From f6edc321e30c7b72d94d7c5d4b650c71fe44dd93 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 21 May 2026 16:31:15 +0200 Subject: [PATCH] Add Ollama model selector dropdown and improve automatic tool detection - Add `listModels()`, `setModel()`, and `getModel()` methods to `OllamaClient` - Populate model dropdown in chat view from `/api/tags` endpoint - Synchronize model selection across main and agent clients - Detect user intent for vault operations from message content - Expand action phrase matching for `shouldAutoRunReadTools()` - Streamline retry logic and suppress "Let me..." text with actual tool calls - Improve fallback search query generation by removing more filler words --- main.js | 177 ++++++++++++++++++++++++++++++++++++------- src/chat-view.ts | 166 ++++++++++++++++++++++++++++++++-------- src/ollama-client.ts | 53 +++++++++++++ styles.css | 19 +++++ 4 files changed, 355 insertions(+), 60 deletions(-) diff --git a/main.js b/main.js index afaee50..672044b 100644 --- a/main.js +++ b/main.js @@ -8508,6 +8508,43 @@ var OllamaClient = class { } return true; } + async listModels() { + try { + const response = await this.fetchFn(`${this.baseURL}/api/tags`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }); + if (!response.ok) { + throw new ApiError(`Ollama API error: ${response.status}`, response.status); + } + const data = await response.json(); + if (typeof data === "object" && data !== null && "models" in data && Array.isArray(data.models)) { + const models = data.models; + return models.filter( + (m) => typeof m === "object" && m !== null && "name" in m && typeof m.name === "string" + ).map((m) => ({ + name: m.name, + size: m.size, + modified: m.modified_at + })); + } + return []; + } catch (error) { + Logger.warn( + `Failed to list models: ${error instanceof Error ? error.message : String(error)}`, + "ollama-client" + ); + return []; + } + } + setModel(model) { + this.model = model; + } + getModel() { + return this.model; + } }; // src/vault-indexer.ts @@ -10887,6 +10924,7 @@ var ChatView = class extends import_obsidian5.ItemView { this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.modeSelectorEl = null; + this.modelSelectorEl = null; this.historySelectEl = null; this.historyDeleteButton = null; this.pendingActions = []; @@ -11117,6 +11155,20 @@ var ChatView = class extends import_obsidian5.ItemView { } else { newChatContainer.appendChild(this.modeSelectorEl); } + if (!this.modelSelectorEl) { + this.modelSelectorEl = newChatContainer.createEl("select", { + cls: "ollama-model-selector" + }); + this.modelSelectorEl.addEventListener("change", () => { + const selectedModel = this.modelSelectorEl.value; + this.ollamaClient.setModel(selectedModel); + if (this.agentOllamaClient !== this.ollamaClient) { + this.agentOllamaClient.setModel(selectedModel); + } + }); + } + this.populateModelDropdown(); + newChatContainer.appendChild(this.modelSelectorEl); if (!this.historySelectEl) { this.historySelectEl = newChatContainer.createEl("select", { cls: "ollama-history-selector" @@ -11375,6 +11427,36 @@ var ChatView = class extends import_obsidian5.ItemView { this.historySelectEl.value = "__new__"; } } + async populateModelDropdown() { + if (!this.modelSelectorEl) return; + const previousValue = this.modelSelectorEl.value; + this.modelSelectorEl.innerHTML = ""; + const models = await this.ollamaClient.listModels(); + const currentModel = this.ollamaClient.getModel(); + if (models.length === 0) { + const option = this.modelSelectorEl.createEl("option", { + text: currentModel, + attr: { value: currentModel } + }); + option.setAttribute("selected", "selected"); + return; + } + for (const model of models) { + const displayName = model.name; + const option = this.modelSelectorEl.createEl("option", { + text: displayName, + attr: { value: model.name } + }); + if (model.name === currentModel || model.name === previousValue) { + option.setAttribute("selected", "selected"); + } + } + if (previousValue && models.some((m) => m.name === previousValue)) { + this.modelSelectorEl.value = previousValue; + } else if (models.some((m) => m.name === currentModel)) { + this.modelSelectorEl.value = currentModel; + } + } updateMessageById(id, updates) { const index = this.messages.findIndex((m) => m.id === id); if (index !== -1) { @@ -12053,13 +12135,21 @@ ${actualMessage}` : actualMessage; let shouldFallbackToReadTools = false; const toolCapableModes = ["edit", "organize", "research"]; const isToolCapable = toolCapableModes.includes(this.currentAgentMode); - if (isToolCapable && toolCalls.length === 0 && fullResponse.trim().length > 0) { - shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); - if (shouldFallbackToReadTools) { + const userWantsVaultOps = this.userMessageImpliesToolUse(actualMessage); + const modelMentionedActions = isToolCapable && this.shouldAutoRunReadTools(fullResponse); + if (isToolCapable && toolCalls.length === 0) { + if (modelMentionedActions || userWantsVaultOps) { + shouldFallbackToReadTools = true; + fullResponse = ""; + this.updateMessageById(assistantMessageId, { + content: "", + isStreaming: true, + isThinking: false + }); let attempts = 0; const maxAttempts = 3; let currentMessages = [...messagesWithMemory]; - let currentResponse = fullResponse; + let currentResponse = ""; while (attempts < maxAttempts && toolCalls.length === 0) { attempts++; this.showActivityIndicator( @@ -12100,25 +12190,20 @@ ${actualMessage}` : actualMessage; } currentMessages = nudgeMessages; } - } - } - if (isToolCapable && toolCalls.length === 0) { - if (!shouldFallbackToReadTools) { - shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); - } - if (shouldFallbackToReadTools) { - const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools); - if (autoToolCalls.length > 0) { - this.showActivityIndicator("Using tools\u2026"); - toolCalls = autoToolCalls; - fullResponse = ""; - await this.processToolCalls( - autoToolCalls, - messagesWithMemory, - tools, - fullResponse, - assistantMessageId - ); + if (toolCalls.length === 0) { + const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools); + if (autoToolCalls.length > 0) { + this.showActivityIndicator("Using tools\u2026"); + toolCalls = autoToolCalls; + fullResponse = ""; + await this.processToolCalls( + autoToolCalls, + messagesWithMemory, + tools, + fullResponse, + assistantMessageId + ); + } } } } @@ -12188,14 +12273,52 @@ ${actualMessage}` : actualMessage; "let me", "i will", "i need to", + "i should", "search for", "look for", "read", "explore", - "check" + "check", + "find", + "analyze", + "examine", + "review", + "inspect", + "investigate", + "scan" ]; return intentPhrases.some((phrase) => lowerResponse.includes(phrase)); } + userMessageImpliesToolUse(message) { + const toolCapableModes = ["edit", "organize", "research"]; + if (!toolCapableModes.includes(this.currentAgentMode)) { + return false; + } + const lower = message.toLowerCase(); + const operationPhrases = [ + "organize", + "structure", + "folder", + "tag", + "move", + "rename", + "create", + "delete", + "search", + "find", + "look for", + "read", + "show me", + "list", + "what is in", + "what are", + "tell me about", + "vault", + "notes", + "files" + ]; + return operationPhrases.some((phrase) => lower.includes(phrase)); + } buildAutomaticReadToolCalls(message, tools) { const availableTools = new Set(tools.map((tool) => tool.function.name)); const lowerMessage = message.toLowerCase(); @@ -12232,10 +12355,10 @@ ${actualMessage}` : actualMessage; } buildAutomaticSearchQuery(message) { const lowerMessage = message.toLowerCase().trim(); - if (lowerMessage === "continue" || lowerMessage === "please continue") { - return "folder structure tags organization vault index prompts"; + if (lowerMessage === "continue" || lowerMessage === "please continue" || lowerMessage === "go ahead") { + return "folder structure tags organization vault index"; } - return message.replace(/\bplease\b/gi, "").replace(/\bcontinue\b/gi, "").replace(/\bimplement\b/gi, "").replace(/\bcreate\b/gi, "").replace(/\bmove\b/gi, "").replace(/\bnotes?\b/gi, "").replace(/\bfolders?\b/gi, "").replace(/\bstructure\b/gi, "").replace(/\s+/g, " ").trim(); + return message.replace(/\bplease\b/gi, "").replace(/\bcontinue\b/gi, "").replace(/\bgo ahead\b/gi, "").replace(/\bimplement\b/gi, "").replace(/\bmove\b/gi, "").replace(/\s+/g, " ").trim(); } showActivityIndicator(text) { if (!this.activityIndicatorEl) return; diff --git a/src/chat-view.ts b/src/chat-view.ts index d17452b..3285f2e 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -301,6 +301,22 @@ export class ChatView extends ItemView { newChatContainer.appendChild(this.modeSelectorEl); } + // Setup model selector + if (!this.modelSelectorEl) { + this.modelSelectorEl = newChatContainer.createEl('select', { + cls: 'ollama-model-selector', + }); + this.modelSelectorEl.addEventListener('change', () => { + const selectedModel = this.modelSelectorEl!.value; + this.ollamaClient.setModel(selectedModel); + if (this.agentOllamaClient !== this.ollamaClient) { + this.agentOllamaClient.setModel(selectedModel); + } + }); + } + this.populateModelDropdown(); + newChatContainer.appendChild(this.modelSelectorEl); + // Setup chat history selector if (!this.historySelectEl) { this.historySelectEl = newChatContainer.createEl('select', { @@ -619,6 +635,43 @@ export class ChatView extends ItemView { } } + private async populateModelDropdown(): Promise { + if (!this.modelSelectorEl) return; + const previousValue = this.modelSelectorEl.value; + this.modelSelectorEl.innerHTML = ''; + + const models = await this.ollamaClient.listModels(); + const currentModel = this.ollamaClient.getModel(); + + if (models.length === 0) { + // Fallback: use the configured model name if API is unavailable + const option = this.modelSelectorEl.createEl('option', { + text: currentModel, + attr: { value: currentModel }, + }); + option.setAttribute('selected', 'selected'); + return; + } + + for (const model of models) { + const displayName = model.name; + const option = this.modelSelectorEl.createEl('option', { + text: displayName, + attr: { value: model.name }, + }); + if (model.name === currentModel || model.name === previousValue) { + option.setAttribute('selected', 'selected'); + } + } + + // If the previously selected value is still valid, keep it + if (previousValue && models.some((m) => m.name === previousValue)) { + this.modelSelectorEl.value = previousValue; + } else if (models.some((m) => m.name === currentModel)) { + this.modelSelectorEl.value = currentModel; + } + } + updateMessageById(id: string, updates: Partial): void { const index = this.messages.findIndex((m) => m.id === id); if (index !== -1) { @@ -1407,15 +1460,27 @@ export class ChatView extends ItemView { const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research']; const isToolCapable = toolCapableModes.includes(this.currentAgentMode); - if (isToolCapable && toolCalls.length === 0 && fullResponse.trim().length > 0) { - shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); + // Determine if automatic tools should run based on model response OR user intent + const userWantsVaultOps = this.userMessageImpliesToolUse(actualMessage); + const modelMentionedActions = isToolCapable && this.shouldAutoRunReadTools(fullResponse); - // If the model output action-text without tools, suppress it and retry up to 3 times - if (shouldFallbackToReadTools) { + if (isToolCapable && toolCalls.length === 0) { + if (modelMentionedActions || userWantsVaultOps) { + shouldFallbackToReadTools = true; + + // Suppress the model's "Let me..." text — replace with actual tool execution + fullResponse = ''; + this.updateMessageById(assistantMessageId, { + content: '', + isStreaming: true, + isThinking: false, + }); + + // Try aggressive retry loop first (hidden from user) let attempts = 0; const maxAttempts = 3; let currentMessages: OllamaMessage[] = [...messagesWithMemory]; - let currentResponse = fullResponse; + let currentResponse = ''; while (attempts < maxAttempts && toolCalls.length === 0) { attempts++; @@ -1423,7 +1488,6 @@ export class ChatView extends ItemView { attempts === 1 ? 'Thinking…' : `Retrying (${attempts}/${maxAttempts})…` ); - // Replace the assistant's text-only response with a forced instruction const nudgeMessages: OllamaMessage[] = [ ...currentMessages, { role: 'assistant', content: currentResponse }, @@ -1464,30 +1528,24 @@ export class ChatView extends ItemView { break; } - // If still no tools, continue the loop with the new response as context currentMessages = nudgeMessages; } - } - } - // Final fallback: if the model NEVER emitted tools but clearly intended to, force automatic read tools - if (isToolCapable && toolCalls.length === 0) { - if (!shouldFallbackToReadTools) { - shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); - } - if (shouldFallbackToReadTools) { - const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools); - if (autoToolCalls.length > 0) { - this.showActivityIndicator('Using tools…'); - toolCalls = autoToolCalls; - fullResponse = ''; - await this.processToolCalls( - autoToolCalls, - messagesWithMemory, - tools, - fullResponse, - assistantMessageId - ); + // If retries all failed, force automatic read tools immediately + if (toolCalls.length === 0) { + const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools); + if (autoToolCalls.length > 0) { + this.showActivityIndicator('Using tools…'); + toolCalls = autoToolCalls; + fullResponse = ''; + await this.processToolCalls( + autoToolCalls, + messagesWithMemory, + tools, + fullResponse, + assistantMessageId + ); + } } } } @@ -1573,15 +1631,55 @@ export class ChatView extends ItemView { 'let me', 'i will', 'i need to', + 'i should', 'search for', 'look for', 'read', 'explore', 'check', + 'find', + 'analyze', + 'examine', + 'review', + 'inspect', + 'investigate', + 'scan', ]; return intentPhrases.some((phrase) => lowerResponse.includes(phrase)); } + private userMessageImpliesToolUse(message: string): boolean { + const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research']; + if (!toolCapableModes.includes(this.currentAgentMode)) { + return false; + } + + const lower = message.toLowerCase(); + const operationPhrases = [ + 'organize', + 'structure', + 'folder', + 'tag', + 'move', + 'rename', + 'create', + 'delete', + 'search', + 'find', + 'look for', + 'read', + 'show me', + 'list', + 'what is in', + 'what are', + 'tell me about', + 'vault', + 'notes', + 'files', + ]; + return operationPhrases.some((phrase) => lower.includes(phrase)); + } + private buildAutomaticReadToolCalls(message: string, tools: OllamaTool[]): OllamaToolCall[] { const availableTools = new Set(tools.map((tool) => tool.function.name)); const lowerMessage = message.toLowerCase(); @@ -1629,19 +1727,20 @@ export class ChatView extends ItemView { private buildAutomaticSearchQuery(message: string): string { const lowerMessage = message.toLowerCase().trim(); - if (lowerMessage === 'continue' || lowerMessage === 'please continue') { - return 'folder structure tags organization vault index prompts'; + if ( + lowerMessage === 'continue' || + lowerMessage === 'please continue' || + lowerMessage === 'go ahead' + ) { + return 'folder structure tags organization vault index'; } return message .replace(/\bplease\b/gi, '') .replace(/\bcontinue\b/gi, '') + .replace(/\bgo ahead\b/gi, '') .replace(/\bimplement\b/gi, '') - .replace(/\bcreate\b/gi, '') .replace(/\bmove\b/gi, '') - .replace(/\bnotes?\b/gi, '') - .replace(/\bfolders?\b/gi, '') - .replace(/\bstructure\b/gi, '') .replace(/\s+/g, ' ') .trim(); } @@ -1675,6 +1774,7 @@ export class ChatView extends ItemView { private vectorStore?: VaultVectorStore; private modeSelectorEl: HTMLSelectElement | null = null; + private modelSelectorEl: HTMLSelectElement | null = null; private historySelectEl: HTMLSelectElement | null = null; private historyDeleteButton: HTMLElement | null = null; private currentAgentMode: AgentMode; diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 6bd58a5..d9c552f 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -401,4 +401,57 @@ export class OllamaClient { return true; } + + async listModels(): Promise<{ name: string; size?: number; modified?: string }[]> { + try { + const response = await this.fetchFn(`${this.baseURL}/api/tags`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new ApiError(`Ollama API error: ${response.status}`, response.status); + } + + const data = (await response.json()) as unknown; + if ( + typeof data === 'object' && + data !== null && + 'models' in data && + Array.isArray((data as { models: unknown }).models) + ) { + const models = (data as { models: unknown[] }).models; + return models + .filter( + (m): m is { name: string; size?: number; modified_at?: string } => + typeof m === 'object' && + m !== null && + 'name' in m && + typeof (m as { name: unknown }).name === 'string' + ) + .map((m) => ({ + name: m.name, + size: m.size, + modified: m.modified_at, + })); + } + return []; + } catch (error) { + Logger.warn( + `Failed to list models: ${error instanceof Error ? error.message : String(error)}`, + 'ollama-client' + ); + return []; + } + } + + setModel(model: string): void { + this.model = model; + } + + getModel(): string { + return this.model; + } } diff --git a/styles.css b/styles.css index c1abac5..f64a62a 100644 --- a/styles.css +++ b/styles.css @@ -314,6 +314,25 @@ border-color: var(--interactive-accent); } +/* Model Selector */ +.ollama-model-selector { + padding: var(--size-4-1) var(--size-4-2); + border-radius: var(--ollama-radius); + border: 1px solid var(--ollama-border); + background-color: var(--background-modifier-form-field); + color: var(--text-normal); + font-size: var(--font-ui-small); + cursor: pointer; + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.ollama-model-selector:focus { + outline: none; + border-color: var(--interactive-accent); +} + /* Chat History Selector */ .ollama-history-selector { padding: var(--size-4-1) var(--size-4-2);