Improve custom command (#1942)

* Persist quick command configs

* upgrade langchain and fix gpt-5 in custom command

* Handle thinking in custom command

* code clean up

* Address cursor feedback
This commit is contained in:
Zero Liu 2025-10-20 17:52:50 -07:00 committed by GitHub
parent 3ccbcda9ec
commit 3269943cc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 916 additions and 664 deletions

View file

@ -2,11 +2,19 @@ import esbuild from "esbuild";
import svgPlugin from "esbuild-plugin-svg";
import process from "process";
import wasmPlugin from "./wasmPlugin.mjs";
import nodeModuleShim from "./nodeModuleShim.mjs";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
// Polyfill for import.meta in CommonJS context
if (typeof import_meta === 'undefined') {
var import_meta = {
url: typeof __filename !== 'undefined' ? 'file://' + __filename : 'file:///obsidian-plugin'
};
}
`;
const prod = process.argv[2] === "production";
@ -31,6 +39,14 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
// Node.js built-in modules (available in Electron) - except node:module which we shim
"node:fs",
"node:path",
"node:url",
"node:buffer",
"node:stream",
"node:crypto",
"node:async_hooks",
],
format: "cjs",
target: "es2020",
@ -38,10 +54,11 @@ const context = await esbuild.context({
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
plugins: [svgPlugin(), wasmPlugin],
plugins: [nodeModuleShim, svgPlugin(), wasmPlugin],
define: {
global: "window",
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
"import.meta.url": "import_meta.url",
},
minify: prod,
});

37
nodeModuleShim.mjs Normal file
View file

@ -0,0 +1,37 @@
// Plugin to provide a shim for node:module in browser/Electron renderer context
const nodeModuleShim = {
name: "node-module-shim",
setup(build) {
// Intercept node:module imports and provide a shim
build.onResolve({ filter: /^node:module$/ }, (args) => {
return {
path: args.path,
namespace: "node-module-shim",
};
});
build.onLoad({ filter: /.*/, namespace: "node-module-shim" }, () => {
return {
contents: `
// Shim for node:module in Electron/Obsidian environment (CommonJS format)
module.exports = {
createRequire: function(filename) {
// In Electron renderer, we can use the global require
// Note: filename parameter is ignored (may be undefined from @langchain/community v1.0.0)
if (typeof require !== 'undefined') {
return require;
}
// Fallback: return a function that throws a helpful error
return function shimmedRequire(id) {
throw new Error('Dynamic require of "' + id + '" is not supported in this environment');
};
}
};
`,
loader: "js",
};
});
},
};
export default nodeModuleShim;

1315
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,7 @@
},
"license": "AGPL-3.0",
"devDependencies": {
"@langchain/ollama": "^0.2.0",
"@langchain/ollama": "^1.0.0",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
@ -81,18 +81,19 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@google/generative-ai": "^0.21.0",
"@huggingface/inference": "^2.6.4",
"@huggingface/inference": "^4.11.3",
"@koa/cors": "^5.0.0",
"@langchain/anthropic": "^0.3.30",
"@langchain/cohere": "^0.3.0",
"@langchain/community": "^0.3.22",
"@langchain/core": "^0.3.45",
"@langchain/deepseek": "^0.0.1",
"@langchain/google-genai": "^0.1.6",
"@langchain/groq": "^0.1.2",
"@langchain/mistralai": "^0.2.0",
"@langchain/openai": "^0.6.6",
"@langchain/xai": "^0.0.2",
"@langchain/anthropic": "^1.0.0",
"@langchain/cohere": "^1.0.0",
"@langchain/community": "^1.0.0",
"@langchain/core": "^1.0.1",
"@langchain/deepseek": "^1.0.0",
"@langchain/google-genai": "^1.0.0",
"@langchain/groq": "^1.0.0",
"@langchain/mistralai": "^1.0.0",
"@langchain/openai": "^1.0.0",
"@langchain/textsplitters": "^1.0.0",
"@langchain/xai": "^1.0.0",
"@lexical/plain-text": "^0.34.0",
"@lexical/react": "^0.34.0",
"@lexical/selection": "^0.34.0",
@ -131,7 +132,7 @@
"jotai": "^2.10.3",
"koa": "^2.14.2",
"koa-proxies": "^0.12.3",
"langchain": "^0.3.2",
"langchain": "^1.0.1",
"lexical": "^0.34.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.462.0",

View file

@ -10,6 +10,7 @@ import { deriveReadNoteDisplayName, ToolResultFormatter } from "@/tools/ToolResu
import { ChatMessage, ResponseMetadata, StreamingResult } from "@/types/message";
import { getMessageRole, withSuppressedTokenWarnings } from "@/utils";
import { processToolResults } from "@/utils/toolResultUtils";
import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages";
import { CopilotPlusChainRunner } from "./CopilotPlusChainRunner";
import { addChatHistoryToMessages } from "./utils/chatHistoryUtils";
import {
@ -773,8 +774,17 @@ ${params}
while (retryCount <= maxRetries) {
try {
// Convert ConversationMessage to LangChain BaseMessage format
const langchainMessages: BaseMessage[] = messages.map((msg) => {
if (msg.role === "user") {
return new HumanMessage(msg.content);
} else {
return new AIMessage(msg.content);
}
});
const chatStream = await withSuppressedTokenWarnings(() =>
this.chainManager.chatModelManager.getChatModel().stream(messages, {
this.chainManager.chatModelManager.getChatModel().stream(langchainMessages, {
signal: abortController.signal,
})
);

View file

@ -16,17 +16,24 @@ export class ThinkBlockStreamer {
constructor(
private updateCurrentAiMessage: (message: string) => void,
private modelAdapter?: ModelAdapter
private modelAdapter?: ModelAdapter,
private excludeThinking: boolean = false
) {}
private handleClaude37Chunk(content: any[]) {
let textContent = "";
let hasThinkingContent = false;
for (const item of content) {
switch (item.type) {
case "text":
textContent += item.text;
break;
case "thinking":
hasThinkingContent = true;
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
break;
}
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
@ -36,13 +43,13 @@ export class ThinkBlockStreamer {
this.fullResponse += item.thinking;
}
this.updateCurrentAiMessage(this.fullResponse);
return true; // Indicate we handled a thinking chunk
break;
}
}
if (textContent) {
this.fullResponse += textContent;
}
return false; // No thinking chunk handled
return hasThinkingContent;
}
private handleDeepseekChunk(chunk: any) {
@ -53,6 +60,10 @@ export class ThinkBlockStreamer {
// Handle deepseek reasoning/thinking content
if (chunk.additional_kwargs?.reasoning_content) {
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
return true; // Indicate we handled (but skipped) a thinking chunk
}
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;

View file

@ -206,7 +206,7 @@ export default class ChatModelManager {
},
[ChatModelProviders.GOOGLE]: {
apiKey: await getDecryptedKey(customModel.apiKey || settings.googleApiKey),
modelName: modelName,
model: modelName,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
@ -246,7 +246,7 @@ export default class ChatModelManager {
},
[ChatModelProviders.GROQ]: {
apiKey: await getDecryptedKey(customModel.apiKey || settings.groqApiKey),
modelName: modelName,
model: modelName,
},
[ChatModelProviders.OLLAMA]: {
// ChatOllama has `model` instead of `modelName`!!
@ -475,6 +475,23 @@ export default class ChatModelManager {
return ChatModelManager.chatModel;
}
/**
* langchain 1.0 TypeScript doesn't support temperature override in BaseChatModelCallOptions,
* so we need to create a new model instance with the specified temperature.
*/
async getChatModelWithTemperature(temperature: number): Promise<BaseChatModel> {
const settings = getSettings();
const currentModel = settings.activeModels[0];
// Create a temporary model config with overridden temperature
const modelWithTempOverride: CustomModel = {
...currentModel,
temperature,
};
return await this.createModelInstance(modelWithTempOverride);
}
async setChatModel(model: CustomModel): Promise<void> {
const modelKey = getModelKeyFromModel(model);
try {

View file

@ -1,5 +1,5 @@
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { BaseChatMemory, BufferWindowMemory } from "langchain/memory";
import { BaseChatMemory, BufferWindowMemory } from "@langchain/classic/memory";
import { BaseChatMessageHistory } from "@langchain/core/chat_history";
export default class MemoryManager {

View file

@ -6,8 +6,8 @@ import { StringOutputParser } from "@langchain/core/output_parsers";
import { ChatPromptTemplate, PromptTemplate } from "@langchain/core/prompts";
import { BaseRetriever } from "@langchain/core/retrievers";
import { RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";
import { BaseChatMemory } from "langchain/memory";
import { formatDocumentsAsString } from "langchain/util/document";
import { BaseChatMemory } from "@langchain/classic/memory";
import { formatDocumentsAsString } from "@langchain/classic/util/document";
import { removeThinkTags } from "./utils";
export interface LLMChainInput {
@ -72,7 +72,7 @@ class ChainFactory {
public static createNewLLMChain(args: LLMChainInput): RunnableSequence {
const { llm, memory, prompt, abortController } = args;
const model = llm.bind({ signal: abortController?.signal });
const model = llm.withConfig({ signal: abortController?.signal });
const instance = RunnableSequence.from([
{
input: (initialInput) => initialInput.input,

View file

@ -1,6 +1,5 @@
import ProjectManager from "@/LLMProviders/projectManager";
import { ChatHistoryEntry, removeThinkTags, withSuppressedTokenWarnings } from "@/utils";
import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models";
export async function getStandaloneQuestion(
question: string,
@ -24,10 +23,11 @@ export async function getStandaloneQuestion(
// Wrap the model call with token warning suppression
return await withSuppressedTokenWarnings(async () => {
const chatModel = ProjectManager.instance
// Use temperature=0 for deterministic question condensation
const chatModel = await ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.getChatModel()
.bind({ temperature: 0 } as BaseChatModelCallOptions);
.chatModelManager.getChatModelWithTemperature(0);
const response = await chatModel.invoke([
{
role: "user",

View file

@ -3,6 +3,7 @@ import { processCommandPrompt } from "@/commands/customCommandUtils";
import { Button } from "@/components/ui/button";
import { getModelDisplayText } from "@/components/ui/model-display";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { ThinkBlockStreamer } from "@/LLMProviders/chainRunner/utils/ThinkBlockStreamer";
import { logError } from "@/logger";
import { findCustomModel, insertIntoEditor } from "@/utils";
import {
@ -12,7 +13,7 @@ import {
SystemMessagePromptTemplate,
} from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChatMemory, BufferMemory } from "langchain/memory";
import { BaseChatMemory, BufferMemory } from "@langchain/classic/memory";
import { ArrowBigUp, Bot, Command, Copy, CornerDownLeft, PenLine } from "lucide-react";
import { App, Modal, Notice, Platform } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -94,6 +95,29 @@ function CustomCommandChatModalContent({
const commandTitle = command.title;
/**
* Compute the display value for the textarea
*/
const displayValue = useMemo(() => {
// If we have a processed message, show that
if (processedMessage) {
return processedMessage;
}
// If not generating, show loading
if (!generating) {
return "loading...";
}
// If generating but no content yet, show loading
if (!aiCurrentMessage || aiCurrentMessage.trim() === "") {
return "loading...";
}
// Otherwise show the streaming content
return aiCurrentMessage;
}, [processedMessage, generating, aiCurrentMessage]);
// Reusable function to handle streaming responses wrapped in useCallback
const streamResponse = useCallback(
async (input: string, abortController: AbortController) => {
@ -108,21 +132,31 @@ function CustomCommandChatModalContent({
setAiCurrentMessage(null);
setProcessedMessage(null);
setGenerating(true);
let fullResponse = "";
const chainWithSignal = chatChain.bind({ signal: abortController.signal });
const chainWithSignal = chatChain.withConfig({ signal: abortController.signal });
const stream = await chainWithSignal.stream({ input });
// Initialize ThinkBlockStreamer to handle reasoning content from Claude and Deepseek
// excludeThinking=true means thinking tokens are not included in the response
const thinkStreamer = new ThinkBlockStreamer(
(message: string) => {
setAiCurrentMessage(message);
},
undefined, // no model adapter
true // excludeThinking
);
for await (const chunk of stream) {
if (abortController.signal.aborted) break;
const chunkContent = typeof chunk.content === "string" ? chunk.content : "";
fullResponse += chunkContent;
setAiCurrentMessage(fullResponse);
thinkStreamer.processChunk(chunk);
}
// Close the streamer to finalize the response and close any open think blocks
const result = thinkStreamer.close();
if (!abortController.signal.aborted) {
const trimmedResponse = fullResponse.trim();
const trimmedResponse = result.content.trim();
setProcessedMessage(trimmedResponse);
setGenerating(false);
@ -262,7 +296,7 @@ function CustomCommandChatModalContent({
<textarea
ref={textareaRef}
className="tw-peer tw-h-60 tw-w-full tw-text-text"
value={processedMessage ?? aiCurrentMessage ?? "loading..."}
value={displayValue}
disabled={processedMessage == null}
onChange={(e) => setProcessedMessage(e.target.value)}
/>

View file

@ -10,6 +10,7 @@ import { useModelKey } from "@/aiParams";
import { CustomCommand } from "@/commands/type";
import { removeQuickCommandBlocks } from "@/commands/customCommandUtils";
import CopilotPlugin from "@/main";
import { updateSetting, useSettingsValue } from "@/settings/model";
interface QuickCommandProps {
plugin: CopilotPlugin;
@ -18,12 +19,14 @@ interface QuickCommandProps {
export function QuickCommand({ plugin, onRemove }: QuickCommandProps) {
const [prompt, setPrompt] = useState("");
const [includeActiveNote, setIncludeActiveNote] = useState(true);
const settings = useSettingsValue();
const [selectedText, setSelectedText] = useState("");
const [globalModelKey] = useModelKey();
const [selectedModelKey, setSelectedModelKey] = useState(globalModelKey);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectedModelKey = settings.quickCommandModelKey ?? globalModelKey;
const includeActiveNote = settings.quickCommandIncludeNoteContext;
// Get the currently selected text from the editor
useEffect(() => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
@ -119,6 +122,20 @@ Response format: Match the format implied by the user's request (e.g., if they a
}
};
/**
* Handle model selection change and persist to settings
*/
const handleModelChange = (modelKey: string) => {
updateSetting("quickCommandModelKey", modelKey);
};
/**
* Handle include note context checkbox change and persist to settings
*/
const handleIncludeNoteContextChange = (checked: boolean) => {
updateSetting("quickCommandIncludeNoteContext", checked);
};
return (
<div
className="tw-rounded-lg tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-4"
@ -140,14 +157,14 @@ Response format: Match the format implied by the user's request (e.g., if they a
size="sm"
variant="ghost"
value={selectedModelKey}
onChange={setSelectedModelKey}
onChange={handleModelChange}
/>
<div className="tw-flex tw-items-center tw-gap-2">
<Checkbox
id="includeActiveNote"
checked={includeActiveNote}
onCheckedChange={(checked) => setIncludeActiveNote(!!checked)}
onCheckedChange={(checked) => handleIncludeNoteContextChange(!!checked)}
/>
<label
htmlFor="includeActiveNote"

View file

@ -805,6 +805,8 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
maxRecentConversations: 30,
enableSavedMemory: true,
enableInlineCitations: true,
quickCommandModelKey: undefined,
quickCommandIncludeNoteContext: true,
};
export const EVENT_NAMES = {

View file

@ -8,11 +8,13 @@ export interface ImageContent {
image_url: {
url: string;
};
[key: string]: unknown; // Index signature for LangChain compatibility
}
export interface TextContent {
type: "text";
text: string;
[key: string]: unknown; // Index signature for LangChain compatibility
}
export interface ImageProcessingResult {

View file

@ -14,7 +14,6 @@ import {
} from "@/utils";
import { BaseCallbackConfig } from "@langchain/core/callbacks/manager";
import { Document } from "@langchain/core/documents";
import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { BaseRetriever } from "@langchain/core/retrievers";
import { search } from "@orama/orama";
@ -143,11 +142,11 @@ export class HybridRetriever extends BaseRetriever {
const promptResult = await this.queryRewritePrompt.format({ question: query });
// Execute model invocation with warnings suppressed
const rewrittenQueryObject = await withSuppressedTokenWarnings(() => {
const chatModel = ProjectManager.instance
const rewrittenQueryObject = await withSuppressedTokenWarnings(async () => {
// Use temperature=0 for deterministic HyDE query rewriting
const chatModel = await ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.getChatModel()
.bind({ temperature: 0 } as BaseChatModelCallOptions);
.chatModelManager.getChatModelWithTemperature(0);
return chatModel.invoke(promptResult);
});

View file

@ -5,7 +5,7 @@ import { RateLimiter } from "@/rateLimiter";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { formatDateTime } from "@/utils";
import { MD5 } from "crypto-js";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { App, Notice, TFile } from "obsidian";
import { DBOperations } from "./dbOperations";
import {

View file

@ -1,7 +1,7 @@
import { logInfo, logWarn } from "@/logger";
import { CHUNK_SIZE } from "@/constants";
import { App, TFile } from "obsidian";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
/**
* Chunk interface for unified search system

View file

@ -148,6 +148,10 @@ export interface CopilotSettings {
enableSavedMemory: boolean;
/** Enable inline citations in AI responses with footnote-style references */
enableInlineCitations: boolean;
/** Last selected model for quick command */
quickCommandModelKey: string | undefined;
/** Last checkbox state for including note context in quick command */
quickCommandIncludeNoteContext: boolean;
}
export const settingsStore = createStore();
@ -402,6 +406,20 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
sanitizedSettings.autosaveChat = DEFAULT_SETTINGS.autosaveChat;
}
// Ensure quickCommandIncludeNoteContext has a default value
if (typeof sanitizedSettings.quickCommandIncludeNoteContext !== "boolean") {
sanitizedSettings.quickCommandIncludeNoteContext =
DEFAULT_SETTINGS.quickCommandIncludeNoteContext;
}
// Ensure quickCommandModelKey is either undefined or a string
if (
settingsToSanitize.quickCommandModelKey !== undefined &&
typeof settingsToSanitize.quickCommandModelKey !== "string"
) {
sanitizedSettings.quickCommandModelKey = DEFAULT_SETTINGS.quickCommandModelKey;
}
// Ensure folder settings fall back to defaults when empty/whitespace
const saveFolder = (settingsToSanitize.defaultSaveFolder || "").trim();
sanitizedSettings.defaultSaveFolder =

View file

@ -16,7 +16,7 @@ import { ChatMessage } from "@/types/message";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { MemoryVariables } from "@langchain/core/memory";
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChain, RetrievalQAChain } from "langchain/chains";
import { BaseChain, RetrievalQAChain } from "@langchain/classic/chains";
import moment from "moment";
import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
@ -204,12 +204,12 @@ export const stringToChainType = (chain: string): ChainType => {
// Remove after confirming chainManager no longer uses them
export const isLLMChain = (chain: RunnableSequence): chain is RunnableSequence => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (chain as any).last.bound.modelName || (chain as any).last.bound.model;
return (chain as any).last?.modelName || (chain as any).last?.model;
};
export const isRetrievalQAChain = (chain: BaseChain): chain is RetrievalQAChain => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (chain as any).last.bound.retriever !== undefined;
return (chain as any).last?.retriever !== undefined;
};
export const isSupportedChain = (chain: RunnableSequence): chain is RunnableSequence => {