Refactor lint config and retry logic

This commit is contained in:
2026-05-07 14:44:59 +02:00
parent 140198177b
commit 4fabf1df98
15 changed files with 397 additions and 644 deletions
+8 -2
View File
@@ -26,7 +26,7 @@ module.exports = {
}, },
}, },
{ {
files: ['jest.config.js', '.eslintrc.js'], files: ['jest.config.js', '.eslintrc.js', 'jest.setup.js'],
parserOptions: { parserOptions: {
project: null, project: null,
}, },
@@ -37,5 +37,11 @@ module.exports = {
es2020: true, es2020: true,
jest: true, jest: true,
}, },
ignorePatterns: ['**/__mocks__/**', '**/*.test.ts'], ignorePatterns: [
'**/__mocks__/**',
'**/*.test.ts',
'src/**/*.js',
'jest.setup.js',
'jest.config.js',
],
}; };
-46
View File
@@ -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);
-34
View File
@@ -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}`);
});
-92
View File
@@ -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);
-30
View File
@@ -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));
+1 -1
View File
@@ -26,12 +26,12 @@
"@typescript-eslint/parser": "^6.19.1", "@typescript-eslint/parser": "^6.19.1",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^30.3.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"dependencies": { "dependencies": {
"jest-environment-jsdom": "^30.3.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"obsidian": "^1.4.11" "obsidian": "^1.4.11"
} }
+92 -87
View File
@@ -2,8 +2,6 @@
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.ChatView = void 0; exports.ChatView = void 0;
const obsidian_1 = require("obsidian"); const obsidian_1 = require("obsidian");
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
const MAX_MESSAGE_HISTORY = 50;
const MAX_STREAM_CHUNKS = 1000; const MAX_STREAM_CHUNKS = 1000;
const ollama_client_1 = require("./ollama-client"); const ollama_client_1 = require("./ollama-client");
const vault_indexer_1 = require("./vault-indexer"); const vault_indexer_1 = require("./vault-indexer");
@@ -213,7 +211,7 @@ class ChatView extends obsidian_1.ItemView {
updateLastMessage(content) { updateLastMessage(content) {
const streamingMessage = this.messages.find((msg) => msg.isStreaming); const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && !this.lastMessageEl) { if (streamingMessage && !this.lastMessageEl) {
this.lastMessageEl = this.contentEl.createEl('div', { this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', {
cls: `ollama-message assistant`, cls: `ollama-message assistant`,
}); });
this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id); this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
@@ -222,44 +220,8 @@ class ChatView extends obsidian_1.ItemView {
this.lastMessageEl.textContent = content; this.lastMessageEl.textContent = content;
} }
} }
async handleUserInput(content) { getTools() {
if (!this.sendButton || !this.inputEl) return [
return;
this.sendButton.disabled = true;
try {
// Guard against empty messages
const userMessage = content.trim();
if (!userMessage)
return;
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
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', type: 'function',
function: { function: {
@@ -279,51 +241,30 @@ class ChatView extends obsidian_1.ItemView {
}, },
}, },
]; ];
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }
const userMessageId = messageId; buildMessages(userMessage, context) {
const assistantMessageId = `${messageId}-assistant`; const systemContent = context
// Store user message in conversation history ? `You are a helpful assistant.\n\nRelevant vault context:\n${context}`
const userChatMessage = { : 'You are a helpful assistant.';
id: userMessageId, const systemMessage = {
role: 'system',
content: systemContent,
};
const userMessageWithContext = {
role: 'user', role: 'user',
content: userMessage, content: userMessage,
timestamp: Date.now(),
}; };
const assistantMessage = { return [
id: assistantMessageId, systemMessage,
role: 'assistant', ...this.messages.map((m) => ({
content: '', role: m.role,
timestamp: Date.now(), content: m.content,
isStreaming: true, tool_calls: m.tool_calls,
}; })),
// Update messages immutably userMessageWithContext,
this.messages = [...this.messages, userChatMessage, assistantMessage]; ];
try {
this.render();
const stream = this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls = [];
let chunkCount = 0;
for await (const chunk of stream) {
chunkCount++;
if (chunkCount > MAX_STREAM_CHUNKS) {
throw new Error('Response too long, stopped streaming');
} }
if (chunk.content) { async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) {
fullResponse += chunk.content;
}
if (chunk.tool_calls) {
toolCalls = toolCalls.concat(chunk.tool_calls);
}
this.updateLastMessage(fullResponse);
}
// Update the assistant message with the full response immutably
this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
});
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
// Validate tool calls before processing // Validate tool calls before processing
const MAX_TOOL_CALLS = 10; const MAX_TOOL_CALLS = 10;
if (toolCalls.length > MAX_TOOL_CALLS) { if (toolCalls.length > MAX_TOOL_CALLS) {
@@ -370,6 +311,72 @@ class ChatView extends obsidian_1.ItemView {
}); });
} }
} }
async handleUserInput(content) {
if (!this.sendButton || !this.inputEl)
return;
this.sendButton.disabled = true;
try {
// Guard against empty messages
const userMessage = content.trim();
if (!userMessage)
return;
// Search vault using user message as query
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 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`;
// Store user message in conversation history
const userChatMessage = {
id: userMessageId,
role: 'user',
content: userMessage,
timestamp: Date.now(),
};
const assistantMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
};
// Update messages immutably
this.messages = [...this.messages, userChatMessage, assistantMessage];
try {
this.render();
const stream = this.ollamaClient.streamChat(messages, tools);
let fullResponse = '';
let toolCalls = [];
let chunkCount = 0;
for await (const chunk of stream) {
chunkCount++;
if (chunkCount > MAX_STREAM_CHUNKS) {
throw new Error('Response too long, stopped streaming');
}
if (chunk.content) {
fullResponse += chunk.content;
}
if (chunk.tool_calls) {
toolCalls = toolCalls.concat(chunk.tool_calls);
}
this.updateLastMessage(fullResponse);
}
// Update the assistant message with the full response immutably
this.updateMessageById(assistantMessageId, {
content: fullResponse,
tool_calls: toolCalls,
});
// Process tool calls with proper follow-up context
if (toolCalls.length > 0) {
await this.processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId);
}
// Update assistant message immutably — only if no tool calls were processed // Update assistant message immutably — only if no tool calls were processed
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
this.updateMessageById(assistantMessageId, { this.updateMessageById(assistantMessageId, {
@@ -377,8 +384,8 @@ class ChatView extends obsidian_1.ItemView {
}); });
} }
// Limit conversation history to prevent memory issues // Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) { if (this.messages.length > this.settings.maxMessageHistory) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY); this.messages = this.messages.slice(-this.settings.maxMessageHistory);
} }
this.render(); this.render();
} }
@@ -391,11 +398,9 @@ class ChatView extends obsidian_1.ItemView {
// Use centralized error handler // Use centralized error handler
error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput'); error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput');
// Update any streaming messages to non-streaming state to prevent stale messages // 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.messages = this.messages.map((msg) => msg.isStreaming ? { ...msg, isStreaming: false } : msg);
this.cleanupStreamingResources(); this.cleanupStreamingResources();
this.render();
} }
finally { finally {
if (this.sendButton) { if (this.sendButton) {
+119 -103
View File
@@ -5,8 +5,6 @@ type KeyboardEvent = globalThis.KeyboardEvent;
type HTMLTextAreaElement = globalThis.HTMLTextAreaElement; type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
type HTMLButtonElement = globalThis.HTMLButtonElement; type HTMLButtonElement = globalThis.HTMLButtonElement;
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
const MAX_MESSAGE_HISTORY = 50;
const MAX_STREAM_CHUNKS = 1000; const MAX_STREAM_CHUNKS = 1000;
import { import {
PluginSettings, PluginSettings,
@@ -258,7 +256,7 @@ export class ChatView extends ItemView {
private updateLastMessage(content: string) { private updateLastMessage(content: string) {
const streamingMessage = this.messages.find((msg) => msg.isStreaming); const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && !this.lastMessageEl) { if (streamingMessage && !this.lastMessageEl) {
this.lastMessageEl = this.contentEl.createEl('div', { this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', {
cls: `ollama-message assistant`, cls: `ollama-message assistant`,
}) as HTMLElement; }) as HTMLElement;
this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id); this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
@@ -268,51 +266,8 @@ export class ChatView extends ItemView {
} }
} }
private async handleUserInput(content: string) { private getTools(): OllamaTool[] {
if (!this.sendButton || !this.inputEl) return; return [
this.sendButton.disabled = true;
try {
// Guard against empty messages
const userMessage = content.trim();
if (!userMessage) return;
// Search vault using user message as query
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
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: 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', type: 'function',
function: { function: {
@@ -332,6 +287,118 @@ export class ChatView extends ItemView {
}, },
}, },
]; ];
}
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<void> {
// 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;
try {
// Guard against empty messages
const userMessage = content.trim();
if (!userMessage) return;
// Search vault using user message as query
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 messages = this.buildMessages(userMessage, context);
const tools = this.getTools();
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 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 // Process tool calls with proper follow-up context
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
// Validate tool calls before processing await this.processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId);
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,
});
}
} }
// Update assistant message immutably — only if no tool calls were processed // 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 // Limit conversation history to prevent memory issues
if (this.messages.length > MAX_MESSAGE_HISTORY) { if (this.messages.length > this.settings.maxMessageHistory) {
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY); this.messages = this.messages.slice(-this.settings.maxMessageHistory);
} }
this.render(); this.render();
} finally { } finally {
@@ -461,13 +479,11 @@ export class ChatView extends ItemView {
// Use centralized error handler // Use centralized error handler
ErrorHandler.handleError(error, 'ChatView.handleUserInput'); ErrorHandler.handleError(error, 'ChatView.handleUserInput');
// Update any streaming messages to non-streaming state to prevent stale messages // 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) => this.messages = this.messages.map((msg) =>
msg.isStreaming ? { ...msg, isStreaming: false } : msg msg.isStreaming ? { ...msg, isStreaming: false } : msg
); );
this.cleanupStreamingResources(); this.cleanupStreamingResources();
this.render();
} finally { } finally {
if (this.sendButton) { if (this.sendButton) {
this.sendButton.disabled = false; this.sendButton.disabled = false;
+9 -7
View File
@@ -50,7 +50,6 @@ class OllamaClient {
if (response.status >= 500 && attempt < this.maxRetries) { if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; 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'); 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 retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
const abortListener = () => { const abortListener = () => {
utils_1.Logger.info('Retry aborted by user', 'ollama-client'); utils_1.Logger.info('Retry aborted by user', 'ollama-client');
@@ -75,7 +74,6 @@ class OllamaClient {
else { else {
await retryTimeout; await retryTimeout;
} }
}
yield* this.streamChatWithRetry(messages, tools, attempt + 1); yield* this.streamChatWithRetry(messages, tools, attempt + 1);
return; return;
} }
@@ -148,12 +146,16 @@ class OllamaClient {
} }
} }
finally { finally {
// Abort the local controller // Abort the local controller to release underlying fetch resources if not already aborted
if (!controller.signal.aborted) {
controller.abort(); controller.abort();
} }
// Clean up the reference // Clean up the reference only if this is still the current stream
if (this.currentStreamController === controller) {
this.currentStreamController = null; this.currentStreamController = null;
} }
}
}
async chat(messages, tools = []) { async chat(messages, tools = []) {
return this.chatWithRetry(messages, tools, 0); return this.chatWithRetry(messages, tools, 0);
} }
@@ -178,7 +180,6 @@ class OllamaClient {
if (response.status >= 500 && attempt < this.maxRetries) { if (response.status >= 500 && attempt < this.maxRetries) {
const retryDelay = Math.pow(2, attempt) * 100; 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'); 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 retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
const abortListener = () => { const abortListener = () => {
utils_1.Logger.info('Retry aborted by user', 'ollama-client'); utils_1.Logger.info('Retry aborted by user', 'ollama-client');
@@ -203,7 +204,6 @@ class OllamaClient {
else { else {
await retryTimeout; await retryTimeout;
} }
}
return this.chatWithRetry(messages, tools, attempt + 1); return this.chatWithRetry(messages, tools, attempt + 1);
} }
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
@@ -212,10 +212,12 @@ class OllamaClient {
return (this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }); return (this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] });
} }
finally { finally {
// Abort the local controller // Abort the local controller to release underlying fetch resources if not already aborted
if (!controller.signal.aborted) {
controller.abort(); controller.abort();
} }
} }
}
throwIfOllamaError(parsed) { throwIfOllamaError(parsed) {
if (parsed.error) { if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`); throw new Error(`Ollama error: ${String(parsed.error)}`);
+6 -6
View File
@@ -77,7 +77,6 @@ export class OllamaClient {
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client' 'ollama-client'
); );
if (attempt < this.maxRetries - 1) {
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
const abortListener = () => { const abortListener = () => {
Logger.info('Retry aborted by user', 'ollama-client'); Logger.info('Retry aborted by user', 'ollama-client');
@@ -100,7 +99,6 @@ export class OllamaClient {
} else { } else {
await retryTimeout; await retryTimeout;
} }
}
yield* this.streamChatWithRetry(messages, tools, attempt + 1); yield* this.streamChatWithRetry(messages, tools, attempt + 1);
return; return;
} }
@@ -187,8 +185,10 @@ export class OllamaClient {
reader.releaseLock(); reader.releaseLock();
} }
} finally { } finally {
// Abort the local controller // Abort the local controller to release underlying fetch resources if not already aborted
if (!controller.signal.aborted) {
controller.abort(); controller.abort();
}
// Clean up the reference only if this is still the current stream // Clean up the reference only if this is still the current stream
if (this.currentStreamController === controller) { if (this.currentStreamController === controller) {
this.currentStreamController = null; this.currentStreamController = null;
@@ -229,7 +229,6 @@ export class OllamaClient {
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
'ollama-client' 'ollama-client'
); );
if (attempt < this.maxRetries - 1) {
const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay));
const abortListener = () => { const abortListener = () => {
Logger.info('Retry aborted by user', 'ollama-client'); Logger.info('Retry aborted by user', 'ollama-client');
@@ -252,7 +251,6 @@ export class OllamaClient {
} else { } else {
await retryTimeout; await retryTimeout;
} }
}
return this.chatWithRetry(messages, tools, attempt + 1); return this.chatWithRetry(messages, tools, attempt + 1);
} }
throw new ApiError(`Ollama API error: ${response.status}`, response.status); throw new ApiError(`Ollama API error: ${response.status}`, response.status);
@@ -263,10 +261,12 @@ export class OllamaClient {
this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] } this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] }
); );
} finally { } finally {
// Abort the local controller // Abort the local controller to release underlying fetch resources if not already aborted
if (!controller.signal.aborted) {
controller.abort(); controller.abort();
} }
} }
}
private throwIfOllamaError(parsed: Record<string, unknown>): void { private throwIfOllamaError(parsed: Record<string, unknown>): void {
if (parsed.error) { if (parsed.error) {
+2 -2
View File
@@ -147,8 +147,8 @@ function safeParseJson(jsonString) {
if (dangerousKeys.some((key) => Object.keys(obj).includes(key))) { if (dangerousKeys.some((key) => Object.keys(obj).includes(key))) {
return true; return true;
} }
// Recursively check nested objects // Recursively check nested objects (own properties only)
for (const key in obj) { for (const key of Object.keys(obj)) {
if (checkDangerousPatterns(obj[key])) { if (checkDangerousPatterns(obj[key])) {
return true; return true;
} }
+2 -2
View File
@@ -166,8 +166,8 @@ export function safeParseJson(jsonString: string): unknown {
return true; return true;
} }
// Recursively check nested objects // Recursively check nested objects (own properties only)
for (const key in obj as Record<string, unknown>) { for (const key of Object.keys(obj as Record<string, unknown>)) {
if (checkDangerousPatterns((obj as Record<string, unknown>)[key])) { if (checkDangerousPatterns((obj as Record<string, unknown>)[key])) {
return true; return true;
} }
+2 -36
View File
@@ -1,7 +1,7 @@
"use strict"; "use strict";
// src/vault-indexer.ts // src/vault-indexer.ts
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.CancellationToken = exports.InMemoryCache = exports.VaultIndexer = void 0; exports.InMemoryCache = exports.VaultIndexer = void 0;
exports.createVaultIndexerWithCache = createVaultIndexerWithCache; exports.createVaultIndexerWithCache = createVaultIndexerWithCache;
const utils_1 = require("./utils"); const utils_1 = require("./utils");
class InMemoryCache { class InMemoryCache {
@@ -21,18 +21,6 @@ class InMemoryCache {
} }
} }
exports.InMemoryCache = InMemoryCache; exports.InMemoryCache = InMemoryCache;
class CancellationToken {
constructor() {
this.cancelled = false;
}
cancel() {
this.cancelled = true;
}
get isCancelled() {
return this.cancelled;
}
}
exports.CancellationToken = CancellationToken;
class VaultIndexer { class VaultIndexer {
constructor(vault, cache) { constructor(vault, cache) {
this.vault = null; this.vault = null;
@@ -70,10 +58,7 @@ class VaultIndexer {
const vault = this.vault; const vault = this.vault;
const allFiles = vault.getMarkdownFiles(); const allFiles = vault.getMarkdownFiles();
const results = await this.processFilesInBatches(vault, allFiles, queryTokens); const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
const filteredResults = results const filteredResults = results.sort((a, b) => b.score - a.score).slice(0, limit);
.filter((result) => result !== null)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
if (this.cache) { if (this.cache) {
try { try {
await this.cache.put(cacheKey, JSON.stringify(filteredResults)); await this.cache.put(cacheKey, JSON.stringify(filteredResults));
@@ -88,19 +73,7 @@ class VaultIndexer {
const batchSize = 10; const batchSize = 10;
const results = []; const results = [];
const seenPaths = new Set(); 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) { for (let i = 0; i < files.length; i += batchSize) {
if (cancellationToken.isCancelled) {
break;
}
const batch = files.slice(i, i + batchSize); const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (file) => { const batchResults = await Promise.all(batch.map(async (file) => {
try { try {
@@ -129,13 +102,6 @@ class VaultIndexer {
})); }));
const validResults = batchResults.filter((result) => result !== null); const validResults = batchResults.filter((result) => result !== null);
results.push(...validResults); 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 results; return results;
} }
+3 -40
View File
@@ -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 { interface Frontmatter {
title?: string; title?: string;
tags?: string; tags?: string;
@@ -112,10 +100,7 @@ class VaultIndexer {
const allFiles = vault.getMarkdownFiles(); const allFiles = vault.getMarkdownFiles();
const results = await this.processFilesInBatches(vault, allFiles, queryTokens); const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
const filteredResults = results const filteredResults = results.sort((a, b) => b.score - a.score).slice(0, limit);
.filter((result): result is NonNullable<typeof result> => result !== null)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
if (this.cache) { if (this.cache) {
try { try {
@@ -135,26 +120,12 @@ class VaultIndexer {
vault: VaultLike, vault: VaultLike,
files: VaultFile[], files: VaultFile[],
queryTokens: string[] queryTokens: string[]
): Promise<Array<VaultIndexEntry | null>> { ): Promise<VaultIndexEntry[]> {
const batchSize = 10; const batchSize = 10;
const results: VaultIndexEntry[] = []; const results: VaultIndexEntry[] = [];
const seenPaths = new Set<string>(); const seenPaths = new Set<string>();
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) { for (let i = 0; i < files.length; i += batchSize) {
if (cancellationToken.isCancelled) {
break;
}
const batch = files.slice(i, i + batchSize); const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all( const batchResults = await Promise.all(
batch.map(async (file) => { batch.map(async (file) => {
@@ -190,14 +161,6 @@ class VaultIndexer {
(result): result is NonNullable<typeof result> => result !== null (result): result is NonNullable<typeof result> => result !== null
); );
results.push(...validResults); 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 results; 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 // Convenience method to create a VaultIndexer with an in-memory cache
export function createVaultIndexerWithCache(vault: VaultLike): VaultIndexer { export function createVaultIndexerWithCache(vault: VaultLike): VaultIndexer {
-3
View File
@@ -190,9 +190,6 @@ describe('VaultIndexer', () => {
const results = await indexer.searchVault('algorithm', 5); const results = await indexer.searchVault('algorithm', 5);
expect(results.length).toBe(2); expect(results.length).toBe(2);
// File with heading match should score higher // 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'); expect(results[0].title).toBe('file1');
}); });