From 4fabf1df98020d42cb92448437a66e8db2740179 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 7 May 2026 14:44:59 +0200 Subject: [PATCH] Refactor lint config and retry logic --- .eslintrc.js | 10 +- debug_heading.js | 46 -------- debug_heading_extraction.js | 34 ------ debug_scoring.js | 92 --------------- debug_test.js | 30 ----- package.json | 2 +- src/chat-view.js | 193 ++++++++++++++++--------------- src/chat-view.ts | 224 +++++++++++++++++++----------------- src/ollama-client.js | 106 ++++++++--------- src/ollama-client.ts | 96 ++++++++-------- src/utils.js | 4 +- src/utils.ts | 4 +- src/vault-indexer.js | 90 +++++---------- src/vault-indexer.ts | 107 ++++++----------- tests/vault-indexer.test.ts | 3 - 15 files changed, 397 insertions(+), 644 deletions(-) delete mode 100644 debug_heading.js delete mode 100644 debug_heading_extraction.js delete mode 100644 debug_scoring.js delete mode 100644 debug_test.js diff --git a/.eslintrc.js b/.eslintrc.js index a78dfa3..ed6d96a 100755 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,7 +26,7 @@ module.exports = { }, }, { - files: ['jest.config.js', '.eslintrc.js'], + files: ['jest.config.js', '.eslintrc.js', 'jest.setup.js'], parserOptions: { project: null, }, @@ -37,5 +37,11 @@ module.exports = { es2020: true, jest: true, }, - ignorePatterns: ['**/__mocks__/**', '**/*.test.ts'], + ignorePatterns: [ + '**/__mocks__/**', + '**/*.test.ts', + 'src/**/*.js', + 'jest.setup.js', + 'jest.config.js', + ], }; diff --git a/debug_heading.js b/debug_heading.js deleted file mode 100644 index ad0997c..0000000 --- a/debug_heading.js +++ /dev/null @@ -1,46 +0,0 @@ -// Debug the heading matching -const heading = "Algorithm Design"; -const content = "This file mentions algorithm somewhere in the body text"; -const query = "algorithm"; - -function stemToken(token) { - if (token.endsWith('s')) return token.slice(0, -1); - if (token.endsWith('ed')) return token.slice(0, -2); - if (token.endsWith('ing')) return token.slice(0, -3); - return token; -} - -function tokenize(text) { - const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'of', 'for', 'with', 'as', 'by', 'it', 'its', 'that', 'this', 'these', 'those']); - return text - .toLowerCase() - .split(/\W+/) - .filter((token) => token.length > 1 && !stopWords.has(token)); -} - -const queryTokens = tokenize(query); -const headingTokens = tokenize(heading); -const contentTokens = tokenize(content); - -console.log("Query:", query); -console.log("Query tokens:", queryTokens); -console.log("Heading:", heading); -console.log("Heading tokens:", headingTokens); -console.log("Content:", content); -console.log("Content tokens:", contentTokens); - -const queryStemmed = queryTokens.map(t => stemToken(t)); -const headingStemmed = headingTokens.map(t => stemToken(t)); -const contentStemmed = contentTokens.map(t => stemToken(t)); - -console.log("Query stemmed:", queryStemmed); -console.log("Heading stemmed:", headingStemmed); -console.log("Content stemmed:", contentStemmed); - -// Check heading match -const headingMatch = headingStemmed.some(h => h.includes(stemToken(queryStemmed[0]))); -console.log("Heading match:", headingMatch); - -// Check content match -const contentMatch = contentStemmed.includes(stemToken(queryStemmed[0])); -console.log("Content match:", contentMatch); diff --git a/debug_heading_extraction.js b/debug_heading_extraction.js deleted file mode 100644 index dc94c9d..0000000 --- a/debug_heading_extraction.js +++ /dev/null @@ -1,34 +0,0 @@ -// Debug heading extraction -const file1Content = "# Algorithm Design\n\nThis discusses design patterns"; -const file2Content = "This file mentions algorithm somewhere in the body text"; - -function extractHeadings(content) { - const headingMatches = content.match(/^# (.*?)$/gm); - if (headingMatches) { - return headingMatches.map((h) => h.replace(/^# /, '')); - } - return []; -} - -console.log("File 1 content:", file1Content); -console.log("File 1 headings:", extractHeadings(file1Content)); -console.log("File 2 content:", file2Content); -console.log("File 2 headings:", extractHeadings(file2Content)); - -// Check if there's any issue with the regex -const allLines1 = file1Content.split('\n'); -const allLines2 = file2Content.split('\n'); - -console.log("File 1 lines:", allLines1); -console.log("File 2 lines:", allLines2); - -// Check each line for heading match -allLines1.forEach((line, i) => { - const match = line.match(/^# (.*?)$/); - console.log(`File 1 line ${i}: "${line}" -> heading match: ${!!match}`); -}); - -allLines2.forEach((line, i) => { - const match = line.match(/^# (.*?)$/); - console.log(`File 2 line ${i}: "${line}" -> heading match: ${!!match}`); -}); diff --git a/debug_scoring.js b/debug_scoring.js deleted file mode 100644 index 2ed78aa..0000000 --- a/debug_scoring.js +++ /dev/null @@ -1,92 +0,0 @@ -// Debug the scoring logic -const file1Content = "# Algorithm Design\n\nThis discusses design patterns"; -const file2Content = "This file mentions algorithm somewhere in the body text"; -const query = "algorithm"; - -function stemToken(token) { - if (token.endsWith('s')) return token.slice(0, -1); - if (token.endsWith('ed')) return token.slice(0, -2); - if (token.endsWith('ing')) return token.slice(0, -3); - return token; -} - -function tokenize(text) { - const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'of', 'for', 'with', 'as', 'by', 'it', 'its', 'that', 'this', 'these', 'those']); - return text - .toLowerCase() - .split(/\W+/) - .filter((token) => token.length > 1 && !stopWords.has(token)); -} - -function exactMatch(content, token) { - const stemmed = stemToken(token); - const contentTokens = tokenize(content); - return contentTokens.some((ct) => stemToken(ct) === stemmed); -} - -function extractHeadings(content) { - const headingMatches = content.match(/^# (.*?)$/gm); - if (headingMatches) { - return headingMatches.map((h) => h.replace(/^# /, '')); - } - return []; -} - -function extractContentTokens(content) { - const allText = content - .replace(/^---.*?---/s, '') - .replace(/^#.*?$/gm, '') - .replace(/```.*?```/gs, '') - .replace(/`.*?`/g, '') - .replace(/\[.*?\]\(.*?\)/g, ''); - return tokenize(allText); -} - -const queryTokens = tokenize(query); -const file1Headings = extractHeadings(file1Content); -const file1Tokens = extractContentTokens(file1Content); -const file2Headings = extractHeadings(file2Content); -const file2Tokens = extractContentTokens(file2Content); - -console.log("Query tokens:", queryTokens); -console.log("File 1 headings:", file1Headings); -console.log("File 1 content tokens:", file1Tokens); -console.log("File 2 headings:", file2Headings); -console.log("File 2 content tokens:", file2Tokens); - -// Calculate scores -function calculateScore(headings, contentTokens, queryTokens) { - let totalScore = 0; - - for (const queryToken of queryTokens) { - let tokenScore = 0; - const stemmed = stemToken(queryToken); - let matched = false; - - // Weight 2: Heading check - if (headings.some((heading) => heading.toLowerCase().includes(stemmed))) { - tokenScore += 2; - matched = true; - } - - // Weight 1: Content token check - const contentMatch = contentTokens.includes(stemmed); - if (contentMatch) { - tokenScore += 1; - matched = true; - } - - if (matched) { - totalScore += tokenScore; - } - } - - return totalScore; -} - -const score1 = calculateScore(file1Headings, file1Tokens, queryTokens); -const score2 = calculateScore(file2Headings, file2Tokens, queryTokens); - -console.log("File 1 score:", score1); -console.log("File 2 score:", score2); -console.log("File 1 should be first:", score1 > score2); diff --git a/debug_test.js b/debug_test.js deleted file mode 100644 index f6a9369..0000000 --- a/debug_test.js +++ /dev/null @@ -1,30 +0,0 @@ -// Debug the exactMatch function -const text = "algorithm"; -const query = "algorithm"; - -function stemToken(token) { - if (token.endsWith('s')) return token.slice(0, -1); - if (token.endsWith('ed')) return token.slice(0, -2); - if (token.endsWith('ing')) return token.slice(0, -3); - return token; -} - -function tokenize(text) { - const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'of', 'for', 'with', 'as', 'by', 'it', 'its', 'that', 'this', 'these', 'those']); - return text - .toLowerCase() - .split(/\W+/) - .filter((token) => token.length > 1 && !stopWords.has(token)); -} - -function exactMatch(content, token) { - const stemmed = stemToken(token); - const contentTokens = tokenize(content); - return contentTokens.some((ct) => stemToken(ct) === stemmed); -} - -console.log("Text:", text); -console.log("Query:", query); -console.log("Tokenized text:", tokenize(text)); -console.log("Stemmed query:", stemToken(query)); -console.log("Exact match result:", exactMatch(text, query)); diff --git a/package.json b/package.json index b31b470..c9a4d22 100755 --- a/package.json +++ b/package.json @@ -26,12 +26,12 @@ "@typescript-eslint/parser": "^6.19.1", "eslint": "^8.56.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^30.3.0", "prettier": "^3.2.5", "ts-jest": "^29.1.2", "typescript": "^5.3.3" }, "dependencies": { - "jest-environment-jsdom": "^30.3.0", "node-fetch": "^3.3.2", "obsidian": "^1.4.11" } diff --git a/src/chat-view.js b/src/chat-view.js index 941e381..e121076 100644 --- a/src/chat-view.js +++ b/src/chat-view.js @@ -2,8 +2,6 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.ChatView = void 0; const obsidian_1 = require("obsidian"); -const DEFAULT_VAULT_SEARCH_LIMIT = 3; -const MAX_MESSAGE_HISTORY = 50; const MAX_STREAM_CHUNKS = 1000; const ollama_client_1 = require("./ollama-client"); const vault_indexer_1 = require("./vault-indexer"); @@ -213,7 +211,7 @@ class ChatView extends obsidian_1.ItemView { updateLastMessage(content) { const streamingMessage = this.messages.find((msg) => msg.isStreaming); if (streamingMessage && !this.lastMessageEl) { - this.lastMessageEl = this.contentEl.createEl('div', { + this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', { cls: `ollama-message assistant`, }); this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id); @@ -222,6 +220,97 @@ class ChatView extends obsidian_1.ItemView { this.lastMessageEl.textContent = content; } } + getTools() { + return [ + { + type: 'function', + function: { + name: 'create_file', + description: 'Create a new file in the vault', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: "Relative path within the vault, e.g. 'Notes/todo.md'", + }, + content: { type: 'string', description: 'Content of the file to create' }, + }, + required: ['path', 'content'], + }, + }, + }, + ]; + } + buildMessages(userMessage, context) { + const systemContent = context + ? `You are a helpful assistant.\n\nRelevant vault context:\n${context}` + : 'You are a helpful assistant.'; + const systemMessage = { + role: 'system', + content: systemContent, + }; + const userMessageWithContext = { + role: 'user', + content: userMessage, + }; + return [ + systemMessage, + ...this.messages.map((m) => ({ + role: m.role, + content: m.content, + tool_calls: m.tool_calls, + })), + userMessageWithContext, + ]; + } + async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) { + // Validate tool calls before processing + const MAX_TOOL_CALLS = 10; + if (toolCalls.length > MAX_TOOL_CALLS) { + throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`); + } + // Collect all tool results using allSettled to support partial results + const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call))); + const toolResults = []; + for (const result of settledResults) { + if (result.status === 'fulfilled') { + toolResults.push(result.value); + } + else { + // Use centralized error handler for tool errors + error_handler_1.ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput'); + } + } + // Only create follow-up when we have tool results + if (toolResults.length > 0) { + // Create follow-up messages including the assistant's tool calls and results + const followUpMessages = [ + ...messages, + { role: 'assistant', content: fullResponse, tool_calls: toolCalls }, + ...toolResults.map((result) => ({ + role: 'tool', + content: JSON.stringify(result), + })), + ]; + const followUp = await this.ollamaClient.chat(followUpMessages, tools); + fullResponse += followUp.content; + this.updateLastMessage(fullResponse); + // Update the assistant message with the final response immutably + this.updateMessageById(assistantMessageId, { + content: fullResponse, + isStreaming: false, + }); + } + else { + // Even if no tool results were successful, mark streaming as complete + // to prevent the assistant message from disappearing + this.updateMessageById(assistantMessageId, { + content: fullResponse, + isStreaming: false, + }); + } + } async handleUserInput(content) { if (!this.sendButton || !this.inputEl) return; @@ -232,53 +321,15 @@ class ChatView extends obsidian_1.ItemView { if (!userMessage) return; // Search vault using user message as query - const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT); + const entries = await this.vaultIndexer.searchVault(userMessage, this.settings.vaultSearchLimit); let context = entries.map((entry) => `### ${entry.title}\n${entry.content}`).join('\n\n'); // Cap context size to prevent prompt bloat with large vaults const MAX_CONTEXT_LENGTH = 4000; if (context.length > MAX_CONTEXT_LENGTH) { context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)'; } - const systemContent = context - ? `You are a helpful assistant.\n\nRelevant vault context:\n${context}` - : 'You are a helpful assistant.'; - const systemMessage = { - role: 'system', - content: systemContent, - }; - const userMessageWithContext = { - role: 'user', - content: userMessage, - }; - const messages = [ - systemMessage, - ...this.messages.map((m) => ({ - role: m.role, - content: m.content, - tool_calls: m.tool_calls, - })), - userMessageWithContext, - ]; - const tools = [ - { - type: 'function', - function: { - name: 'create_file', - description: 'Create a new file in the vault', - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: "Relative path within the vault, e.g. 'Notes/todo.md'", - }, - content: { type: 'string', description: 'Content of the file to create' }, - }, - required: ['path', 'content'], - }, - }, - }, - ]; + const messages = this.buildMessages(userMessage, context); + const tools = this.getTools(); const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const userMessageId = messageId; const assistantMessageId = `${messageId}-assistant`; @@ -324,51 +375,7 @@ class ChatView extends obsidian_1.ItemView { }); // Process tool calls with proper follow-up context if (toolCalls.length > 0) { - // Validate tool calls before processing - const MAX_TOOL_CALLS = 10; - if (toolCalls.length > MAX_TOOL_CALLS) { - throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`); - } - // Collect all tool results using allSettled to support partial results - const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call))); - const toolResults = []; - for (const result of settledResults) { - if (result.status === 'fulfilled') { - toolResults.push(result.value); - } - else { - // Use centralized error handler for tool errors - error_handler_1.ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput'); - } - } - // Only create follow-up when we have tool results - if (toolResults.length > 0) { - // Create follow-up messages including the assistant's tool calls and results - const followUpMessages = [ - ...messages, - { role: 'assistant', content: fullResponse, tool_calls: toolCalls }, - ...toolResults.map((result) => ({ - role: 'tool', - content: JSON.stringify(result), - })), - ]; - const followUp = await this.ollamaClient.chat(followUpMessages, tools); - fullResponse += followUp.content; - this.updateLastMessage(fullResponse); - // Update the assistant message with the final response immutably - this.updateMessageById(assistantMessageId, { - content: fullResponse, - isStreaming: false, - }); - } - else { - // Even if no tool results were successful, mark streaming as complete - // to prevent the assistant message from disappearing - this.updateMessageById(assistantMessageId, { - content: fullResponse, - isStreaming: false, - }); - } + await this.processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId); } // Update assistant message immutably — only if no tool calls were processed if (toolCalls.length === 0) { @@ -377,8 +384,8 @@ class ChatView extends obsidian_1.ItemView { }); } // Limit conversation history to prevent memory issues - if (this.messages.length > MAX_MESSAGE_HISTORY) { - this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY); + if (this.messages.length > this.settings.maxMessageHistory) { + this.messages = this.messages.slice(-this.settings.maxMessageHistory); } this.render(); } @@ -391,11 +398,9 @@ class ChatView extends obsidian_1.ItemView { // Use centralized error handler error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput'); // Update any streaming messages to non-streaming state to prevent stale messages - // This ensures that if an error occurs during streaming, the assistant message - // is still visible (with any partial content received) but won't cause issues - // in subsequent requests due to stale isStreaming: true flag this.messages = this.messages.map((msg) => msg.isStreaming ? { ...msg, isStreaming: false } : msg); this.cleanupStreamingResources(); + this.render(); } finally { if (this.sendButton) { diff --git a/src/chat-view.ts b/src/chat-view.ts index d08f9cf..0009c02 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -5,8 +5,6 @@ type KeyboardEvent = globalThis.KeyboardEvent; type HTMLTextAreaElement = globalThis.HTMLTextAreaElement; type HTMLButtonElement = globalThis.HTMLButtonElement; -const DEFAULT_VAULT_SEARCH_LIMIT = 3; -const MAX_MESSAGE_HISTORY = 50; const MAX_STREAM_CHUNKS = 1000; import { PluginSettings, @@ -258,7 +256,7 @@ export class ChatView extends ItemView { private updateLastMessage(content: string) { const streamingMessage = this.messages.find((msg) => msg.isStreaming); if (streamingMessage && !this.lastMessageEl) { - this.lastMessageEl = this.contentEl.createEl('div', { + this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', { cls: `ollama-message assistant`, }) as HTMLElement; this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id); @@ -268,6 +266,115 @@ export class ChatView extends ItemView { } } + private getTools(): OllamaTool[] { + return [ + { + type: 'function', + function: { + name: 'create_file', + description: 'Create a new file in the vault', + parameters: { + type: 'object' as const, + properties: { + path: { + type: 'string' as const, + description: "Relative path within the vault, e.g. 'Notes/todo.md'", + }, + content: { type: 'string' as const, description: 'Content of the file to create' }, + }, + required: ['path', 'content'], + }, + }, + }, + ]; + } + + private buildMessages(userMessage: string, context: string): OllamaMessage[] { + const systemContent = context + ? `You are a helpful assistant.\n\nRelevant vault context:\n${context}` + : 'You are a helpful assistant.'; + const systemMessage: OllamaMessage = { + role: 'system', + content: systemContent, + }; + const userMessageWithContext: OllamaMessage = { + role: 'user', + content: userMessage, + }; + + return [ + systemMessage, + ...this.messages.map( + (m) => + ({ + role: m.role, + content: m.content, + tool_calls: m.tool_calls, + }) as OllamaMessage + ), + userMessageWithContext, + ]; + } + + private async processToolCalls( + toolCalls: ToolCall[], + messages: OllamaMessage[], + tools: OllamaTool[], + fullResponse: string, + assistantMessageId: string + ): Promise { + // Validate tool calls before processing + const MAX_TOOL_CALLS = 10; + if (toolCalls.length > MAX_TOOL_CALLS) { + throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`); + } + + // Collect all tool results using allSettled to support partial results + const settledResults = await Promise.allSettled( + toolCalls.map((call) => this.toolExecutor.handleToolCall(call)) + ); + + const toolResults: ToolResult[] = []; + for (const result of settledResults) { + if (result.status === 'fulfilled') { + toolResults.push(result.value); + } else { + // Use centralized error handler for tool errors + ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput'); + } + } + + // Only create follow-up when we have tool results + if (toolResults.length > 0) { + // Create follow-up messages including the assistant's tool calls and results + const followUpMessages: OllamaMessage[] = [ + ...messages, + { role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls }, + ...toolResults.map((result) => ({ + role: 'tool' as const, + content: JSON.stringify(result), + })), + ]; + + const followUp = await this.ollamaClient.chat(followUpMessages, tools); + fullResponse += followUp.content; + this.updateLastMessage(fullResponse); + + // Update the assistant message with the final response immutably + this.updateMessageById(assistantMessageId, { + content: fullResponse, + isStreaming: false, + }); + } else { + // Even if no tool results were successful, mark streaming as complete + // to prevent the assistant message from disappearing + this.updateMessageById(assistantMessageId, { + content: fullResponse, + isStreaming: false, + }); + } + } + private async handleUserInput(content: string) { if (!this.sendButton || !this.inputEl) return; this.sendButton.disabled = true; @@ -278,7 +385,10 @@ export class ChatView extends ItemView { if (!userMessage) return; // Search vault using user message as query - const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT); + const entries = await this.vaultIndexer.searchVault( + userMessage, + this.settings.vaultSearchLimit + ); let context = entries.map((entry) => `### ${entry.title}\n${entry.content}`).join('\n\n'); // Cap context size to prevent prompt bloat with large vaults @@ -287,51 +397,8 @@ export class ChatView extends ItemView { context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)'; } - const systemContent = context - ? `You are a helpful assistant.\n\nRelevant vault context:\n${context}` - : 'You are a helpful assistant.'; - const systemMessage: OllamaMessage = { - role: 'system', - content: systemContent, - }; - const userMessageWithContext: OllamaMessage = { - role: 'user', - content: userMessage, - }; - - const messages: OllamaMessage[] = [ - systemMessage, - ...this.messages.map( - (m) => - ({ - role: m.role, - content: m.content, - tool_calls: m.tool_calls, - }) as OllamaMessage - ), - userMessageWithContext, - ]; - - const tools: OllamaTool[] = [ - { - type: 'function', - function: { - name: 'create_file', - description: 'Create a new file in the vault', - parameters: { - type: 'object' as const, - properties: { - path: { - type: 'string' as const, - description: "Relative path within the vault, e.g. 'Notes/todo.md'", - }, - content: { type: 'string' as const, description: 'Content of the file to create' }, - }, - required: ['path', 'content'], - }, - }, - }, - ]; + const messages = this.buildMessages(userMessage, context); + const tools = this.getTools(); const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -389,56 +456,7 @@ export class ChatView extends ItemView { // Process tool calls with proper follow-up context if (toolCalls.length > 0) { - // Validate tool calls before processing - const MAX_TOOL_CALLS = 10; - if (toolCalls.length > MAX_TOOL_CALLS) { - throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`); - } - - // Collect all tool results using allSettled to support partial results - const settledResults = await Promise.allSettled( - toolCalls.map((call) => this.toolExecutor.handleToolCall(call)) - ); - - const toolResults: ToolResult[] = []; - for (const result of settledResults) { - if (result.status === 'fulfilled') { - toolResults.push(result.value); - } else { - // Use centralized error handler for tool errors - ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput'); - } - } - - // Only create follow-up when we have tool results - if (toolResults.length > 0) { - // Create follow-up messages including the assistant's tool calls and results - const followUpMessages: OllamaMessage[] = [ - ...messages, - { role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls }, - ...toolResults.map((result) => ({ - role: 'tool' as const, - content: JSON.stringify(result), - })), - ]; - - const followUp = await this.ollamaClient.chat(followUpMessages, tools); - fullResponse += followUp.content; - this.updateLastMessage(fullResponse); - - // Update the assistant message with the final response immutably - this.updateMessageById(assistantMessageId, { - content: fullResponse, - isStreaming: false, - }); - } else { - // Even if no tool results were successful, mark streaming as complete - // to prevent the assistant message from disappearing - this.updateMessageById(assistantMessageId, { - content: fullResponse, - isStreaming: false, - }); - } + await this.processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId); } // Update assistant message immutably — only if no tool calls were processed @@ -449,8 +467,8 @@ export class ChatView extends ItemView { } // Limit conversation history to prevent memory issues - if (this.messages.length > MAX_MESSAGE_HISTORY) { - this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY); + if (this.messages.length > this.settings.maxMessageHistory) { + this.messages = this.messages.slice(-this.settings.maxMessageHistory); } this.render(); } finally { @@ -461,13 +479,11 @@ export class ChatView extends ItemView { // Use centralized error handler ErrorHandler.handleError(error, 'ChatView.handleUserInput'); // Update any streaming messages to non-streaming state to prevent stale messages - // This ensures that if an error occurs during streaming, the assistant message - // is still visible (with any partial content received) but won't cause issues - // in subsequent requests due to stale isStreaming: true flag this.messages = this.messages.map((msg) => msg.isStreaming ? { ...msg, isStreaming: false } : msg ); this.cleanupStreamingResources(); + this.render(); } finally { if (this.sendButton) { this.sendButton.disabled = false; diff --git a/src/ollama-client.js b/src/ollama-client.js index d023586..55723d5 100644 --- a/src/ollama-client.js +++ b/src/ollama-client.js @@ -50,32 +50,30 @@ class OllamaClient { if (response.status >= 500 && attempt < this.maxRetries) { const retryDelay = Math.pow(2, attempt) * 100; utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client'); - if (attempt < this.maxRetries - 1) { - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - utils_1.Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } - finally { - signal.removeEventListener('abort', abortListener); - } + const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); + const abortListener = () => { + utils_1.Logger.info('Retry aborted by user', 'ollama-client'); + }; + const signal = controller.signal; + if (signal) { + signal.addEventListener('abort', abortListener); + try { + await Promise.race([ + retryTimeout, + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); } - else { - await retryTimeout; + finally { + signal.removeEventListener('abort', abortListener); } } + else { + await retryTimeout; + } yield* this.streamChatWithRetry(messages, tools, attempt + 1); return; } @@ -148,11 +146,15 @@ class OllamaClient { } } finally { - // Abort the local controller - controller.abort(); + // Abort the local controller to release underlying fetch resources if not already aborted + if (!controller.signal.aborted) { + controller.abort(); + } + // Clean up the reference only if this is still the current stream + if (this.currentStreamController === controller) { + this.currentStreamController = null; + } } - // Clean up the reference - this.currentStreamController = null; } async chat(messages, tools = []) { return this.chatWithRetry(messages, tools, 0); @@ -178,32 +180,30 @@ class OllamaClient { if (response.status >= 500 && attempt < this.maxRetries) { const retryDelay = Math.pow(2, attempt) * 100; utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client'); - if (attempt < this.maxRetries - 1) { - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - utils_1.Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } - finally { - signal.removeEventListener('abort', abortListener); - } + const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); + const abortListener = () => { + utils_1.Logger.info('Retry aborted by user', 'ollama-client'); + }; + const signal = controller.signal; + if (signal) { + signal.addEventListener('abort', abortListener); + try { + await Promise.race([ + retryTimeout, + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); } - else { - await retryTimeout; + finally { + signal.removeEventListener('abort', abortListener); } } + else { + await retryTimeout; + } return this.chatWithRetry(messages, tools, attempt + 1); } throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); @@ -212,8 +212,10 @@ class OllamaClient { return (this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }); } finally { - // Abort the local controller - controller.abort(); + // Abort the local controller to release underlying fetch resources if not already aborted + if (!controller.signal.aborted) { + controller.abort(); + } } } throwIfOllamaError(parsed) { diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 5016b25..9192a9e 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -77,29 +77,27 @@ export class OllamaClient { `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client' ); - if (attempt < this.maxRetries - 1) { - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } finally { - signal.removeEventListener('abort', abortListener); - } - } else { - await retryTimeout; + const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); + const abortListener = () => { + Logger.info('Retry aborted by user', 'ollama-client'); + }; + const signal = controller.signal; + if (signal) { + signal.addEventListener('abort', abortListener); + try { + await Promise.race([ + retryTimeout, + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + } finally { + signal.removeEventListener('abort', abortListener); } + } else { + await retryTimeout; } yield* this.streamChatWithRetry(messages, tools, attempt + 1); return; @@ -187,8 +185,10 @@ export class OllamaClient { reader.releaseLock(); } } finally { - // Abort the local controller - controller.abort(); + // Abort the local controller to release underlying fetch resources if not already aborted + if (!controller.signal.aborted) { + controller.abort(); + } // Clean up the reference only if this is still the current stream if (this.currentStreamController === controller) { this.currentStreamController = null; @@ -229,29 +229,27 @@ export class OllamaClient { `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client' ); - if (attempt < this.maxRetries - 1) { - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } finally { - signal.removeEventListener('abort', abortListener); - } - } else { - await retryTimeout; + const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); + const abortListener = () => { + Logger.info('Retry aborted by user', 'ollama-client'); + }; + const signal = controller.signal; + if (signal) { + signal.addEventListener('abort', abortListener); + try { + await Promise.race([ + retryTimeout, + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + } finally { + signal.removeEventListener('abort', abortListener); } + } else { + await retryTimeout; } return this.chatWithRetry(messages, tools, attempt + 1); } @@ -263,8 +261,10 @@ export class OllamaClient { this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] } ); } finally { - // Abort the local controller - controller.abort(); + // Abort the local controller to release underlying fetch resources if not already aborted + if (!controller.signal.aborted) { + controller.abort(); + } } } diff --git a/src/utils.js b/src/utils.js index 59e6c35..889e40e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -147,8 +147,8 @@ function safeParseJson(jsonString) { if (dangerousKeys.some((key) => Object.keys(obj).includes(key))) { return true; } - // Recursively check nested objects - for (const key in obj) { + // Recursively check nested objects (own properties only) + for (const key of Object.keys(obj)) { if (checkDangerousPatterns(obj[key])) { return true; } diff --git a/src/utils.ts b/src/utils.ts index e58965a..75d4cba 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -166,8 +166,8 @@ export function safeParseJson(jsonString: string): unknown { return true; } - // Recursively check nested objects - for (const key in obj as Record) { + // Recursively check nested objects (own properties only) + for (const key of Object.keys(obj as Record)) { if (checkDangerousPatterns((obj as Record)[key])) { return true; } diff --git a/src/vault-indexer.js b/src/vault-indexer.js index f14eaa3..2422384 100644 --- a/src/vault-indexer.js +++ b/src/vault-indexer.js @@ -1,7 +1,7 @@ "use strict"; // src/vault-indexer.ts Object.defineProperty(exports, "__esModule", { value: true }); -exports.CancellationToken = exports.InMemoryCache = exports.VaultIndexer = void 0; +exports.InMemoryCache = exports.VaultIndexer = void 0; exports.createVaultIndexerWithCache = createVaultIndexerWithCache; const utils_1 = require("./utils"); class InMemoryCache { @@ -21,18 +21,6 @@ class InMemoryCache { } } exports.InMemoryCache = InMemoryCache; -class CancellationToken { - constructor() { - this.cancelled = false; - } - cancel() { - this.cancelled = true; - } - get isCancelled() { - return this.cancelled; - } -} -exports.CancellationToken = CancellationToken; class VaultIndexer { constructor(vault, cache) { this.vault = null; @@ -70,10 +58,7 @@ class VaultIndexer { const vault = this.vault; const allFiles = vault.getMarkdownFiles(); const results = await this.processFilesInBatches(vault, allFiles, queryTokens); - const filteredResults = results - .filter((result) => result !== null) - .sort((a, b) => b.score - a.score) - .slice(0, limit); + const filteredResults = results.sort((a, b) => b.score - a.score).slice(0, limit); if (this.cache) { try { await this.cache.put(cacheKey, JSON.stringify(filteredResults)); @@ -88,54 +73,35 @@ class VaultIndexer { const batchSize = 10; const results = []; const seenPaths = new Set(); - const cancellationToken = new CancellationToken(); - // Removed unused processedCount variable - // Set up a check for cancellation every 100 files - const checkInterval = setInterval(() => { - if (cancellationToken.isCancelled) { - clearInterval(checkInterval); - } - }, 100); - try { - for (let i = 0; i < files.length; i += batchSize) { - if (cancellationToken.isCancelled) { - break; - } - const batch = files.slice(i, i + batchSize); - const batchResults = await Promise.all(batch.map(async (file) => { - try { - const content = await vault.read(file); - const tokenized = this.tokenizeContent(content); - const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file); - if (scoreResult.score > 0) { - const entry = { - path: file.path, - title: file.basename.replace(/\.md$/, ''), - content: content.substring(0, 500), - score: scoreResult.score, - }; - if (!seenPaths.has(entry.path)) { - seenPaths.add(entry.path); - return entry; - } - return null; + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + const batchResults = await Promise.all(batch.map(async (file) => { + try { + const content = await vault.read(file); + const tokenized = this.tokenizeContent(content); + const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file); + if (scoreResult.score > 0) { + const entry = { + path: file.path, + title: file.basename.replace(/\.md$/, ''), + content: content.substring(0, 500), + score: scoreResult.score, + }; + if (!seenPaths.has(entry.path)) { + seenPaths.add(entry.path); + return entry; } return null; } - catch (error) { - utils_1.Logger.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer'); - return null; - } - })); - const validResults = batchResults.filter((result) => result !== null); - results.push(...validResults); - // Continue processing all files to ensure we don't miss higher-scoring results - // even if we've already found some matches - // Removed processedCount increment - } - } - finally { - clearInterval(checkInterval); + return null; + } + catch (error) { + utils_1.Logger.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer'); + return null; + } + })); + const validResults = batchResults.filter((result) => result !== null); + results.push(...validResults); } return results; } diff --git a/src/vault-indexer.ts b/src/vault-indexer.ts index 130521f..341f2ac 100644 --- a/src/vault-indexer.ts +++ b/src/vault-indexer.ts @@ -31,18 +31,6 @@ class InMemoryCache implements Cache { } } -class CancellationToken { - private cancelled = false; - - cancel(): void { - this.cancelled = true; - } - - get isCancelled(): boolean { - return this.cancelled; - } -} - interface Frontmatter { title?: string; tags?: string; @@ -112,10 +100,7 @@ class VaultIndexer { const allFiles = vault.getMarkdownFiles(); const results = await this.processFilesInBatches(vault, allFiles, queryTokens); - const filteredResults = results - .filter((result): result is NonNullable => result !== null) - .sort((a, b) => b.score - a.score) - .slice(0, limit); + const filteredResults = results.sort((a, b) => b.score - a.score).slice(0, limit); if (this.cache) { try { @@ -135,69 +120,47 @@ class VaultIndexer { vault: VaultLike, files: VaultFile[], queryTokens: string[] - ): Promise> { + ): Promise { const batchSize = 10; const results: VaultIndexEntry[] = []; const seenPaths = new Set(); - const cancellationToken = new CancellationToken(); - // Removed unused processedCount variable - // Set up a check for cancellation every 100 files - const checkInterval = setInterval(() => { - if (cancellationToken.isCancelled) { - clearInterval(checkInterval); - } - }, 100); - - try { - for (let i = 0; i < files.length; i += batchSize) { - if (cancellationToken.isCancelled) { - break; - } - - const batch = files.slice(i, i + batchSize); - const batchResults = await Promise.all( - batch.map(async (file) => { - try { - const content = await vault.read(file); - const tokenized = this.tokenizeContent(content); - const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file); - if (scoreResult.score > 0) { - const entry: VaultIndexEntry = { - path: file.path, - title: file.basename.replace(/\.md$/, ''), - content: content.substring(0, 500), - score: scoreResult.score, - }; - if (!seenPaths.has(entry.path)) { - seenPaths.add(entry.path); - return entry; - } - return null; + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map(async (file) => { + try { + const content = await vault.read(file); + const tokenized = this.tokenizeContent(content); + const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file); + if (scoreResult.score > 0) { + const entry: VaultIndexEntry = { + path: file.path, + title: file.basename.replace(/\.md$/, ''), + content: content.substring(0, 500), + score: scoreResult.score, + }; + if (!seenPaths.has(entry.path)) { + seenPaths.add(entry.path); + return entry; } return null; - } catch (error) { - Logger.warn( - `Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, - 'vault-indexer' - ); - return null; } - }) - ); + return null; + } catch (error) { + Logger.warn( + `Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, + 'vault-indexer' + ); + return null; + } + }) + ); - const validResults = batchResults.filter( - (result): result is NonNullable => result !== null - ); - results.push(...validResults); - - // Continue processing all files to ensure we don't miss higher-scoring results - // even if we've already found some matches - - // Removed processedCount increment - } - } finally { - clearInterval(checkInterval); + const validResults = batchResults.filter( + (result): result is NonNullable => result !== null + ); + results.push(...validResults); } return results; @@ -363,7 +326,7 @@ class VaultIndexer { } } -export { VaultIndexer, Cache, InMemoryCache, CancellationToken }; +export { VaultIndexer, Cache, InMemoryCache }; // Convenience method to create a VaultIndexer with an in-memory cache export function createVaultIndexerWithCache(vault: VaultLike): VaultIndexer { diff --git a/tests/vault-indexer.test.ts b/tests/vault-indexer.test.ts index 608929b..f6431ed 100755 --- a/tests/vault-indexer.test.ts +++ b/tests/vault-indexer.test.ts @@ -190,9 +190,6 @@ describe('VaultIndexer', () => { const results = await indexer.searchVault('algorithm', 5); expect(results.length).toBe(2); // File with heading match should score higher - console.log('Results:', JSON.stringify(results, null, 2)); - console.log('file1 content:', mockVault.read({ basename: 'file1', path: 'file1.md' })); - console.log('file2 content:', mockVault.read({ basename: 'file2', path: 'file2.md' })); expect(results[0].title).toBe('file1'); });