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
This commit is contained in:
2026-05-21 16:31:15 +02:00
parent 51208e2031
commit f6edc321e3
4 changed files with 355 additions and 60 deletions
+150 -27
View File
@@ -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;
+133 -33
View File
@@ -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<void> {
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<ChatMessage>): 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;
+53
View File
@@ -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;
}
}
+19
View File
@@ -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);