Files
obsidian_ollama/src/chat-view.ts
T
fegger b7b3a185a0 Add automatic folder creation for file operations and improve chat session handling
Implement `ensureFolderExists` in `ToolExecutor` to create parent directories when creating, moving, or renaming notes.
Update `ChatView` to properly persist agent mode and derive session titles from the first user message. Fix error
handling in tool execution to return structured failures instead of null, and include failure details in follow-up
messages.
2026-05-21 20:35:05 +02:00

2015 lines
66 KiB
TypeScript
Executable File

import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
import {
ALL_AGENT_MODES,
getAgentModeLabel,
getSystemPromptForMode,
filterToolsForMode,
modeRequiresPreview,
} from './agent-modes';
import { OllamaClient } from './ollama-client';
import { VaultIndexer } from './vault-indexer';
import { VaultVectorStore } from './vault-vector-store';
import { ToolExecutor } from './tool-executor';
import { ActionPreviewBuilder, isWriteTool } from './action-preview-builder';
import { WorkflowEngine } from './workflow-engine';
import { NoteContextBuilder } from './note-context-builder';
import {
AgentMode,
PluginSettings,
OllamaMessage,
OllamaTool,
OllamaToolCall,
ChatMessage,
ProposedAction,
ToolResult,
ChatSession,
} from './types';
import { ConversationStateManager } from './conversation-state';
import { ErrorHandler } from './error-handler';
import { StructuredMemoryManager } from './structured-memory';
import { TelemetryManager } from './tool-telemetry';
import { ChatHistoryManager } from './chat-history';
import { Logger, LogEntry } from './utils';
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
export class ChatView extends ItemView {
// Getters for testing
getSendButtonClickHandler() {
return this.sendButtonClickHandler;
}
getInputKeyDownHandler() {
return this.inputKeyDownHandler;
}
getNewChatButtonClickHandler() {
return this.newChatButtonClickHandler;
}
getHistorySelectEl() {
return this.historySelectEl;
}
constructor(
leaf: WorkspaceLeaf,
settings: PluginSettings,
vectorStore?: VaultVectorStore,
structuredMemoryManager?: StructuredMemoryManager,
telemetryManager?: TelemetryManager,
chatHistoryManager?: ChatHistoryManager,
onModelChange?: (model: string) => void
) {
super(leaf);
this.messages = [];
this.lastMessageEl = null;
this.newChatButton = null;
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
this.sendButtonClickHandler = null;
this.inputKeyDownHandler = null;
this.newChatButtonClickHandler = null;
this.sendButtonClickWrapper = null;
this.inputKeyDownWrapper = null;
this.newChatButtonClickWrapper = null;
this.listenersAttached = false;
this.settings = settings;
this.currentAgentMode = settings.agentMode ?? 'ask';
this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings);
this.agentOllamaClient =
(settings.agentModel ?? settings.model) === (settings.chatModel ?? settings.model)
? this.ollamaClient
: this.createOllamaClient(settings.agentModel ?? settings.model, settings);
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
this.vaultIndexer.setApp(this.app);
this.toolExecutor = new ToolExecutor(
this.app.vault,
this.app,
telemetryManager,
this.vaultIndexer
);
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
this.conversationStateManager = new ConversationStateManager(
getSystemPromptForMode(this.currentAgentMode)
);
this.structuredMemoryManager = structuredMemoryManager;
this.telemetryManager = telemetryManager;
this.chatHistoryManager = chatHistoryManager;
this.onModelChange = onModelChange;
this.workflowEngine = new WorkflowEngine(
this.app.vault,
this.app,
settings.ollamaUrl,
settings.agentModel ?? settings.model,
{ cacheConfig: settings.cacheConfig }
);
// Subscribe to plugin logs
this.removeLogListener = Logger.addListener((entry) => {
if (this.logsVisible && this.logsContainer) {
this.appendLogEntry(entry);
}
});
// Restore active session if available
this.restoreActiveSession();
}
updateSettings(newSettings: PluginSettings) {
this.settings = newSettings;
this.currentAgentMode = newSettings.agentMode ?? 'ask';
if (this.modeSelectorEl) {
this.modeSelectorEl.value = this.currentAgentMode;
}
this.ollamaClient = this.createOllamaClient(
newSettings.chatModel ?? newSettings.model,
newSettings
);
this.agentOllamaClient =
(newSettings.agentModel ?? newSettings.model) === (newSettings.chatModel ?? newSettings.model)
? this.ollamaClient
: this.createOllamaClient(newSettings.agentModel ?? newSettings.model, newSettings);
// Refresh model dropdown so it reflects the newly saved model
void this.populateModelDropdown();
this.workflowEngine = new WorkflowEngine(
this.app.vault,
this.app,
newSettings.ollamaUrl,
newSettings.agentModel ?? newSettings.model,
{ cacheConfig: newSettings.cacheConfig }
);
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(this.currentAgentMode));
void this.initializeClientCaches().catch(() => {
new Notice(
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
);
});
}
setVectorStore(vectorStore: VaultVectorStore | undefined) {
this.vectorStore = vectorStore;
this.vaultIndexer.setVectorStore(vectorStore);
}
async clearCache(): Promise<void> {
await this.ollamaClient.clearCache();
if (this.agentOllamaClient !== this.ollamaClient) {
await this.agentOllamaClient.clearCache();
}
}
getViewType(): string {
return 'ollama-chat-view';
}
getDisplayText(): string {
return 'Ollama Chat';
}
getIcon(): string {
return 'bot';
}
async onOpen(): Promise<void> {
try {
await this.initializeClientCaches();
} catch {
new Notice(
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
);
}
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
}
onSettingsChange(newSettings: PluginSettings): void {
this.updateSettings(newSettings);
}
async onClose(): Promise<void> {
this.saveCurrentSession();
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
if (this.removeLogListener) {
this.removeLogListener();
this.removeLogListener = null;
}
if (this.logsContainer && this.logsContainer.parentElement) {
this.logsContainer.parentElement.removeChild(this.logsContainer);
}
this.lastMessageEl = null;
this.sendButton = null;
this.stopButton = null;
this.inputEl = null;
this.chatContainer = null;
this.showLogsButton = null;
this.logsContainer = null;
this.modelSelectorEl = null;
this.historySelectEl = null;
this.historyDeleteButton = null;
return Promise.resolve();
}
cleanupStreamingResources(): void {
// Only cleanup if there's still an active streaming message
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
this.lastMessageEl = null;
}
}
render(): void {
const container =
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
this.chatContainer = container;
const inputContainer =
this.contentEl.querySelector('.ollama-input-container') ||
this.contentEl.createEl('div', { cls: 'ollama-input-container' });
const newChatContainer =
this.contentEl.querySelector('.ollama-new-chat-container') ||
this.contentEl.createEl('div', { cls: 'ollama-new-chat-container' });
const messagesSnapshot = [...this.messages];
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
const existingMessages = container.querySelectorAll('.ollama-message');
// Remove messages that are no longer in the array
for (const el of Array.from(existingMessages)) {
const id = el.getAttribute('data-msg-id');
if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
el.remove();
}
}
// Render non-streaming messages
for (const msg of nonStreamingMessages) {
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
if (existingEl) {
const contentEl = existingEl.querySelector('.ollama-message-content');
if (contentEl) {
if (msg.isThinking) {
contentEl.innerHTML = '<span class="ollama-thinking-indicator">Thinking…</span>';
} else {
contentEl.textContent = msg.content;
}
}
} else {
const messageEl = container.createEl('div', {
cls: `ollama-message ollama-message-${msg.role}`,
});
messageEl.setAttribute('data-msg-id', msg.id);
const headerEl = messageEl.createEl('div', { cls: 'ollama-message-header' });
headerEl.createEl('span', { cls: 'ollama-message-role', text: msg.role });
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
if (msg.isThinking) {
contentEl.innerHTML = '<span class="ollama-thinking-indicator">Thinking…</span>';
} else {
contentEl.textContent = msg.content;
}
}
}
// Re-attach streaming message if it exists
const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
if (streamingMessage && this.lastMessageEl) {
const existingStreamingEl = container.querySelector(
`.ollama-message[data-msg-id="${streamingMessage.id}"]`
);
if (!existingStreamingEl) {
container.appendChild(this.lastMessageEl);
}
}
// 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.onModelChange?.(selectedModel);
});
}
this.populateModelDropdown();
newChatContainer.appendChild(this.modelSelectorEl);
// Setup mode selector
if (!this.modeSelectorEl) {
this.modeSelectorEl = newChatContainer.createEl('select', {
cls: 'ollama-mode-selector',
});
for (const mode of ALL_AGENT_MODES) {
const option = this.modeSelectorEl.createEl('option', {
text: getAgentModeLabel(mode),
attr: { value: mode },
});
if (mode === this.currentAgentMode) {
option.setAttribute('selected', 'selected');
}
}
this.modeSelectorEl.addEventListener('change', () => {
this.setAgentMode(this.modeSelectorEl!.value as AgentMode);
});
} else {
newChatContainer.appendChild(this.modeSelectorEl);
}
// Setup chat history selector
if (!this.historySelectEl) {
this.historySelectEl = newChatContainer.createEl('select', {
cls: 'ollama-history-selector',
});
this.historySelectEl.addEventListener('change', () => {
const selectedId = this.historySelectEl!.value;
if (selectedId === '__new__') {
this.clearConversation();
} else if (selectedId) {
const session = this.chatHistoryManager?.getSession(selectedId);
if (session) {
this.chatHistoryManager?.setActiveSessionId(selectedId);
this.loadSession(session);
this.render();
}
}
});
}
this.populateHistoryDropdown();
newChatContainer.appendChild(this.historySelectEl);
// Setup new chat button
if (!this.newChatButton) {
this.newChatButton = newChatContainer.createEl('button', {
cls: 'ollama-new-chat-button',
text: 'New Chat',
});
} else {
newChatContainer.appendChild(this.newChatButton);
}
// Setup delete history button
if (!this.historyDeleteButton) {
this.historyDeleteButton = newChatContainer.createEl('button', {
cls: 'ollama-history-delete-button',
text: 'Delete',
});
this.historyDeleteButton.addEventListener('click', () => {
const selectedId = this.historySelectEl?.value;
if (selectedId && selectedId !== '__new__') {
const deleted = this.chatHistoryManager?.deleteSession(selectedId);
if (deleted) {
new Notice('Chat deleted.');
this.clearConversation();
}
}
});
}
newChatContainer.appendChild(this.historyDeleteButton);
// Setup show-logs toggle button
if (!this.showLogsButton) {
this.showLogsButton = newChatContainer.createEl('button', {
cls: 'ollama-show-logs-button',
text: this.logsVisible ? 'Hide Logs' : 'Show Logs',
});
} else {
this.showLogsButton.textContent = this.logsVisible ? 'Hide Logs' : 'Show Logs';
newChatContainer.appendChild(this.showLogsButton);
}
// Setup / toggle logs container
if (this.logsVisible) {
if (!this.logsContainer) {
this.logsContainer = this.contentEl.createEl('div', { cls: 'ollama-logs-container' });
// Populate with existing history
for (const entry of Logger.getHistory()) {
this.renderLogEntry(entry, this.logsContainer);
}
}
this.contentEl.insertBefore(this.logsContainer, inputContainer);
this.scrollLogsToBottom();
} else if (this.logsContainer && this.logsContainer.parentElement) {
this.logsContainer.parentElement.removeChild(this.logsContainer);
}
// Setup input area
if (!this.inputEl) {
this.inputEl = inputContainer.createEl('textarea', {
cls: 'ollama-input',
attr: { placeholder: 'Type your message...', rows: '3' },
});
} else {
inputContainer.appendChild(this.inputEl);
}
// Setup activity indicator
if (!this.activityIndicatorEl) {
this.activityIndicatorEl = inputContainer.createEl('div', {
cls: 'ollama-activity-indicator',
});
this.activityIndicatorEl.createEl('span', { cls: 'ollama-activity-dot' });
this.activityIndicatorEl.createEl('span', {
cls: 'ollama-activity-text',
text: '',
});
this.hideActivityIndicator();
} else {
inputContainer.appendChild(this.activityIndicatorEl);
}
// Setup send button
if (!this.sendButton) {
this.sendButton = inputContainer.createEl('button', {
cls: 'ollama-send-button',
text: 'Send',
});
} else {
inputContainer.appendChild(this.sendButton);
}
// Setup stop button
if (!this.stopButton) {
this.stopButton = inputContainer.createEl('button', {
cls: 'ollama-stop-button',
text: 'Stop',
});
this.stopButton.addEventListener('click', () => {
this.cancelCurrentOperation();
});
}
inputContainer.appendChild(this.stopButton);
this.stopButton.style.display = 'none';
// Append containers to contentEl
if (this.contentEl.addClass) {
this.contentEl.addClass('ollama-chat-view-content');
} else {
this.contentEl.classList?.add('ollama-chat-view-content');
}
this.contentEl.appendChild(newChatContainer);
this.contentEl.appendChild(container);
this.contentEl.appendChild(inputContainer);
// Auto-scroll chat to bottom if enabled
if (this.shouldAutoScroll && container) {
container.scrollTop = container.scrollHeight;
}
// Focus input on open
this.inputEl.focus();
}
setupEventListeners(): void {
if (this.listenersAttached) {
return;
}
this.sendButtonClickHandler = () => {
void this.handleUserInput(this.inputEl?.value);
};
this.inputKeyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void this.handleUserInput(this.inputEl?.value);
}
};
this.newChatButtonClickHandler = () => {
this.clearConversation();
};
this.showLogsClickHandler = () => {
this.logsVisible = !this.logsVisible;
this.render();
};
if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
}
if (this.inputEl && this.inputKeyDownHandler) {
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
}
if (this.showLogsButton && this.showLogsClickHandler) {
this.showLogsButton.addEventListener('click', this.showLogsClickHandler);
}
// Chat container scroll listener for auto-scroll toggle
if (this.chatContainer) {
this.chatScrollHandler = () => {
if (!this.chatContainer) return;
const { scrollTop, scrollHeight, clientHeight } = this.chatContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
this.shouldAutoScroll = isNearBottom;
};
this.chatContainer.addEventListener('scroll', this.chatScrollHandler);
}
this.listenersAttached = true;
}
removeEventListeners(): void {
if (!this.listenersAttached) {
return;
}
if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
}
if (this.inputEl && this.inputKeyDownHandler) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
}
if (this.showLogsButton && this.showLogsClickHandler) {
this.showLogsButton.removeEventListener('click', this.showLogsClickHandler);
}
if (this.chatContainer && this.chatScrollHandler) {
this.chatContainer.removeEventListener('scroll', this.chatScrollHandler);
}
this.listenersAttached = false;
}
getAgentMode(): AgentMode {
return this.currentAgentMode;
}
setAgentMode(mode: AgentMode): void {
this.currentAgentMode = mode;
if (this.modeSelectorEl) {
this.modeSelectorEl.value = mode;
}
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
const activeId = this.chatHistoryManager?.getActiveSessionId();
if (activeId) {
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
}
}
clearConversation(): void {
// Save the current session before clearing
this.saveCurrentSession();
this.messages = [];
this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode));
// Create a new session for the fresh conversation
this.chatHistoryManager?.createSession(this.currentAgentMode);
this.render();
}
private restoreActiveSession(): void {
if (!this.chatHistoryManager) return;
const activeId = this.chatHistoryManager.getActiveSessionId();
if (activeId) {
const session = this.chatHistoryManager.getSession(activeId);
if (session) {
this.loadSession(session);
return;
}
}
// No active session: create one
this.chatHistoryManager.createSession(this.currentAgentMode);
}
private loadSession(session: ChatSession): void {
this.messages = [...session.messages];
this.currentAgentMode = session.agentMode;
this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode));
// Rebuild conversation state from messages
for (const msg of this.messages) {
if (msg.role === 'user' || msg.role === 'assistant') {
this.conversationStateManager.updateShortTermContext({
role: msg.role,
content: msg.content,
});
}
}
if (this.modeSelectorEl) {
this.modeSelectorEl.value = this.currentAgentMode;
}
}
private saveCurrentSession(): void {
if (!this.chatHistoryManager) return;
const activeId = this.chatHistoryManager.getActiveSessionId();
if (!activeId) return;
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
this.chatHistoryManager.updateSession(activeId, {
messages: nonStreamingMessages,
agentMode: this.currentAgentMode,
title: this.deriveSessionTitle(nonStreamingMessages),
});
}
private syncMessagesToSession(): void {
if (!this.chatHistoryManager) return;
const activeId = this.chatHistoryManager.getActiveSessionId();
if (!activeId) return;
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
this.chatHistoryManager.updateSession(activeId, {
messages: nonStreamingMessages,
agentMode: this.currentAgentMode,
title: this.deriveSessionTitle(nonStreamingMessages),
});
}
private deriveSessionTitle(messages: ChatMessage[]): string {
const firstUser = messages.find((m) => m.role === 'user');
const text = firstUser?.content.trim() ?? '';
if (!text) return 'New Chat';
return text.length > 40 ? text.slice(0, 40) + '…' : text;
}
private populateHistoryDropdown(): void {
if (!this.historySelectEl) return;
const previousValue = this.historySelectEl.value;
this.historySelectEl.innerHTML = '';
// New Chat option
const newOption = this.historySelectEl.createEl('option', {
text: 'New Chat',
attr: { value: '__new__' },
});
const sessions = this.chatHistoryManager?.getSessions() ?? [];
const activeId = this.chatHistoryManager?.getActiveSessionId();
for (const session of sessions) {
const option = this.historySelectEl.createEl('option', {
text: session.title,
attr: { value: session.id },
});
if (session.id === activeId) {
option.setAttribute('selected', 'selected');
}
}
// If the previously selected value is still valid, keep it; otherwise select active or New Chat
if (previousValue && sessions.some((s) => s.id === previousValue)) {
this.historySelectEl.value = previousValue;
} else if (activeId) {
this.historySelectEl.value = activeId;
} else {
this.historySelectEl.value = '__new__';
}
}
private async populateModelDropdown(): Promise<void> {
if (!this.modelSelectorEl) return;
const previousValue = this.modelSelectorEl.value;
this.modelSelectorEl.innerHTML = '';
const models = await this.ollamaClient.listModels();
if (!this.modelSelectorEl) return; // Guard: view may have closed while fetching
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) {
if (!this.modelSelectorEl) return;
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 (!this.modelSelectorEl) return;
// 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) {
this.messages[index] = { ...this.messages[index], ...updates };
this.render();
this.syncMessagesToSession();
}
}
updateLastMessage(updates: Partial<ChatMessage>): void {
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage) {
const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id);
if (index !== -1) {
this.messages[index] = { ...this.messages[index], ...updates };
this.render();
this.syncMessagesToSession();
}
}
}
getTools(): OllamaTool[] {
const allTools: OllamaTool[] = [
{
type: 'function',
function: {
name: 'create_note',
description:
'Creates a new note in the vault at the specified path with the given content',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the new note (e.g., "Projects/My Note.md")',
},
content: {
type: 'string',
description: 'The markdown content for the new note',
},
},
required: ['path', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'read_vault_file',
description: 'Reads the content of a file from the vault',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the file to read',
},
},
required: ['path'],
},
},
},
{
type: 'function',
function: {
name: 'search_vault_files',
description:
'Searches for files in the vault by content, tags, titles, and headings using a given query. Returns matching file metadata including paths, titles, and tags.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to use (can be a keyword, tag, or phrase)',
},
limit: {
type: 'number',
description: 'The maximum number of results to return',
},
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'append_to_note',
description: 'Appends content to the end of an existing note',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
content: {
type: 'string',
description: 'The content to append',
},
},
required: ['path', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'replace_note_section',
description: 'Replaces the body of a section under the specified heading in a note',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
heading: {
type: 'string',
description: 'The heading text of the section to replace',
},
content: {
type: 'string',
description: 'The new content for the section (heading will be preserved)',
},
},
required: ['path', 'heading', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'update_frontmatter',
description:
'Updates YAML frontmatter fields in a note. Adds, updates, or removes fields.',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
fields: {
type: 'object',
description:
'An object of frontmatter key-value pairs to set. Use null to remove a field.',
},
},
required: ['path', 'fields'],
},
},
},
{
type: 'function',
function: {
name: 'rename_note',
description: 'Renames a note to a new path within the vault',
parameters: {
type: 'object',
properties: {
oldPath: {
type: 'string',
description: 'The current path to the note',
},
newPath: {
type: 'string',
description: 'The new path for the note',
},
},
required: ['oldPath', 'newPath'],
},
},
},
{
type: 'function',
function: {
name: 'move_note',
description: 'Moves a note into a different folder',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The current path to the note',
},
folder: {
type: 'string',
description: 'The target folder path (e.g., "Projects"). Use "" for vault root.',
},
},
required: ['path', 'folder'],
},
},
},
{
type: 'function',
function: {
name: 'delete_note',
description: 'Deletes a note from the vault',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note to delete',
},
},
required: ['path'],
},
},
},
{
type: 'function',
function: {
name: 'list_vault_tags',
description:
'Lists all unique tags used across the vault with usage counts. Use this when the user asks about tags, tag organization, or wants to see all tags. Do NOT use search_vault_files for tag listing.',
parameters: {
type: 'object',
properties: {
sortBy: {
type: 'string',
description:
'Sort by "name" (alphabetical) or "count" (most used first). Default: "name"',
enum: ['name', 'count'],
},
},
},
},
},
{
type: 'function',
function: {
name: 'get_vault_stats',
description:
'Returns an overview of the vault: total notes, folder structure, tag distribution, average note length, and recent files. Use this when the user asks about vault structure, folder organization, or wants a high-level overview before making organizational suggestions.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function',
function: {
name: 'insert_link',
description: 'Inserts a wikilink to another note at the end of a source note',
parameters: {
type: 'object',
properties: {
sourcePath: {
type: 'string',
description: 'The path to the note that will contain the link',
},
targetPath: {
type: 'string',
description: 'The path to the note being linked to',
},
anchorText: {
type: 'string',
description: 'Optional display text for the link',
},
},
required: ['sourcePath', 'targetPath'],
},
},
},
];
return filterToolsForMode(allTools, this.currentAgentMode);
}
/**
* Build messages for the LLM, injecting structured memory context if available.
* This is the canonical message builder used for all LLM calls in this view.
*/
private buildMessagesWithMemory(baseMessages: OllamaMessage[]): OllamaMessage[] {
const memoryContext = this.structuredMemoryManager?.buildMemoryContext();
if (memoryContext) {
return [{ role: 'system', content: memoryContext }, ...baseMessages];
}
return baseMessages;
}
async processToolCalls(
toolCalls: OllamaToolCall[],
messages: OllamaMessage[],
tools: OllamaTool[],
fullResponse: string,
assistantMessageId: string,
depth = 0
): Promise<void> {
if (depth >= MAX_TOOL_CALL_DEPTH) {
this.updateMessageById(assistantMessageId, {
content: fullResponse || '(No response)',
isStreaming: false,
isThinking: false,
});
return;
}
const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? ''));
const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? ''));
// Execute read/search tools immediately
const readResults = (
await Promise.all(
readToolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
try {
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
return { ...toolResult, id: toolCall.id };
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
return null;
}
})
)
).filter((result): result is NonNullable<typeof result> => result !== null);
// Build previews for write tools
const writePreviews: ProposedAction[] = [];
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
try {
const preview = await this.actionPreviewBuilder.buildPreview(toolCall);
writePreviews.push(preview);
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
}
}
if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) {
// Store pending state for apply/cancel
this.pendingActions = writePreviews;
this.pendingReadResults = readResults;
this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse };
this.updateMessageById(assistantMessageId, {
content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`,
isStreaming: false,
isThinking: false,
});
this.render();
this.renderActionPreviews(assistantMessageId);
return;
}
// If mode does not require preview, execute write tools immediately
let writeResults: (ToolResult & { id?: string })[] = [];
if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) {
writeResults = (
await Promise.all(
writePreviews.map(async (action) => {
try {
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
return { ...toolResult, id: action.toolCall.id };
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
return {
success: false,
message: error instanceof Error ? error.message : String(error),
id: action.toolCall.id,
};
}
})
)
);
}
// No write tools (or they were already executed) — proceed with follow-up
const allResults = [...readResults, ...writeResults];
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
role: 'tool',
content: JSON.stringify(result),
tool_call_id: result.id ?? '',
}));
const followUp: OllamaMessage = {
role: 'assistant',
content: '',
tool_calls: toolCalls,
};
if (followUpMessages.length > 0) {
const finalMessages = [...messages, followUp, ...followUpMessages];
const followUpStartTime = Date.now();
const response = await this.getActiveOllamaClient().chat(finalMessages, tools);
const followUpDurationMs = Date.now() - followUpStartTime;
const followUpContent = response.content ?? '';
const followUpToolCalls = response.tool_calls ?? [];
// Record follow-up LLM call telemetry
this.telemetryManager?.recordLlmCall({
model: this.getActiveModel(),
promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4),
completionTokens: Math.round(followUpContent.length / 4),
totalTokens: Math.round(
(finalMessages.reduce((sum, m) => sum + m.content.length, 0) + followUpContent.length) / 4
),
durationMs: followUpDurationMs,
});
if (followUpToolCalls.length > 0) {
// LLM wants another round of tool use — recurse rather than discarding these calls
this.showActivityIndicator('Using tools…');
await this.processToolCalls(
followUpToolCalls,
finalMessages,
tools,
followUpContent,
assistantMessageId,
depth + 1
);
} else {
this.updateMessageById(assistantMessageId, {
content: followUpContent || fullResponse || '(No response)',
isStreaming: false,
isThinking: false,
});
}
} else {
// No tool results to follow up on — all tools failed or produced no output
this.updateMessageById(assistantMessageId, {
content: fullResponse || 'No tool results to report.',
isStreaming: false,
isThinking: false,
});
}
}
async applyPendingActions(): Promise<void> {
if (this.pendingActions.length === 0 || !this.pendingFollowUpContext) {
return;
}
const { messages, tools, assistantMessageId, allToolCalls, assistantText } = this.pendingFollowUpContext;
// Execute write tools
const writeResults = (
await Promise.all(
this.pendingActions.map(async (action) => {
try {
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
return { ...toolResult, id: action.toolCall.id };
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.applyPendingActions');
return {
success: false,
message: error instanceof Error ? error.message : String(error),
id: action.toolCall.id,
};
}
})
)
);
const allResults = [...this.pendingReadResults, ...writeResults];
const failedWrites = writeResults.filter((result) => !result.success);
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
role: 'tool',
content: JSON.stringify(result),
tool_call_id: result.id ?? '',
}));
const followUp: OllamaMessage = {
role: 'assistant',
content: assistantText,
tool_calls: allToolCalls,
};
if (followUpMessages.length > 0) {
const finalMessages = [...messages, followUp, ...followUpMessages];
const followUpStartTime = Date.now();
const response = await this.getActiveOllamaClient().chat(finalMessages, tools);
const followUpDurationMs = Date.now() - followUpStartTime;
const followUpContent = response.content ?? '';
const followUpToolCalls = response.tool_calls ?? [];
// Record follow-up LLM call telemetry
this.telemetryManager?.recordLlmCall({
model: this.getActiveModel(),
promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4),
completionTokens: Math.round(followUpContent.length / 4),
totalTokens: Math.round(
(finalMessages.reduce((sum, m) => sum + m.content.length, 0) + followUpContent.length) / 4
),
durationMs: followUpDurationMs,
});
this.clearPendingActions();
if (followUpToolCalls.length > 0) {
// LLM wants more tool calls after applying — hand off to processToolCalls
this.showActivityIndicator('Using tools…');
await this.processToolCalls(
followUpToolCalls,
finalMessages,
tools,
followUpContent,
assistantMessageId,
1
);
} else {
this.updateMessageById(assistantMessageId, {
content:
followUpContent ||
(failedWrites.length > 0
? `Some actions failed:\n${failedWrites.map((r) => `- ${r.message}`).join('\n')}`
: 'Actions applied successfully.'),
isStreaming: false,
isThinking: false,
});
}
} else {
this.updateMessageById(assistantMessageId, {
content:
failedWrites.length > 0
? `Actions failed:\n${failedWrites.map((r) => `- ${r.message}`).join('\n')}`
: 'Actions applied successfully.',
isStreaming: false,
isThinking: false,
});
this.clearPendingActions();
}
this.render();
}
cancelPendingActions(): void {
if (this.pendingActions.length === 0) {
return;
}
const context = this.pendingFollowUpContext;
if (context) {
this.updateMessageById(context.assistantMessageId, {
content: 'Actions cancelled. No changes were made.',
isStreaming: false,
isThinking: false,
});
}
this.clearPendingActions();
this.render();
}
private renderActionPreviews(assistantMessageId: string): void {
const messageEl = this.chatContainer?.querySelector(
`.ollama-message[data-msg-id="${assistantMessageId}"]`
);
if (!messageEl) {
return;
}
// Remove existing preview
const existing = messageEl.querySelector('.ollama-proposed-actions');
existing?.remove();
const container = messageEl.createEl('div', { cls: 'ollama-proposed-actions' });
container.createEl('div', {
cls: 'ollama-proposed-actions-header',
text: 'Proposed Actions',
});
for (const action of this.pendingActions) {
const card = container.createEl('div', { cls: 'ollama-proposed-action' });
card.createEl('div', { cls: 'ollama-action-description', text: action.description });
if (action.preview) {
const diffEl = card.createEl('div', { cls: 'ollama-action-diff' });
if (action.preview.before !== undefined) {
diffEl.createEl('pre', {
cls: 'ollama-diff-before',
text: `Before:\n${action.preview.before.slice(0, 500)}`,
});
}
if (action.preview.after !== undefined) {
diffEl.createEl('pre', {
cls: 'ollama-diff-after',
text: `After:\n${action.preview.after.slice(0, 500)}`,
});
}
}
}
const buttonContainer = container.createEl('div', { cls: 'ollama-action-buttons' });
const applyBtn = buttonContainer.createEl('button', {
cls: 'ollama-apply-button',
text: 'Apply All',
});
const cancelBtn = buttonContainer.createEl('button', {
cls: 'ollama-cancel-button',
text: 'Cancel',
});
applyBtn.addEventListener('click', () => {
void this.applyPendingActions();
});
cancelBtn.addEventListener('click', () => {
this.cancelPendingActions();
});
}
private clearPendingActions(): void {
this.pendingActions = [];
this.pendingReadResults = [];
this.pendingFollowUpContext = null;
this.chatContainer?.querySelectorAll('.ollama-proposed-actions').forEach((el) => el.remove());
}
private formatWorkflowResult(result: {
workflowName: string;
success: boolean;
stepResults: { stepName: string; success: boolean; data: unknown; error?: string }[];
finalOutput: unknown;
error?: string;
}): string {
const lines: string[] = [];
lines.push(`## ${result.workflowName}`);
lines.push('');
if (result.stepResults.length > 0) {
lines.push('**Steps:**');
for (const step of result.stepResults) {
const status = step.success ? '✅' : '❌';
lines.push(`${status} **${step.stepName}**`);
if (!step.success && step.error) {
lines.push(` Error: ${step.error}`);
}
}
lines.push('');
}
if (result.error) {
lines.push(`**Workflow Error:** ${result.error}`);
lines.push('');
}
if (result.finalOutput) {
lines.push('**Result:**');
if (typeof result.finalOutput === 'string') {
lines.push(result.finalOutput);
} else {
lines.push(JSON.stringify(result.finalOutput, null, 2));
}
}
return lines.join('\n');
}
async handleWorkflowRequest(query: string, assistantMessageId: string): Promise<void> {
try {
this.updateMessageById(assistantMessageId, {
content: '🔄 Generating workflow plan...',
isStreaming: false,
isThinking: false,
});
const result = await this.workflowEngine.executeWorkflowFromQuery(query, this.getTools());
const formatted = this.formatWorkflowResult({
workflowName: result.workflowName,
success: result.success,
stepResults: result.stepResults.map((sr) => ({
stepName: sr.stepName,
success: sr.success,
data: sr.data,
error: sr.error,
})),
finalOutput: result.finalOutput,
error: result.error,
});
this.updateMessageById(assistantMessageId, {
content: formatted,
isStreaming: false,
isThinking: false,
});
this.conversationStateManager.updateShortTermContext({
role: 'assistant',
content: formatted,
});
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleWorkflowRequest');
this.updateMessageById(assistantMessageId, {
content: 'An error occurred while executing the workflow.',
isStreaming: false,
});
}
}
async handleUserInput(inputValue?: string): Promise<void> {
const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim();
if (!userMessage) {
return;
}
const isWorkflowCommand = userMessage.toLowerCase().startsWith('/workflow');
const actualMessage = isWorkflowCommand
? userMessage.slice('/workflow'.length).trim()
: userMessage;
const maxContextLength = this.settings.maxContextLength;
const tools = this.getTools();
const messageId = crypto.randomUUID();
const userMessageId = `${messageId}-user`;
const assistantMessageId = `${messageId}-assistant`;
const userChatMessage: ChatMessage = {
id: userMessageId,
role: 'user',
content: actualMessage,
timestamp: Date.now(),
};
const assistantMessage: ChatMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
isThinking: true,
};
const previousStreamingEl = this.lastMessageEl;
this.messages = [...this.messages, userChatMessage, assistantMessage];
this.render();
if (this.inputEl) {
this.inputEl.value = '';
}
// Add the assistant message to the DOM to enable streaming
this.lastMessageEl =
this.chatContainer?.querySelector(`.ollama-message[data-msg-id="${assistantMessageId}"]`) ??
null;
if (!this.lastMessageEl && previousStreamingEl) {
previousStreamingEl.classList.add('ollama-message');
previousStreamingEl.setAttribute('data-msg-id', assistantMessageId);
this.contentEl.appendChild(previousStreamingEl);
this.lastMessageEl = previousStreamingEl;
}
if (isWorkflowCommand) {
this.showActivityIndicator('Running workflow…');
await this.handleWorkflowRequest(actualMessage, assistantMessageId);
this.hideActivityIndicator();
this.cleanupStreamingResources();
return;
}
try {
this.showActivityIndicator('Thinking…');
const noteContext = await this.noteContextBuilder.buildContext(
actualMessage,
this.settings.vaultSearchLimit,
{
includeOpenNote: true,
includeSelectedText: true,
includeBacklinks: true,
includeOutlinks: true,
includeRelated: true,
maxRelatedNotes: 10,
}
);
const context = this.noteContextBuilder.formatContext(noteContext, maxContextLength);
const userMessageWithContext = context
? `Relevant vault context:\n${context}\n\nUser question:\n${actualMessage}`
: actualMessage;
// Get the complete messages array for the LLM with all context layers
const completeMessages =
this.conversationStateManager.getCompleteMessages(userMessageWithContext);
// Prepend structured memory as a system message if available
const messagesWithMemory = this.buildMessagesWithMemory(completeMessages);
const activeClient = this.getActiveOllamaClient();
const activeModel = this.getActiveModel();
const stream = activeClient.streamChat(messagesWithMemory, tools);
let fullResponse = '';
let toolCalls: OllamaToolCall[] = [];
let promptTokens = 0;
let completionTokens = 0;
const llmStartTime = Date.now();
for await (const chunk of stream) {
if (chunk.content) {
fullResponse += chunk.content;
this.updateLastMessage({
content: fullResponse,
isStreaming: true,
isThinking: false,
});
}
if (chunk.tool_calls) {
toolCalls = [...toolCalls, ...chunk.tool_calls];
}
// Capture token counts from final chunk when Ollama provides them
if (typeof chunk.prompt_eval_count === 'number') {
promptTokens = chunk.prompt_eval_count;
}
if (typeof chunk.eval_count === 'number') {
completionTokens = chunk.eval_count;
}
}
const llmDurationMs = Date.now() - llmStartTime;
// Fallback: estimate tokens from characters if Ollama didn't provide counts
const estimatedPromptTokens =
promptTokens > 0
? promptTokens
: completeMessages.reduce((sum, m) => sum + m.content.length, 0) / 4;
const estimatedCompletionTokens =
completionTokens > 0 ? completionTokens : fullResponse.length / 4;
this.telemetryManager?.recordLlmCall({
model: activeModel,
promptTokens: Math.round(estimatedPromptTokens),
completionTokens: Math.round(estimatedCompletionTokens),
totalTokens: Math.round(estimatedPromptTokens + estimatedCompletionTokens),
durationMs: llmDurationMs,
});
// Process tool calls if any
if (toolCalls.length > 0) {
this.showActivityIndicator('Using tools…');
await this.processToolCalls(
toolCalls,
messagesWithMemory,
tools,
fullResponse,
assistantMessageId
);
}
// Auto-nudge for tool-capable modes if assistant didn't emit tools but seems to intend to
let shouldFallbackToReadTools = false;
const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
const isToolCapable = toolCapableModes.includes(this.currentAgentMode);
// 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 (isToolCapable && toolCalls.length === 0) {
if (modelMentionedActions || userWantsVaultOps) {
shouldFallbackToReadTools = true;
// Suppress the model's "Let me..." text — clear it from the DOM immediately
const priorResponse = fullResponse;
fullResponse = '';
this.updateMessageById(assistantMessageId, {
content: '',
isStreaming: false,
isThinking: false,
});
// Single nudge — only when the model actually expressed intent to act.
// Lying to models that never mentioned action ("you said you would...") confuses
// them and wastes a full LLM round-trip without benefit.
if (modelMentionedActions) {
this.showActivityIndicator('Thinking…');
const nudgeMessages: OllamaMessage[] = [
...messagesWithMemory,
...(priorResponse.trim()
? [{ role: 'assistant' as const, content: priorResponse }]
: []),
{
role: 'user',
content:
'You indicated you would take action but did not emit any tool_calls. Emit the required tool_calls now. Do not output explanatory text.',
},
];
try {
const nudgeStream = activeClient.streamChat(nudgeMessages, tools);
let nudgeResponse = '';
for await (const chunk of nudgeStream) {
if (chunk.content) nudgeResponse += chunk.content;
if (chunk.tool_calls) toolCalls = [...toolCalls, ...chunk.tool_calls];
}
if (toolCalls.length > 0) {
this.showActivityIndicator('Using tools…');
await this.processToolCalls(
toolCalls,
nudgeMessages,
tools,
nudgeResponse,
assistantMessageId
);
}
} catch {
// Stream failed — fall through to auto tool calls
}
}
// Auto tool calls: fire immediately when the user's request implies vault operations
// and the model (with or without nudging) still hasn't called any tools.
if (toolCalls.length === 0) {
const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools);
if (autoToolCalls.length > 0) {
this.showActivityIndicator('Using tools…');
toolCalls = autoToolCalls;
await this.processToolCalls(
autoToolCalls,
messagesWithMemory,
tools,
'',
assistantMessageId
);
}
}
}
}
// Update assistant message — only if no tool calls were processed
if (toolCalls.length === 0) {
this.updateMessageById(assistantMessageId, {
content: fullResponse || '(No response)',
isStreaming: false,
isThinking: false,
});
}
// Update conversation state with the assistant's response
this.conversationStateManager.updateShortTermContext({ role: 'user', content: userMessage });
this.conversationStateManager.updateShortTermContext({
role: 'assistant',
content: fullResponse,
});
// Update structured memory with preferences, facts, and a conversation summary
if (this.structuredMemoryManager) {
const prefs = this.structuredMemoryManager.extractPreferencesFromMessage(userMessage);
for (const pref of prefs) {
this.structuredMemoryManager.addUserPreference(pref);
}
const facts = this.structuredMemoryManager.extractFactsFromMessage(userMessage);
for (const fact of facts) {
this.structuredMemoryManager.addLearnedFact(fact);
}
// Also extract from assistant response
const assistantFacts = this.structuredMemoryManager.extractFactsFromMessage(fullResponse);
for (const fact of assistantFacts) {
this.structuredMemoryManager.addLearnedFact(fact);
}
const { topic, keyPoints } = this.structuredMemoryManager.summarizeConversation(
this.conversationStateManager.getShortTermContext()
);
if (keyPoints.length > 0) {
this.structuredMemoryManager.addConversationSummary({
id: crypto.randomUUID?.() ?? `summary-${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
topic,
summary: keyPoints.join('; ').slice(0, 300),
keyPoints,
});
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > this.settings.maxMessageHistory) {
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
}
this.render();
this.syncMessagesToSession();
} catch (error) {
if (this.isCancelled) {
// User-initiated stop — show clean message, not an error
this.updateMessageById(assistantMessageId, {
content: 'Stopped.',
isStreaming: false,
isThinking: false,
});
this.syncMessagesToSession();
} else {
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
const errorMsg = error instanceof Error ? error.message : String(error);
this.updateMessageById(assistantMessageId, {
content: `An error occurred: ${errorMsg}`,
isStreaming: false,
isThinking: false,
});
this.syncMessagesToSession();
}
} finally {
// Clean up streaming resources regardless of outcome
this.isCancelled = false;
this.hideActivityIndicator();
this.cleanupStreamingResources();
}
}
private shouldAutoRunReadTools(response: string): boolean {
const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
if (!toolCapableModes.includes(this.currentAgentMode)) {
return false;
}
const lowerResponse = response.toLowerCase();
const intentPhrases = [
'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',
'continue',
'go ahead',
'proceed',
'do it',
'execute',
'implement',
'apply',
'merge',
'clean',
'fix',
'update',
];
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();
const calls: OllamaToolCall[] = [];
const addCall = (name: string, args: Record<string, unknown>) => {
if (!availableTools.has(name)) {
return;
}
calls.push({
id: crypto.randomUUID(),
type: 'function',
function: {
name,
arguments: JSON.stringify(args),
},
});
};
const wantsStructure =
lowerMessage.includes('folder') ||
lowerMessage.includes('structure') ||
lowerMessage.includes('organize') ||
lowerMessage.includes('vault') ||
lowerMessage.includes('move');
const wantsTags = lowerMessage.includes('tag');
if (wantsStructure) {
addCall('get_vault_stats', {});
}
if (wantsTags || wantsStructure) {
addCall('list_vault_tags', { sortBy: wantsTags ? 'count' : 'name' });
}
const searchQuery = this.buildAutomaticSearchQuery(message);
if (searchQuery) {
addCall('search_vault_files', {
query: searchQuery,
limit: this.settings.vaultSearchLimit,
});
}
return calls.slice(0, MAX_TOOL_CALLS);
}
private buildAutomaticSearchQuery(message: string): string {
const lowerMessage = message.toLowerCase().trim();
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(/\bmove\b/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
// State
private messages: ChatMessage[] = [];
private lastMessageEl: HTMLElement | null = null;
private newChatButton: HTMLElement | null = null;
private sendButton: HTMLElement | null = null;
private stopButton: HTMLElement | null = null;
private inputEl: HTMLTextAreaElement | null = null;
private chatContainer: HTMLElement | null = null;
private sendButtonClickHandler: (() => void) | null = null;
private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null;
private newChatButtonClickHandler: (() => void) | null = null;
private sendButtonClickWrapper: (() => void) | null = null;
private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null;
private newChatButtonClickWrapper: (() => void) | null = null;
private listenersAttached: boolean = false;
private isCancelled: boolean = false;
private settings: PluginSettings;
private ollamaClient: OllamaClient;
private agentOllamaClient: OllamaClient;
private vaultIndexer: VaultIndexer;
private toolExecutor: ToolExecutor;
private actionPreviewBuilder: ActionPreviewBuilder;
private noteContextBuilder: NoteContextBuilder;
private workflowEngine: WorkflowEngine;
private conversationStateManager: ConversationStateManager;
private structuredMemoryManager?: StructuredMemoryManager;
private telemetryManager?: TelemetryManager;
private chatHistoryManager?: ChatHistoryManager;
private vectorStore?: VaultVectorStore;
private onModelChange?: (model: string) => void;
private modeSelectorEl: HTMLSelectElement | null = null;
private modelSelectorEl: HTMLSelectElement | null = null;
private historySelectEl: HTMLSelectElement | null = null;
private historyDeleteButton: HTMLElement | null = null;
private currentAgentMode: AgentMode;
private pendingActions: ProposedAction[] = [];
private pendingReadResults: (ToolResult & { id?: string })[] = [];
private pendingFollowUpContext: {
messages: OllamaMessage[];
tools: OllamaTool[];
assistantMessageId: string;
allToolCalls: OllamaToolCall[];
assistantText: string;
} | null = null;
// Auto-scroll & logs UI
private shouldAutoScroll: boolean = true;
private logsVisible: boolean = false;
private showLogsButton: HTMLElement | null = null;
private logsContainer: HTMLElement | null = null;
private removeLogListener: (() => void) | null = null;
private showLogsClickHandler: (() => void) | null = null;
private chatScrollHandler: (() => void) | null = null;
// Activity indicator
private activityIndicatorEl: HTMLElement | null = null;
private showActivityIndicator(text: string): void {
if (!this.activityIndicatorEl) return;
const textEl = this.activityIndicatorEl.querySelector('.ollama-activity-text');
if (textEl) {
textEl.textContent = text;
}
this.activityIndicatorEl.style.display = 'flex';
if (this.sendButton) {
this.sendButton.style.display = 'none';
}
if (this.stopButton) {
this.stopButton.style.display = 'inline-flex';
}
if (this.inputEl) {
this.inputEl.disabled = true;
}
}
private hideActivityIndicator(): void {
if (!this.activityIndicatorEl) return;
this.activityIndicatorEl.style.display = 'none';
if (this.sendButton) {
this.sendButton.style.display = 'inline-flex';
}
if (this.stopButton) {
this.stopButton.style.display = 'none';
}
if (this.inputEl) {
this.inputEl.disabled = false;
this.inputEl.focus();
}
}
private cancelCurrentOperation(): void {
this.isCancelled = true;
this.ollamaClient.cancelStream();
this.agentOllamaClient.cancelStream();
new Notice('Stopping…');
}
private createOllamaClient(model: string, settings: PluginSettings): OllamaClient {
return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig);
}
private async initializeClientCaches(): Promise<void> {
await this.ollamaClient.initializeCache();
if (this.agentOllamaClient !== this.ollamaClient) {
await this.agentOllamaClient.initializeCache();
}
}
private getActiveModel(): string {
return this.isAgenticMode(this.currentAgentMode)
? (this.settings.agentModel ?? this.settings.model)
: (this.settings.chatModel ?? this.settings.model);
}
private getActiveOllamaClient(): OllamaClient {
return this.isAgenticMode(this.currentAgentMode) ? this.agentOllamaClient : this.ollamaClient;
}
private isAgenticMode(mode: AgentMode): boolean {
return mode === 'edit' || mode === 'organize' || mode === 'research' || mode === 'workflow';
}
private renderLogEntry(entry: LogEntry, container: HTMLElement): void {
const row = container.createEl('div', { cls: 'ollama-log-row' });
row.addClass(`ollama-log-row-${entry.levelLabel.toLowerCase()}`);
const time = new Date(entry.timestamp).toLocaleTimeString();
const timeSpan = row.createEl('span', { cls: 'ollama-log-time', text: time });
const levelSpan = row.createEl('span', {
cls: `ollama-log-level ollama-log-level-${entry.levelLabel.toLowerCase()}`,
text: entry.levelLabel,
});
const catSpan = row.createEl('span', { cls: 'ollama-log-category', text: entry.category });
const msgSpan = row.createEl('span', { cls: 'ollama-log-message', text: entry.message });
// Keep DOM lean
if (container.children.length > 250) {
container.removeChild(container.firstChild!);
}
}
private appendLogEntry(entry: LogEntry): void {
if (!this.logsContainer) return;
this.renderLogEntry(entry, this.logsContainer);
this.scrollLogsToBottom();
}
private scrollLogsToBottom(): void {
if (this.logsContainer) {
this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
}
}
}
const MAX_TOOL_CALLS = 5;
const MAX_TOOL_CALL_DEPTH = 5;