diff --git a/main.js b/main.js index 7b31134..66d2bcd 100644 --- a/main.js +++ b/main.js @@ -3124,10 +3124,7 @@ var ALL_AGENT_MODES = ["ask", "edit", "organize", "research", "workflow"]; function filterToolsByName(tools, allowed) { return tools.filter((t) => allowed.has(t.function.name)); } -var READ_TOOLS = /* @__PURE__ */ new Set([ - "read_vault_file", - "search_vault_files" -]); +var READ_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]); var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([ "read_vault_file", "search_vault_files", @@ -3148,16 +3145,15 @@ var EDIT_TOOLS = /* @__PURE__ */ new Set([ "delete_note", "insert_link" ]); -var RESEARCH_TOOLS = /* @__PURE__ */ new Set([ - "read_vault_file", - "search_vault_files" -]); +var RESEARCH_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]); var AGENT_MODE_CONFIGS = { ask: { label: "Ask", description: "Answer questions using vault context. Read-only mode.", systemPrompt: `You are a helpful assistant that answers questions using the contents of the user's Obsidian vault. You have access to search and read tools to find relevant information. +IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file. +Only respond to the user after you have received and analyzed the tool results. Always base your answers on vault content when possible. If you cannot find relevant information, say so clearly. Do not make up facts.`, @@ -3170,6 +3166,8 @@ Do not make up facts.`, description: "Create, modify, and organize notes with full editing tools.", systemPrompt: `You are an assistant that helps edit and manage notes in the user's Obsidian vault. You have full access to reading, searching, creating, appending, renaming, moving, and deleting notes. +IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool. +Only respond to the user after you have received and analyzed the tool results. When editing notes: - Prefer modifying existing content over creating duplicates. - Use the replace_note_section tool to update specific sections. @@ -3185,6 +3183,8 @@ When editing notes: description: "Tag, rename, move, and link notes to keep the vault tidy.", systemPrompt: `You are an assistant that helps organize the user's Obsidian vault. You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links. +IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool. +Only respond to the user after you have received and analyzed the tool results. When organizing: - Suggest consistent tag vocabularies. - Group related notes by linking them. @@ -3199,6 +3199,8 @@ When organizing: description: "Deep vault search and synthesis across multiple notes.", systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault. Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries. +IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file. +Only respond to the user after you have received and analyzed the tool results. Search broadly, read key sources, and cross-reference information. Cite specific notes and quotes where possible. If information is incomplete or contradictory, note it explicitly.`, @@ -3251,28 +3253,69 @@ var _Logger = class _Logger { _Logger.minLevel = level; } } + static addListener(callback) { + _Logger.listeners.push(callback); + return () => { + const idx = _Logger.listeners.indexOf(callback); + if (idx !== -1) { + _Logger.listeners.splice(idx, 1); + } + }; + } + static getHistory() { + return [..._Logger.history]; + } + static record(level, levelLabel, message, category) { + if (level < _Logger.minLevel) { + return; + } + const entry = { + timestamp: Date.now(), + level, + levelLabel, + category, + message + }; + _Logger.history.push(entry); + if (_Logger.history.length > _Logger.maxHistory) { + _Logger.history = _Logger.history.slice(-_Logger.maxHistory); + } + for (const listener of _Logger.listeners) { + try { + listener(entry); + } catch { + } + } + } static debug(message, category = "general") { if (0 /* DEBUG */ >= _Logger.minLevel) { console.debug(`[${category}] DEBUG: ${message}`); } + _Logger.record(0 /* DEBUG */, "DEBUG", message, category); } static info(message, category = "general") { if (1 /* INFO */ >= _Logger.minLevel) { console.info(`[${category}] INFO: ${message}`); } + _Logger.record(1 /* INFO */, "INFO", message, category); } static warn(message, category = "general") { if (2 /* WARN */ >= _Logger.minLevel) { console.warn(`[${category}] WARN: ${message}`); } + _Logger.record(2 /* WARN */, "WARN", message, category); } static error(message, category = "general") { if (3 /* ERROR */ >= _Logger.minLevel) { console.error(`[${category}] ERROR: ${message}`); } + _Logger.record(3 /* ERROR */, "ERROR", message, category); } }; _Logger.minLevel = 0 /* DEBUG */; +_Logger.listeners = []; +_Logger.history = []; +_Logger.maxHistory = 500; var Logger = _Logger; var MAX_JSON_SIZE = 1e6; var MAX_JSON_NESTING = 24; @@ -8772,10 +8815,11 @@ var INVALID_PATH_CHARS = /[<>:"|?*~]/; var MAX_PATH_LENGTH = 200; var FORBIDDEN_DIRS = [".obsidian", ".git"]; var ToolExecutor = class { - constructor(vault, app, telemetryManager) { + constructor(vault, app, telemetryManager, vaultIndexer) { this.vault = vault; this.app = app; this.telemetryManager = telemetryManager; + this.vaultIndexer = vaultIndexer; } isSafePath(path) { if (!path || path.trim().length === 0) { @@ -8856,7 +8900,7 @@ var ToolExecutor = class { result = await this.handleReadVaultFile(parsedArgs); break; case "search_vault_files": - result = this.handleSearchVaultFiles(parsedArgs); + result = await this.handleSearchVaultFiles(parsedArgs); break; case "append_to_note": result = await this.handleAppendToNote(parsedArgs); @@ -8945,13 +8989,28 @@ var ToolExecutor = class { data: { path, content } }; } - handleSearchVaultFiles(args) { + async handleSearchVaultFiles(args) { const query = args.query; const limitArg = args.limit; if (typeof query !== "string") { throw new Error("Query must be a string"); } const limit = typeof limitArg === "number" && Number.isFinite(limitArg) ? limitArg : 10; + if (this.vaultIndexer) { + const results = await this.vaultIndexer.searchVault(query, limit); + const files2 = results.map((entry) => ({ + path: entry.path, + basename: entry.path.split("/").pop() ?? entry.path, + title: entry.title, + score: entry.score, + tags: entry.tags + })); + return { + success: true, + message: `Found ${files2.length} matching files`, + data: files2 + }; + } const normalizedQuery = query.toLowerCase(); const files = this.vault.getMarkdownFiles().filter((file) => file.path.toLowerCase().includes(normalizedQuery)).slice(0, limit).map((file) => ({ path: file.path, basename: file.basename })); return { @@ -9505,8 +9564,11 @@ ${lines.join("\n")} }; // src/conversation-state.ts +var DEFAULT_SYSTEM_PROMPT = `You are an assistant that can help answer questions using the contents of a vault. +When a user asks for information about their vault, you MUST call the search_vault_files or read_vault_file tool to find the answer. +Do not say you will search or read files \u2014 immediately emit the tool_call.`; var ConversationStateManager = class { - constructor() { + constructor(initialSystemPrompt) { this.shortTermContext = []; this.mediumTermContext = []; this.longTermContext = []; @@ -9515,10 +9577,7 @@ var ConversationStateManager = class { this.longTermContext = [ { role: "system", - content: `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.` + content: initialSystemPrompt ?? DEFAULT_SYSTEM_PROMPT } ]; } @@ -9546,12 +9605,15 @@ var ConversationStateManager = class { * Sets the user's persona or core knowledge as long-term context * @param personaContent The persona or core knowledge content */ + setSystemPrompt(systemPrompt) { + this.longTermContext = this.longTermContext.filter((msg) => msg.role !== "system"); + this.longTermContext.unshift({ + role: "system", + content: systemPrompt + }); + } setPersona(personaContent) { - this.longTermContext = this.longTermContext.filter( - (msg) => msg.role !== "system" || !msg.content.includes( - "You are an assistant that can help answer questions using the contents of a vault" - ) - ); + this.longTermContext = this.longTermContext.filter((msg) => msg.role !== "system"); this.longTermContext.push({ role: "system", content: personaContent @@ -9589,16 +9651,13 @@ var ConversationStateManager = class { /** * Clears all conversation context */ - clear() { + clear(systemPrompt) { this.shortTermContext = []; this.mediumTermContext = []; this.longTermContext = [ { role: "system", - content: `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.` + content: systemPrompt ?? DEFAULT_SYSTEM_PROMPT } ]; } @@ -9641,7 +9700,7 @@ var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g; var WorkflowEngine = class _WorkflowEngine { constructor(vault, app, ollamaUrl, model, options) { this.vaultIndexer = new VaultIndexer(vault); - this.toolExecutor = new ToolExecutor(vault, app); + this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer); this.ollamaClient = new OllamaClient(ollamaUrl, model, void 0, options?.cacheConfig); this.conversationStateManager = new ConversationStateManager(); this.maxSteps = options?.maxSteps ?? 20; @@ -10660,10 +10719,17 @@ var ChatView = class extends import_obsidian5.ItemView { this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.modeSelectorEl = null; - // Pending action state this.pendingActions = []; this.pendingReadResults = []; this.pendingFollowUpContext = null; + // Auto-scroll & logs UI + this.shouldAutoScroll = true; + this.logsVisible = false; + this.showLogsButton = null; + this.logsContainer = null; + this.removeLogListener = null; + this.showLogsClickHandler = null; + this.chatScrollHandler = null; this.messages = []; this.lastMessageEl = null; this.newChatButton = null; @@ -10682,10 +10748,17 @@ var ChatView = class extends import_obsidian5.ItemView { 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, void 0, vectorStore); - this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager); + 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(); + this.conversationStateManager = new ConversationStateManager( + getSystemPromptForMode(this.currentAgentMode) + ); this.structuredMemoryManager = structuredMemoryManager; this.telemetryManager = telemetryManager; this.workflowEngine = new WorkflowEngine( @@ -10695,6 +10768,11 @@ var ChatView = class extends import_obsidian5.ItemView { settings.agentModel ?? settings.model, { cacheConfig: settings.cacheConfig } ); + this.removeLogListener = Logger.addListener((entry) => { + if (this.logsVisible && this.logsContainer) { + this.appendLogEntry(entry); + } + }); } // Getters for testing getSendButtonClickHandler() { @@ -10768,10 +10846,19 @@ var ChatView = class extends import_obsidian5.ItemView { 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.inputEl = null; this.chatContainer = null; + this.showLogsButton = null; + this.logsContainer = null; return Promise.resolve(); } cleanupStreamingResources() { @@ -10857,6 +10944,27 @@ var ChatView = class extends import_obsidian5.ItemView { } else { newChatContainer.appendChild(this.newChatButton); } + 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); + } + if (this.logsVisible) { + if (!this.logsContainer) { + this.logsContainer = this.contentEl.createEl("div", { cls: "ollama-logs-container" }); + 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); + } if (!this.inputEl) { this.inputEl = inputContainer.createEl("textarea", { cls: "ollama-input", @@ -10876,6 +10984,9 @@ var ChatView = class extends import_obsidian5.ItemView { this.contentEl.appendChild(newChatContainer); this.contentEl.appendChild(inputContainer); this.contentEl.appendChild(container); + if (this.shouldAutoScroll && container) { + container.scrollTop = container.scrollHeight; + } this.inputEl.focus(); } setupEventListeners() { @@ -10894,6 +11005,10 @@ var ChatView = class extends import_obsidian5.ItemView { this.newChatButtonClickHandler = () => { this.clearConversation(); }; + this.showLogsClickHandler = () => { + this.logsVisible = !this.logsVisible; + this.render(); + }; if (this.sendButton && this.sendButtonClickHandler) { this.sendButton.addEventListener("click", this.sendButtonClickHandler); } @@ -10903,6 +11018,18 @@ var ChatView = class extends import_obsidian5.ItemView { if (this.newChatButton && this.newChatButtonClickHandler) { this.newChatButton.addEventListener("click", this.newChatButtonClickHandler); } + if (this.showLogsButton && this.showLogsClickHandler) { + this.showLogsButton.addEventListener("click", this.showLogsClickHandler); + } + 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() { @@ -10918,6 +11045,12 @@ var ChatView = class extends import_obsidian5.ItemView { 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() { @@ -10928,10 +11061,11 @@ var ChatView = class extends import_obsidian5.ItemView { if (this.modeSelectorEl) { this.modeSelectorEl.value = mode; } + this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode)); } clearConversation() { this.messages = []; - this.conversationStateManager.clear(); + this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode)); this.render(); } updateMessageById(id, updates) { @@ -11177,35 +11311,6 @@ var ChatView = class extends import_obsidian5.ItemView { } return baseMessages; } - buildMessages(userMessageContent, tools) { - const systemContent = getSystemPromptForMode(this.currentAgentMode); - const messages = []; - if (this.structuredMemoryManager) { - const memoryContext = this.structuredMemoryManager.buildMemoryContext(); - if (memoryContext) { - messages.push({ - role: "system", - content: memoryContext - }); - } - } - messages.push({ - role: "system", - content: systemContent - }); - const userMessage = { - role: "user", - content: userMessageContent - }; - messages.push(userMessage); - if (tools && tools.length > 0) { - messages.push({ - role: "assistant", - content: "I have access to the following tools to help answer your questions:" - }); - } - return messages; - } async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) { const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? "")); const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? "")); @@ -11675,6 +11780,31 @@ ${actualMessage}` : actualMessage; isAgenticMode(mode) { return mode === "edit" || mode === "organize" || mode === "workflow"; } + renderLogEntry(entry, container) { + 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 }); + if (container.children.length > 250) { + container.removeChild(container.firstChild); + } + } + appendLogEntry(entry) { + if (!this.logsContainer) return; + this.renderLogEntry(entry, this.logsContainer); + this.scrollLogsToBottom(); + } + scrollLogsToBottom() { + if (this.logsContainer) { + this.logsContainer.scrollTop = this.logsContainer.scrollHeight; + } + } }; var MAX_TOOL_CALLS = 5; diff --git a/src/chat-view.ts b/src/chat-view.ts index c3b13c1..5523a51 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -27,6 +27,7 @@ import { ConversationStateManager } from './conversation-state'; import { ErrorHandler } from './error-handler'; import { StructuredMemoryManager } from './structured-memory'; import { TelemetryManager } from './tool-telemetry'; +import { Logger, LogEntry } from './utils'; export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view'; @@ -73,7 +74,12 @@ export class ChatView extends ItemView { ? this.ollamaClient : this.createOllamaClient(settings.agentModel ?? settings.model, settings); this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore); - this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager); + 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( @@ -88,6 +94,13 @@ export class ChatView extends ItemView { settings.agentModel ?? settings.model, { cacheConfig: settings.cacheConfig } ); + + // Subscribe to plugin logs + this.removeLogListener = Logger.addListener((entry) => { + if (this.logsVisible && this.logsContainer) { + this.appendLogEntry(entry); + } + }); } updateSettings(newSettings: PluginSettings) { @@ -163,10 +176,19 @@ export class ChatView extends ItemView { 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.inputEl = null; this.chatContainer = null; + this.showLogsButton = null; + this.logsContainer = null; return Promise.resolve(); } @@ -274,6 +296,32 @@ export class ChatView extends ItemView { newChatContainer.appendChild(this.newChatButton); } + // 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', { @@ -299,6 +347,11 @@ export class ChatView extends ItemView { this.contentEl.appendChild(inputContainer); this.contentEl.appendChild(container); + // Auto-scroll chat to bottom if enabled + if (this.shouldAutoScroll && container) { + container.scrollTop = container.scrollHeight; + } + // Focus input on open this.inputEl.focus(); } @@ -323,6 +376,11 @@ export class ChatView extends ItemView { this.clearConversation(); }; + this.showLogsClickHandler = () => { + this.logsVisible = !this.logsVisible; + this.render(); + }; + if (this.sendButton && this.sendButtonClickHandler) { this.sendButton.addEventListener('click', this.sendButtonClickHandler); } @@ -335,6 +393,21 @@ export class ChatView extends ItemView { 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; } @@ -355,6 +428,14 @@ export class ChatView extends ItemView { 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; } @@ -1217,8 +1298,6 @@ export class ChatView extends ItemView { private modeSelectorEl: HTMLSelectElement | null = null; private currentAgentMode: AgentMode; - - // Pending action state private pendingActions: ProposedAction[] = []; private pendingReadResults: (ToolResult & { id?: string })[] = []; private pendingFollowUpContext: { @@ -1227,6 +1306,15 @@ export class ChatView extends ItemView { assistantMessageId: 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; + private createOllamaClient(model: string, settings: PluginSettings): OllamaClient { return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig); } @@ -1251,6 +1339,37 @@ export class ChatView extends ItemView { private isAgenticMode(mode: AgentMode): boolean { return mode === 'edit' || mode === 'organize' || 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; diff --git a/src/tool-executor.ts b/src/tool-executor.ts index c2f8ac7..209cca2 100644 --- a/src/tool-executor.ts +++ b/src/tool-executor.ts @@ -1,9 +1,10 @@ // src/tool-executor.ts import { Vault, App, TFile } from 'obsidian'; -import type { ToolCall, ToolResult } from './types'; +import type { ToolCall, ToolResult, VaultIndexEntry } from './types'; import { safeParseJson } from './utils'; import { TelemetryManager } from './tool-telemetry'; +import { VaultIndexer } from './vault-indexer'; // Disallow characters that are invalid in file paths const INVALID_PATH_CHARS = /[<>:"|?*~]/; @@ -14,11 +15,18 @@ export class ToolExecutor { private vault: Vault; private app: App; private telemetryManager?: TelemetryManager; + private vaultIndexer?: VaultIndexer; - constructor(vault: Vault, app: App, telemetryManager?: TelemetryManager) { + constructor( + vault: Vault, + app: App, + telemetryManager?: TelemetryManager, + vaultIndexer?: VaultIndexer + ) { this.vault = vault; this.app = app; this.telemetryManager = telemetryManager; + this.vaultIndexer = vaultIndexer; } private isSafePath(path: string): boolean { @@ -126,7 +134,7 @@ export class ToolExecutor { result = await this.handleReadVaultFile(parsedArgs); break; case 'search_vault_files': - result = this.handleSearchVaultFiles(parsedArgs); + result = await this.handleSearchVaultFiles(parsedArgs); break; case 'append_to_note': result = await this.handleAppendToNote(parsedArgs); @@ -227,7 +235,7 @@ export class ToolExecutor { }; } - private handleSearchVaultFiles(args: Record): ToolResult { + private async handleSearchVaultFiles(args: Record): Promise { const query = args.query; const limitArg = args.limit; @@ -236,6 +244,25 @@ export class ToolExecutor { } const limit = typeof limitArg === 'number' && Number.isFinite(limitArg) ? limitArg : 10; + + // Use VaultIndexer for rich content/tag/search if available + if (this.vaultIndexer) { + const results = await this.vaultIndexer.searchVault(query, limit); + const files = results.map((entry: VaultIndexEntry) => ({ + path: entry.path, + basename: entry.path.split('/').pop() ?? entry.path, + title: entry.title, + score: entry.score, + tags: entry.tags, + })); + return { + success: true, + message: `Found ${files.length} matching files`, + data: files, + }; + } + + // Fallback to simple path-based search const normalizedQuery = query.toLowerCase(); const files = this.vault .getMarkdownFiles() diff --git a/src/utils.ts b/src/utils.ts index 64a420b..5ce9e40 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,8 +12,19 @@ const SEVERITY_ORDER: Record = { error: LogLevel.ERROR, }; +export interface LogEntry { + timestamp: number; + level: LogLevel; + levelLabel: string; + category: string; + message: string; +} + export class Logger { private static minLevel: LogLevel = LogLevel.DEBUG; + private static listeners: Array<(entry: LogEntry) => void> = []; + private static history: LogEntry[] = []; + private static maxHistory: number = 500; static setLevel(level: string | LogLevel): void { if (typeof level === 'string') { @@ -24,28 +35,75 @@ export class Logger { } } + static addListener(callback: (entry: LogEntry) => void): () => void { + Logger.listeners.push(callback); + return () => { + const idx = Logger.listeners.indexOf(callback); + if (idx !== -1) { + Logger.listeners.splice(idx, 1); + } + }; + } + + static getHistory(): LogEntry[] { + return [...Logger.history]; + } + + private static record( + level: LogLevel, + levelLabel: string, + message: string, + category: string + ): void { + if (level < Logger.minLevel) { + return; + } + const entry: LogEntry = { + timestamp: Date.now(), + level, + levelLabel, + category, + message, + }; + Logger.history.push(entry); + if (Logger.history.length > Logger.maxHistory) { + Logger.history = Logger.history.slice(-Logger.maxHistory); + } + for (const listener of Logger.listeners) { + try { + listener(entry); + } catch { + // ignore listener errors + } + } + } + static debug(message: string, category: string = 'general'): void { if (LogLevel.DEBUG >= Logger.minLevel) { console.debug(`[${category}] DEBUG: ${message}`); } + Logger.record(LogLevel.DEBUG, 'DEBUG', message, category); } static info(message: string, category: string = 'general'): void { if (LogLevel.INFO >= Logger.minLevel) { console.info(`[${category}] INFO: ${message}`); } + Logger.record(LogLevel.INFO, 'INFO', message, category); } static warn(message: string, category: string = 'general'): void { if (LogLevel.WARN >= Logger.minLevel) { console.warn(`[${category}] WARN: ${message}`); } + Logger.record(LogLevel.WARN, 'WARN', message, category); } static error(message: string, category: string = 'general'): void { if (LogLevel.ERROR >= Logger.minLevel) { console.error(`[${category}] ERROR: ${message}`); } + Logger.record(LogLevel.ERROR, 'ERROR', message, category); } } diff --git a/src/workflow-engine/workflow-engine.ts b/src/workflow-engine/workflow-engine.ts index 836fe8f..695dd44 100644 --- a/src/workflow-engine/workflow-engine.ts +++ b/src/workflow-engine/workflow-engine.ts @@ -58,7 +58,7 @@ export class WorkflowEngine { } ) { this.vaultIndexer = new VaultIndexer(vault); - this.toolExecutor = new ToolExecutor(vault, app); + this.toolExecutor = new ToolExecutor(vault, app, undefined, this.vaultIndexer); this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig); this.conversationStateManager = new ConversationStateManager(); this.maxSteps = options?.maxSteps ?? 20; diff --git a/styles.css b/styles.css index 61772cc..c0a0cf6 100644 --- a/styles.css +++ b/styles.css @@ -4,6 +4,11 @@ --ollama-border: var(--background-modifier-border); --ollama-radius: var(--radius-m); --ollama-gap: var(--size-4-2); + --ollama-log-debug: var(--text-muted); + --ollama-log-info: var(--text-accent); + --ollama-log-warn: var(--text-warning); + --ollama-log-error: var(--text-error); + --ollama-log-bg: var(--background-primary-alt); } .ollama-chat-container { @@ -297,3 +302,82 @@ outline: none; border-color: var(--interactive-accent); } + +/* Show Logs Button */ +.ollama-show-logs-button { + display: inline-flex; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1) var(--size-4-2); + font-size: var(--font-smallest); + border-radius: var(--ollama-radius); + border: 1px solid var(--ollama-border); + background-color: var(--background-modifier-form-field); + color: var(--text-normal); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.ollama-show-logs-button:hover { + background-color: var(--background-modifier-hover); +} + +/* Logs Panel */ +.ollama-logs-container { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--size-4-1) var(--size-4-2); + max-height: 12rem; + overflow-y: auto; + background-color: var(--ollama-log-bg); + border-top: 1px solid var(--ollama-border); + border-bottom: 1px solid var(--ollama-border); + font-family: var(--font-monospace); + font-size: var(--font-smallest); +} + +.ollama-log-row { + display: flex; + gap: var(--size-4-1); + align-items: baseline; + line-height: var(--line-height-tight); +} + +.ollama-log-time { + color: var(--text-faint); + flex-shrink: 0; +} + +.ollama-log-level { + flex-shrink: 0; + font-weight: var(--font-semibold); + text-transform: uppercase; + min-width: 3em; +} + +.ollama-log-level-debug { + color: var(--ollama-log-debug); +} + +.ollama-log-level-info { + color: var(--ollama-log-info); +} + +.ollama-log-level-warn { + color: var(--ollama-log-warn); +} + +.ollama-log-level-error { + color: var(--ollama-log-error); +} + +.ollama-log-category { + color: var(--text-muted); + flex-shrink: 0; +} + +.ollama-log-message { + color: var(--text-normal); + word-break: break-word; +} diff --git a/tests/tool-executor.test.ts b/tests/tool-executor.test.ts index 9c3ef4a..84ddd30 100755 --- a/tests/tool-executor.test.ts +++ b/tests/tool-executor.test.ts @@ -725,6 +725,57 @@ describe('ToolExecutor', () => { expect(result.data).toHaveLength(0); expect(result.message).toContain('Found 0 matching files'); }); + + it('should use VaultIndexer for rich search when available', async () => { + const mockIndexer = { + searchVault: jest.fn().mockResolvedValue([ + { + path: 'Projects/AI/ml-basics.md', + title: 'Machine Learning Basics', + content: 'Intro to ML...', + score: 0.95, + tags: 'ai, ml, tutorial', + }, + { + path: 'Projects/AI/deep-learning.md', + title: 'Deep Learning', + content: 'Neural networks...', + score: 0.88, + tags: 'ai, neural-networks', + }, + ]), + }; + const indexedExecutor = new ToolExecutor( + mockVault as unknown as any, + mockApp as unknown as any, + undefined, + mockIndexer as unknown as any + ); + + const call: ToolCall = { + id: 'call_39', + type: 'function', + function: { + name: 'search_vault_files', + arguments: JSON.stringify({ + query: 'machine learning', + limit: 5, + }), + }, + }; + const result = await indexedExecutor.handleToolCall(call); + expect(result.success).toBe(true); + expect(result.data).toHaveLength(2); + expect(mockIndexer.searchVault).toHaveBeenCalledWith('machine learning', 5); + const firstResult = (result.data as any[])[0]; + expect(firstResult).toMatchObject({ + path: 'Projects/AI/ml-basics.md', + basename: 'ml-basics.md', + title: 'Machine Learning Basics', + score: 0.95, + tags: 'ai, ml, tutorial', + }); + }); }); describe('executeTool method', () => {