Update coverage reports and improve error handling
This commit is contained in:
@@ -23,30 +23,30 @@
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">91.54% </span>
|
||||
<span class="strong">91.03% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>184/201</span>
|
||||
<span class='fraction'>193/212</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">83.07% </span>
|
||||
<span class="strong">84.37% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>54/65</span>
|
||||
<span class='fraction'>54/64</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">87.09% </span>
|
||||
<span class="strong">78.78% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>27/31</span>
|
||||
<span class='fraction'>26/33</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">93.58% </span>
|
||||
<span class="strong">92.5% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>175/187</span>
|
||||
<span class='fraction'>185/200</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -517,7 +517,21 @@
|
||||
<a name='L452'></a><a href='#L452'>452</a>
|
||||
<a name='L453'></a><a href='#L453'>453</a>
|
||||
<a name='L454'></a><a href='#L454'>454</a>
|
||||
<a name='L455'></a><a href='#L455'>455</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
|
||||
<a name='L455'></a><a href='#L455'>455</a>
|
||||
<a name='L456'></a><a href='#L456'>456</a>
|
||||
<a name='L457'></a><a href='#L457'>457</a>
|
||||
<a name='L458'></a><a href='#L458'>458</a>
|
||||
<a name='L459'></a><a href='#L459'>459</a>
|
||||
<a name='L460'></a><a href='#L460'>460</a>
|
||||
<a name='L461'></a><a href='#L461'>461</a>
|
||||
<a name='L462'></a><a href='#L462'>462</a>
|
||||
<a name='L463'></a><a href='#L463'>463</a>
|
||||
<a name='L464'></a><a href='#L464'>464</a>
|
||||
<a name='L465'></a><a href='#L465'>465</a>
|
||||
<a name='L466'></a><a href='#L466'>466</a>
|
||||
<a name='L467'></a><a href='#L467'>467</a>
|
||||
<a name='L468'></a><a href='#L468'>468</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -554,6 +568,9 @@
|
||||
<span class="cline-any cline-yes">31x</span>
|
||||
<span class="cline-any cline-yes">31x</span>
|
||||
<span class="cline-any cline-yes">31x</span>
|
||||
<span class="cline-any cline-yes">31x</span>
|
||||
<span class="cline-any cline-yes">31x</span>
|
||||
<span class="cline-any cline-yes">31x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -593,6 +610,7 @@
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -607,6 +625,7 @@
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -654,12 +673,9 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">33x</span>
|
||||
<span class="cline-any cline-yes">128x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">33x</span>
|
||||
<span class="cline-any cline-yes">247x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">247x</span>
|
||||
<span class="cline-any cline-yes">104x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -714,12 +730,21 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -747,6 +772,9 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -771,10 +799,10 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">146x</span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
<span class="cline-any cline-yes">13x</span>
|
||||
@@ -793,6 +821,8 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
@@ -865,6 +895,7 @@
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
@@ -874,7 +905,6 @@
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
@@ -892,20 +922,12 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
@@ -962,6 +984,10 @@
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -971,12 +997,13 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian';
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
|
||||
/// <reference lib="dom" />
|
||||
// Use global types from JSDOM setup
|
||||
type KeyboardEvent = globalThis.KeyboardEvent;
|
||||
type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
|
||||
type HTMLButtonElement = globalThis.HTMLButtonElement;
|
||||
type MouseEvent = globalThis.MouseEvent;
|
||||
|
||||
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
|
||||
const MAX_MESSAGE_HISTORY = 50;
|
||||
@@ -1007,6 +1034,9 @@ export class ChatView extends ItemView {
|
||||
private sendButtonClickHandler: (() => Promise<void>) | null = null;
|
||||
private inputKeyDownHandler: ((e: KeyboardEvent) => Promise<void>) | null = null;
|
||||
private newChatButtonClickHandler: (() => void) | null = null;
|
||||
private sendButtonEventHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private inputKeyDownEventHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonEventHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private listenersAttached = false;
|
||||
|
||||
// Getters for testing
|
||||
@@ -1043,17 +1073,18 @@ export class ChatView extends ItemView {
|
||||
return 'Ollama Chat';
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
await this.render();
|
||||
onOpen(): Promise<void> {
|
||||
this.render();
|
||||
this.removeEventListeners(); // Clean up any existing listeners before reattaching
|
||||
this.setupEventListeners();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public <span class="fstat-no" title="function not covered" >onSettingsChange(</span>newSettings: PluginSettings): void {
|
||||
<span class="cstat-no" title="statement not covered" > this.updateSettings(newSettings);</span>
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
onClose(): Promise<void> {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
this.cleanupStreamingResources();
|
||||
@@ -1061,6 +1092,7 @@ export class ChatView extends ItemView {
|
||||
this.sendButton = null;
|
||||
this.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private cleanupStreamingResources(): void {
|
||||
@@ -1071,7 +1103,7 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
async render() {
|
||||
render() {
|
||||
const container =
|
||||
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
|
||||
this.chatContainer = container;
|
||||
@@ -1085,7 +1117,7 @@ export class ChatView extends ItemView {
|
||||
if (!this.sendButton) {
|
||||
this.sendButton = inputContainer.createEl('button', {
|
||||
cls: 'ollama-send-button',
|
||||
}) as HTMLButtonElement;
|
||||
});
|
||||
(this.sendButton as HTMLButtonElement).textContent = 'Send';
|
||||
}
|
||||
|
||||
@@ -1095,7 +1127,7 @@ export class ChatView extends ItemView {
|
||||
this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
|
||||
this.newChatButton = newChatContainer.createEl('button', {
|
||||
cls: 'ollama-new-chat-button',
|
||||
}) as HTMLButtonElement;
|
||||
});
|
||||
(this.newChatButton as HTMLButtonElement).textContent = '🔄 New Chat';
|
||||
(this.newChatButton as HTMLButtonElement).title = 'Start a new conversation';
|
||||
}
|
||||
@@ -1108,12 +1140,9 @@ export class ChatView extends ItemView {
|
||||
|
||||
// Differential update: only update messages that have changed
|
||||
const existingMessages = container.querySelectorAll('.ollama-message');
|
||||
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
|
||||
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(
|
||||
`.ollama-message[data-msg-id="${msg.id}"]`
|
||||
) as HTMLElement | null;
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
if (existingEl) {
|
||||
existingEl.textContent = msg.content;
|
||||
} else {
|
||||
@@ -1167,39 +1196,51 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonClickHandler!);
|
||||
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownHandler!);
|
||||
this.sendButtonEventHandler = <span class="fstat-no" title="function not covered" >() =</span>> {
|
||||
<span class="cstat-no" title="statement not covered" > void this.sendButtonClickHandler?.();</span>
|
||||
};
|
||||
this.inputKeyDownEventHandler = <span class="fstat-no" title="function not covered" >(e</span>: KeyboardEvent) => {
|
||||
<span class="cstat-no" title="statement not covered" > void this.inputKeyDownHandler?.(e);</span>
|
||||
};
|
||||
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonEventHandler);
|
||||
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownEventHandler);
|
||||
if (this.newChatButton) {
|
||||
if (!this.newChatButtonClickHandler) {
|
||||
this.newChatButtonClickHandler = () => this.clearConversation();
|
||||
}
|
||||
this.newChatButtonEventHandler = <span class="fstat-no" title="function not covered" >() =</span>> {
|
||||
<span class="cstat-no" title="statement not covered" > this.newChatButtonClickHandler?.();</span>
|
||||
};
|
||||
(this.newChatButton as HTMLButtonElement).addEventListener(
|
||||
'click',
|
||||
this.newChatButtonClickHandler!
|
||||
this.newChatButtonEventHandler
|
||||
);
|
||||
}
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
if (this.sendButton && this.sendButtonEventHandler) {
|
||||
(this.sendButton as HTMLButtonElement).removeEventListener(
|
||||
'click',
|
||||
this.sendButtonClickHandler!
|
||||
this.sendButtonEventHandler
|
||||
);
|
||||
}
|
||||
if (this.inputEl && this.inputKeyDownHandler) {
|
||||
if (this.inputEl && this.inputKeyDownEventHandler) {
|
||||
(this.inputEl as HTMLTextAreaElement).removeEventListener(
|
||||
'keydown',
|
||||
this.inputKeyDownHandler!
|
||||
this.inputKeyDownEventHandler
|
||||
);
|
||||
}
|
||||
if (this.newChatButton && this.newChatButtonClickHandler) {
|
||||
if (this.newChatButton && this.newChatButtonEventHandler) {
|
||||
(this.newChatButton as HTMLButtonElement).removeEventListener(
|
||||
'click',
|
||||
this.newChatButtonClickHandler!
|
||||
this.newChatButtonEventHandler
|
||||
);
|
||||
}
|
||||
this.sendButtonEventHandler = null;
|
||||
this.inputKeyDownEventHandler = null;
|
||||
this.newChatButtonEventHandler = null;
|
||||
this.listenersAttached = false;
|
||||
}
|
||||
|
||||
@@ -1222,7 +1263,7 @@ export class ChatView extends ItemView {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async updateLastMessage(content: string) {
|
||||
private updateLastMessage(content: string) {
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && !this.lastMessageEl) {
|
||||
this.lastMessageEl = this.contentEl.createEl('div', {
|
||||
@@ -1246,7 +1287,9 @@ export class ChatView extends ItemView {
|
||||
|
||||
// Search vault using user message as query
|
||||
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
|
||||
let context = entries.map(<span class="fstat-no" title="function not covered" >(e</span>) => <span class="cstat-no" title="statement not covered" >`### ${e.title}\n${e.content}`)</span>.join('\n\n');
|
||||
let context = entries
|
||||
.map(<span class="fstat-no" title="function not covered" >(e</span>ntry) => <span class="cstat-no" title="statement not covered" >`### ${entry.title}\n${entry.content}`)</span>
|
||||
.join('\n\n');
|
||||
|
||||
// Cap context size to prevent prompt bloat with large vaults
|
||||
const MAX_CONTEXT_LENGTH = 4000;
|
||||
@@ -1317,17 +1360,17 @@ export class ChatView extends ItemView {
|
||||
|
||||
// Update messages immutably
|
||||
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
||||
|
||||
await this.render();
|
||||
|
||||
const stream = await this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls: ToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
|
||||
try {
|
||||
this.render();
|
||||
|
||||
const stream = this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls: ToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunkCount++;
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (chunkCount > maxChunks) {
|
||||
@@ -1342,78 +1385,74 @@ export class ChatView extends ItemView {
|
||||
toolCalls = toolCalls.concat(chunk.tool_calls);
|
||||
}
|
||||
|
||||
await this.updateLastMessage(fullResponse);
|
||||
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
|
||||
const MAX_TOOL_CALLS = 10;
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (toolCalls.length > MAX_TOOL_CALLS) {
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);</span>
|
||||
}
|
||||
|
||||
// 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) {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (result.status === 'fulfilled') {
|
||||
<span class="cstat-no" title="statement not covered" > toolResults.push(result.value);</span>
|
||||
} else {
|
||||
// Use centralized error handler for tool errors
|
||||
ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
|
||||
}
|
||||
}
|
||||
|
||||
// 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(<span class="fstat-no" title="function not covered" >(r</span>esult) => (<span class="cstat-no" title="statement not covered" >{</span>
|
||||
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 });
|
||||
}
|
||||
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
this.render();
|
||||
} finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
|
||||
// Update the assistant message with the full response immutably
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (
|
||||
!this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
tool_calls: toolCalls,
|
||||
})
|
||||
) {
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error('Assistant message not found');</span>
|
||||
}
|
||||
|
||||
// Process tool calls with proper follow-up context
|
||||
if (toolCalls.length > 0) {
|
||||
// Validate tool calls before processing
|
||||
const MAX_TOOL_CALLS = 10;
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (toolCalls.length > MAX_TOOL_CALLS) {
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);</span>
|
||||
}
|
||||
|
||||
// Collect all tool results using allSettled to support partial results
|
||||
const settledResults = await Promise.allSettled(
|
||||
toolCalls.map((call) => this.toolExecutor.handleToolCall(call))
|
||||
);
|
||||
|
||||
let toolResults: ToolResult[] = [];
|
||||
for (const result of settledResults) {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (result.status === 'fulfilled') {
|
||||
<span class="cstat-no" title="statement not covered" > toolResults.push(result.value);</span>
|
||||
} else {
|
||||
// Use centralized error handler for tool errors
|
||||
ErrorHandler.handleError(result.reason, 'ChatView.handleUserInput');
|
||||
}
|
||||
}
|
||||
|
||||
// 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(<span class="fstat-no" title="function not covered" >(r</span>esult) => (<span class="cstat-no" title="statement not covered" >{</span>
|
||||
role: 'tool' as const,
|
||||
content: JSON.stringify(result),
|
||||
})),
|
||||
];
|
||||
|
||||
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
|
||||
fullResponse += followUp.content;
|
||||
await this.updateLastMessage(fullResponse);
|
||||
|
||||
// Update the assistant message with the final response immutably
|
||||
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
|
||||
}
|
||||
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
await this.render();
|
||||
} catch (error) {
|
||||
// Use centralized error handler
|
||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
@@ -1432,7 +1471,7 @@ export class ChatView extends ItemView {
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -197,7 +197,11 @@
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
@@ -218,12 +222,16 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">11x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -352,12 +360,16 @@ export class ErrorHandler {
|
||||
|
||||
if (error instanceof Error) {
|
||||
const ctx = context ? ` [${context}]` : '';
|
||||
// Use console.error instead of ErrorHandler.error for fatal errors
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
|
||||
if (error.stack) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error.stack);
|
||||
}
|
||||
} else {
|
||||
const ctx = context ? <span class="branch-0 cbranch-no" title="branch not covered" >` [${context}]` </span>: '';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ollama Plugin Error${ctx}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -472,7 +484,7 @@ export class ErrorHandler {
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -23,30 +23,30 @@
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">85.02% </span>
|
||||
<span class="strong">84.91% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>579/681</span>
|
||||
<span class='fraction'>591/696</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">74.34% </span>
|
||||
<span class="strong">77.02% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>226/304</span>
|
||||
<span class='fraction'>228/296</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">81.37% </span>
|
||||
<span class="strong">79.43% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>83/102</span>
|
||||
<span class='fraction'>85/107</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">85.91% </span>
|
||||
<span class="strong">85.67% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>561/653</span>
|
||||
<span class='fraction'>574/670</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -80,17 +80,17 @@
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="chat-view.ts"><a href="chat-view.ts.html">chat-view.ts</a></td>
|
||||
<td data-value="91.54" class="pic high">
|
||||
<td data-value="91.03" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
|
||||
</td>
|
||||
<td data-value="91.54" class="pct high">91.54%</td>
|
||||
<td data-value="201" class="abs high">184/201</td>
|
||||
<td data-value="83.07" class="pct high">83.07%</td>
|
||||
<td data-value="65" class="abs high">54/65</td>
|
||||
<td data-value="87.09" class="pct high">87.09%</td>
|
||||
<td data-value="31" class="abs high">27/31</td>
|
||||
<td data-value="93.58" class="pct high">93.58%</td>
|
||||
<td data-value="187" class="abs high">175/187</td>
|
||||
<td data-value="91.03" class="pct high">91.03%</td>
|
||||
<td data-value="212" class="abs high">193/212</td>
|
||||
<td data-value="84.37" class="pct high">84.37%</td>
|
||||
<td data-value="64" class="abs high">54/64</td>
|
||||
<td data-value="78.78" class="pct medium">78.78%</td>
|
||||
<td data-value="33" class="abs medium">26/33</td>
|
||||
<td data-value="92.5" class="pct high">92.5%</td>
|
||||
<td data-value="200" class="abs high">185/200</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -110,17 +110,17 @@
|
||||
|
||||
<tr>
|
||||
<td class="file medium" data-value="ollama-client.ts"><a href="ollama-client.ts.html">ollama-client.ts</a></td>
|
||||
<td data-value="76.84" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 76%"></div><div class="cover-empty" style="width: 24%"></div></div>
|
||||
<td data-value="77.65" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 77%"></div><div class="cover-empty" style="width: 23%"></div></div>
|
||||
</td>
|
||||
<td data-value="76.84" class="pct medium">76.84%</td>
|
||||
<td data-value="95" class="abs medium">73/95</td>
|
||||
<td data-value="55.17" class="pct medium">55.17%</td>
|
||||
<td data-value="58" class="abs medium">32/58</td>
|
||||
<td data-value="88.88" class="pct high">88.88%</td>
|
||||
<td data-value="9" class="abs high">8/9</td>
|
||||
<td data-value="78.02" class="pct medium">78.02%</td>
|
||||
<td data-value="91" class="abs medium">71/91</td>
|
||||
<td data-value="77.65" class="pct medium">77.65%</td>
|
||||
<td data-value="94" class="abs medium">73/94</td>
|
||||
<td data-value="61.22" class="pct medium">61.22%</td>
|
||||
<td data-value="49" class="abs medium">30/49</td>
|
||||
<td data-value="90.9" class="pct high">90.9%</td>
|
||||
<td data-value="11" class="abs high">10/11</td>
|
||||
<td data-value="78.88" class="pct medium">78.88%</td>
|
||||
<td data-value="90" class="abs medium">71/90</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -154,33 +154,33 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file high" data-value="utils.ts"><a href="utils.ts.html">utils.ts</a></td>
|
||||
<td data-value="81.05" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 81%"></div><div class="cover-empty" style="width: 19%"></div></div>
|
||||
<td class="file medium" data-value="utils.ts"><a href="utils.ts.html">utils.ts</a></td>
|
||||
<td data-value="79.38" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 79%"></div><div class="cover-empty" style="width: 21%"></div></div>
|
||||
</td>
|
||||
<td data-value="81.05" class="pct high">81.05%</td>
|
||||
<td data-value="95" class="abs high">77/95</td>
|
||||
<td data-value="79.38" class="pct medium">79.38%</td>
|
||||
<td data-value="97" class="abs medium">77/97</td>
|
||||
<td data-value="70.83" class="pct medium">70.83%</td>
|
||||
<td data-value="48" class="abs medium">34/48</td>
|
||||
<td data-value="52.94" class="pct medium">52.94%</td>
|
||||
<td data-value="17" class="abs medium">9/17</td>
|
||||
<td data-value="81.52" class="pct high">81.52%</td>
|
||||
<td data-value="92" class="abs high">75/92</td>
|
||||
<td data-value="79.78" class="pct medium">79.78%</td>
|
||||
<td data-value="94" class="abs medium">75/94</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file medium" data-value="vault-indexer.ts"><a href="vault-indexer.ts.html">vault-indexer.ts</a></td>
|
||||
<td data-value="74.3" class="pic medium">
|
||||
<td data-value="74.82" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 74%"></div><div class="cover-empty" style="width: 26%"></div></div>
|
||||
</td>
|
||||
<td data-value="74.3" class="pct medium">74.3%</td>
|
||||
<td data-value="144" class="abs medium">107/144</td>
|
||||
<td data-value="72" class="pct medium">72%</td>
|
||||
<td data-value="50" class="abs medium">36/50</td>
|
||||
<td data-value="71.42" class="pct medium">71.42%</td>
|
||||
<td data-value="21" class="abs medium">15/21</td>
|
||||
<td data-value="74.45" class="pct medium">74.45%</td>
|
||||
<td data-value="137" class="abs medium">102/137</td>
|
||||
<td data-value="74.82" class="pct medium">74.82%</td>
|
||||
<td data-value="147" class="abs medium">110/147</td>
|
||||
<td data-value="76.92" class="pct medium">76.92%</td>
|
||||
<td data-value="52" class="abs medium">40/52</td>
|
||||
<td data-value="72.72" class="pct medium">72.72%</td>
|
||||
<td data-value="22" class="abs medium">16/22</td>
|
||||
<td data-value="75" class="pct medium">75%</td>
|
||||
<td data-value="140" class="abs medium">105/140</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
@@ -191,7 +191,7 @@
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -23,30 +23,30 @@
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">76.84% </span>
|
||||
<span class="strong">77.65% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>73/95</span>
|
||||
<span class='fraction'>73/94</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">55.17% </span>
|
||||
<span class="strong">61.22% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>32/58</span>
|
||||
<span class='fraction'>30/49</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">88.88% </span>
|
||||
<span class="strong">90.9% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>8/9</span>
|
||||
<span class='fraction'>10/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">78.02% </span>
|
||||
<span class="strong">78.88% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>71/91</span>
|
||||
<span class='fraction'>71/90</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -292,34 +292,16 @@
|
||||
<a name='L227'></a><a href='#L227'>227</a>
|
||||
<a name='L228'></a><a href='#L228'>228</a>
|
||||
<a name='L229'></a><a href='#L229'>229</a>
|
||||
<a name='L230'></a><a href='#L230'>230</a>
|
||||
<a name='L231'></a><a href='#L231'>231</a>
|
||||
<a name='L232'></a><a href='#L232'>232</a>
|
||||
<a name='L233'></a><a href='#L233'>233</a>
|
||||
<a name='L234'></a><a href='#L234'>234</a>
|
||||
<a name='L235'></a><a href='#L235'>235</a>
|
||||
<a name='L236'></a><a href='#L236'>236</a>
|
||||
<a name='L237'></a><a href='#L237'>237</a>
|
||||
<a name='L238'></a><a href='#L238'>238</a>
|
||||
<a name='L239'></a><a href='#L239'>239</a>
|
||||
<a name='L240'></a><a href='#L240'>240</a>
|
||||
<a name='L241'></a><a href='#L241'>241</a>
|
||||
<a name='L242'></a><a href='#L242'>242</a>
|
||||
<a name='L243'></a><a href='#L243'>243</a>
|
||||
<a name='L244'></a><a href='#L244'>244</a>
|
||||
<a name='L245'></a><a href='#L245'>245</a>
|
||||
<a name='L246'></a><a href='#L246'>246</a>
|
||||
<a name='L247'></a><a href='#L247'>247</a>
|
||||
<a name='L248'></a><a href='#L248'>248</a>
|
||||
<a name='L249'></a><a href='#L249'>249</a>
|
||||
<a name='L250'></a><a href='#L250'>250</a>
|
||||
<a name='L251'></a><a href='#L251'>251</a>
|
||||
<a name='L252'></a><a href='#L252'>252</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<a name='L230'></a><a href='#L230'>230</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -351,11 +333,6 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
@@ -363,10 +340,6 @@
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -390,7 +363,6 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">14x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
@@ -434,11 +406,7 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">61x</span>
|
||||
<span class="cline-any cline-yes">61x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
@@ -446,13 +414,8 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">53x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -470,23 +433,16 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -516,7 +472,6 @@
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -531,7 +486,6 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
@@ -545,20 +499,22 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">12x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">11x</span>
|
||||
<span class="cline-any cline-yes">11x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -568,9 +524,13 @@
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">// src/ollama-client.ts
|
||||
|
||||
import type { OllamaMessage, OllamaTool } from './types';
|
||||
import { ApiError, NetworkError } from './types';
|
||||
import { ApiError } from './types';
|
||||
import { Logger } from './utils';
|
||||
|
||||
interface OllamaChatResponse {
|
||||
message?: Partial<OllamaMessage>;
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
private baseURL: string;
|
||||
private model: string;
|
||||
@@ -598,24 +558,15 @@ export class OllamaClient {
|
||||
yield* this.streamChatWithRetry(messages, tools, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper method for testing that converts async generator to Promise
|
||||
* This allows testing with .rejects.toThrow() syntax
|
||||
*/
|
||||
async streamChatAsPromise(
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[] = <span class="branch-0 cbranch-no" title="branch not covered" >[]</span>
|
||||
): Promise<OllamaMessage[]> {
|
||||
const chunks: OllamaMessage[] = [];
|
||||
try {
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
<span class="cstat-no" title="statement not covered" > chunks.push(chunk);</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return chunks;</span>
|
||||
} catch (error) {
|
||||
// Re-throw the error so tests can catch it
|
||||
throw error;
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
<span class="cstat-no" title="statement not covered" > chunks.push(chunk);</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return chunks;</span>
|
||||
}
|
||||
|
||||
private async *streamChatWithRetry(
|
||||
@@ -633,17 +584,16 @@ export class OllamaClient {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
messages,
|
||||
tools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (response.status >= 500 && <span class="branch-1 cbranch-no" title="branch not covered" >attempt < this.maxRetries)</span> {
|
||||
const retryDelay = <span class="cstat-no" title="statement not covered" >Math.pow(2, attempt) * 100;</span> // Exponential backoff: 200ms, 400ms, 800ms
|
||||
const retryDelay = <span class="cstat-no" title="statement not covered" >Math.pow(2, attempt) * 100;</span>
|
||||
<span class="cstat-no" title="statement not covered" > Logger.warn(</span>
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
@@ -668,7 +618,7 @@ export class OllamaClient {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let malformedCount = 0;
|
||||
const MAX_MALFORMED = 50;
|
||||
const maxMalformed = 50;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -678,68 +628,52 @@ export class OllamaClient {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
buffer = lines.pop() ?? <span class="branch-1 cbranch-no" title="branch not covered" >'';</span>
|
||||
|
||||
for (const line of lines) {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!line.trim()) <span class="cstat-no" title="statement not covered" >continue;</span>
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
this.throwIfOllamaError(parsed);
|
||||
|
||||
// Check for Ollama error in stream
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
|
||||
const message = parsed.message as OllamaMessage | undefined;
|
||||
const message = this.toOllamaMessage(parsed.message);
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!message) {
|
||||
<span class="cstat-no" title="statement not covered" > continue;</span>
|
||||
}
|
||||
|
||||
malformedCount = 0; // Reset on successful parse
|
||||
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? <span class="branch-1 cbranch-no" title="branch not covered" >'',</span>
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e; // Re-throw Ollama errors
|
||||
malformedCount = 0;
|
||||
yield message;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
malformedCount++;
|
||||
if (malformedCount > MAX_MALFORMED) {
|
||||
if (malformedCount > maxMalformed) {
|
||||
throw new Error('Too many malformed chunks in stream');
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e as Error).message}`,
|
||||
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`,
|
||||
'ollama-client'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in buffer
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (buffer.trim()) {
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
const parsed = <span class="cstat-no" title="statement not covered" >JSON.parse(buffer) as Record<string, unknown>;</span>
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (parsed.error) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error(`Ollama error: ${String(parsed.error)}`);</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > this.throwIfOllamaError(parsed);</span>
|
||||
|
||||
const message = <span class="cstat-no" title="statement not covered" >parsed.message as OllamaMessage | undefined;</span>
|
||||
const message = <span class="cstat-no" title="statement not covered" >this.toOllamaMessage(parsed.message);</span>
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (message) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > yield {</span>
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
<span class="cstat-no" title="statement not covered" > yield message;</span>
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (e instanceof Error && e.message.startsWith('Ollama error:')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > throw e;</span>
|
||||
} catch (error) {
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (error instanceof Error && error.message.startsWith('Ollama error:')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > throw error;</span>
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > Logger.warn(</span>
|
||||
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
|
||||
@@ -766,54 +700,54 @@ export class OllamaClient {
|
||||
): Promise<OllamaMessage> {
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
Logger.warn(
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
Logger.warn(
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle missing message content gracefully
|
||||
if (!data.message) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: data.message.role ?? 'assistant',
|
||||
content: typeof data.message.content === 'string' ? data.message.content : <span class="branch-1 cbranch-no" title="branch not covered" >'',</span>
|
||||
tool_calls: data.message.tool_calls ?? [],
|
||||
};
|
||||
} finally {
|
||||
// No need to abort after successful response, but signal is available
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OllamaChatResponse;
|
||||
return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
|
||||
}
|
||||
|
||||
private throwIfOllamaError(parsed: Record<string, unknown>): void {
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private toOllamaMessage(value: unknown): OllamaMessage | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Partial<OllamaMessage>;
|
||||
return {
|
||||
role: record.role ?? 'assistant',
|
||||
content: typeof record.content === 'string' ? record.content : <span class="branch-1 cbranch-no" title="branch not covered" >'',</span>
|
||||
tool_calls: record.tool_calls ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
@@ -823,7 +757,7 @@ export class OllamaClient {
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -413,7 +413,7 @@ export class ToolExecutor {
|
||||
throw new Error('Invalid JSON arguments');
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||
parsedArgs = rawArgs as Record<string, unknown>;
|
||||
parsedArgs = rawArgs;
|
||||
} else <span class="missing-if-branch" title="else path not taken" >E</span>{
|
||||
<span class="cstat-no" title="statement not covered" > throw new Error('Arguments must be an object or JSON string');</span>
|
||||
}
|
||||
@@ -463,7 +463,7 @@ export class ToolExecutor {
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -628,7 +628,7 @@ export interface VaultIndexEntry {
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">81.05% </span>
|
||||
<span class="strong">79.38% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>77/95</span>
|
||||
<span class='fraction'>77/97</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">81.52% </span>
|
||||
<span class="strong">79.78% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>75/92</span>
|
||||
<span class='fraction'>75/94</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<div class='status-line medium'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
@@ -272,7 +272,14 @@
|
||||
<a name='L207'></a><a href='#L207'>207</a>
|
||||
<a name='L208'></a><a href='#L208'>208</a>
|
||||
<a name='L209'></a><a href='#L209'>209</a>
|
||||
<a name='L210'></a><a href='#L210'>210</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<a name='L210'></a><a href='#L210'>210</a>
|
||||
<a name='L211'></a><a href='#L211'>211</a>
|
||||
<a name='L212'></a><a href='#L212'>212</a>
|
||||
<a name='L213'></a><a href='#L213'>213</a>
|
||||
<a name='L214'></a><a href='#L214'>214</a>
|
||||
<a name='L215'></a><a href='#L215'>215</a>
|
||||
<a name='L216'></a><a href='#L216'>216</a>
|
||||
<a name='L217'></a><a href='#L217'>217</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -296,6 +303,7 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -303,24 +311,28 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">54x</span>
|
||||
<span class="cline-any cline-yes">54x</span>
|
||||
<span class="cline-any cline-yes">55x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">55x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -462,6 +474,8 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -504,7 +518,8 @@ export class Logger {
|
||||
|
||||
<span class="fstat-no" title="function not covered" > static </span>setLevel(level: string | LogLevel): void {
|
||||
<span class="cstat-no" title="statement not covered" > if (typeof level === 'string') {</span>
|
||||
<span class="cstat-no" title="statement not covered" > Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;</span>
|
||||
const lowerLevel = <span class="cstat-no" title="statement not covered" >level.toLowerCase();</span>
|
||||
<span class="cstat-no" title="statement not covered" > Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;</span>
|
||||
} else {
|
||||
<span class="cstat-no" title="statement not covered" > Logger.minLevel = level;</span>
|
||||
}
|
||||
@@ -512,24 +527,28 @@ export class Logger {
|
||||
|
||||
<span class="fstat-no" title="function not covered" > static </span>debug(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (LogLevel.DEBUG >= Logger.minLevel) {</span>
|
||||
// eslint-disable-next-line no-console
|
||||
<span class="cstat-no" title="statement not covered" > console.debug(`[${category}] DEBUG: ${message}`);</span>
|
||||
}
|
||||
}
|
||||
|
||||
<span class="fstat-no" title="function not covered" > static </span>info(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (LogLevel.INFO >= Logger.minLevel) {</span>
|
||||
// eslint-disable-next-line no-console
|
||||
<span class="cstat-no" title="statement not covered" > console.info(`[${category}] INFO: ${message}`);</span>
|
||||
}
|
||||
}
|
||||
|
||||
static warn(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
|
||||
if (LogLevel.WARN >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${category}] WARN: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
<span class="fstat-no" title="function not covered" > static </span>error(message: string, category: string = <span class="branch-0 cbranch-no" title="branch not covered" >'general')</span>: void {
|
||||
<span class="cstat-no" title="statement not covered" > <span class="missing-if-branch" title="if path not taken" >I</span>if (LogLevel.ERROR >= Logger.minLevel) {</span>
|
||||
// eslint-disable-next-line no-console
|
||||
<span class="cstat-no" title="statement not covered" > console.error(`[${category}] ERROR: ${message}`);</span>
|
||||
}
|
||||
}
|
||||
@@ -669,9 +688,11 @@ export function <span class="fstat-no" title="function not covered" >sanitizeFil
|
||||
<span class="cstat-no" title="statement not covered" > return path;</span>
|
||||
}
|
||||
|
||||
export async function <span class="fstat-no" title="function not covered" >safeWriteFile(</span>filePath: string, content: string): Promise<void> {
|
||||
export function <span class="fstat-no" title="function not covered" >safeWriteFile(</span>filePath: string, content: string): Promise<void> {
|
||||
const sanitizedPath = <span class="cstat-no" title="statement not covered" >sanitizeFilePath(filePath);</span>
|
||||
// eslint-disable-next-line no-console
|
||||
<span class="cstat-no" title="statement not covered" > console.log(`Writing to ${sanitizedPath}:`, content);</span>
|
||||
<span class="cstat-no" title="statement not covered" > return Promise.resolve();</span>
|
||||
}
|
||||
|
||||
// ==================== HTTP Helpers ====================
|
||||
@@ -697,7 +718,7 @@ export function <span class="fstat-no" title="function not covered" >convertMark
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -23,30 +23,30 @@
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">74.3% </span>
|
||||
<span class="strong">74.82% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>107/144</span>
|
||||
<span class='fraction'>110/147</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">72% </span>
|
||||
<span class="strong">76.92% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>36/50</span>
|
||||
<span class='fraction'>40/52</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">71.42% </span>
|
||||
<span class="strong">72.72% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>15/21</span>
|
||||
<span class='fraction'>16/22</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">74.45% </span>
|
||||
<span class="strong">75% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>102/137</span>
|
||||
<span class='fraction'>105/140</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -390,7 +390,45 @@
|
||||
<a name='L325'></a><a href='#L325'>325</a>
|
||||
<a name='L326'></a><a href='#L326'>326</a>
|
||||
<a name='L327'></a><a href='#L327'>327</a>
|
||||
<a name='L328'></a><a href='#L328'>328</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<a name='L328'></a><a href='#L328'>328</a>
|
||||
<a name='L329'></a><a href='#L329'>329</a>
|
||||
<a name='L330'></a><a href='#L330'>330</a>
|
||||
<a name='L331'></a><a href='#L331'>331</a>
|
||||
<a name='L332'></a><a href='#L332'>332</a>
|
||||
<a name='L333'></a><a href='#L333'>333</a>
|
||||
<a name='L334'></a><a href='#L334'>334</a>
|
||||
<a name='L335'></a><a href='#L335'>335</a>
|
||||
<a name='L336'></a><a href='#L336'>336</a>
|
||||
<a name='L337'></a><a href='#L337'>337</a>
|
||||
<a name='L338'></a><a href='#L338'>338</a>
|
||||
<a name='L339'></a><a href='#L339'>339</a>
|
||||
<a name='L340'></a><a href='#L340'>340</a>
|
||||
<a name='L341'></a><a href='#L341'>341</a>
|
||||
<a name='L342'></a><a href='#L342'>342</a>
|
||||
<a name='L343'></a><a href='#L343'>343</a>
|
||||
<a name='L344'></a><a href='#L344'>344</a>
|
||||
<a name='L345'></a><a href='#L345'>345</a>
|
||||
<a name='L346'></a><a href='#L346'>346</a>
|
||||
<a name='L347'></a><a href='#L347'>347</a>
|
||||
<a name='L348'></a><a href='#L348'>348</a>
|
||||
<a name='L349'></a><a href='#L349'>349</a>
|
||||
<a name='L350'></a><a href='#L350'>350</a>
|
||||
<a name='L351'></a><a href='#L351'>351</a>
|
||||
<a name='L352'></a><a href='#L352'>352</a>
|
||||
<a name='L353'></a><a href='#L353'>353</a>
|
||||
<a name='L354'></a><a href='#L354'>354</a>
|
||||
<a name='L355'></a><a href='#L355'>355</a>
|
||||
<a name='L356'></a><a href='#L356'>356</a>
|
||||
<a name='L357'></a><a href='#L357'>357</a>
|
||||
<a name='L358'></a><a href='#L358'>358</a>
|
||||
<a name='L359'></a><a href='#L359'>359</a>
|
||||
<a name='L360'></a><a href='#L360'>360</a>
|
||||
<a name='L361'></a><a href='#L361'>361</a>
|
||||
<a name='L362'></a><a href='#L362'>362</a>
|
||||
<a name='L363'></a><a href='#L363'>363</a>
|
||||
<a name='L364'></a><a href='#L364'>364</a>
|
||||
<a name='L365'></a><a href='#L365'>365</a>
|
||||
<a name='L366'></a><a href='#L366'>366</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
@@ -408,12 +446,40 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-yes">62x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -506,6 +572,7 @@
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">50x</span>
|
||||
@@ -514,6 +581,10 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
<span class="cline-any cline-yes">26x</span>
|
||||
@@ -544,6 +615,7 @@
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -648,6 +720,10 @@
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-yes">58x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
@@ -720,39 +796,67 @@
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">// src/vault-indexer.ts
|
||||
|
||||
import { OllamaClient } from './ollama-client';
|
||||
import { ApiError, NetworkError, ValidationError } from './types';
|
||||
import { ValidationError, VaultIndexEntry } from './types';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
import { sanitizeFilePath } from './utils';
|
||||
|
||||
interface FileSummary {
|
||||
path: string;
|
||||
summary: string;
|
||||
}
|
||||
import { Logger, sanitizeFilePath } from './utils';
|
||||
|
||||
interface Frontmatter {
|
||||
title?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
interface VaultFile {
|
||||
basename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface VaultLike {
|
||||
getMarkdownFiles(): VaultFile[];
|
||||
read(file: VaultFile): Promise<string>;
|
||||
}
|
||||
|
||||
interface TokenizedContent {
|
||||
tokens: string[];
|
||||
headings: string[];
|
||||
frontmatter: Frontmatter;
|
||||
firstParagraph?: string;
|
||||
}
|
||||
|
||||
interface ScoreResult {
|
||||
score: number;
|
||||
matchedFields: string[];
|
||||
}
|
||||
|
||||
function isVaultLike(value: unknown): value is VaultLike {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'getMarkdownFiles' in value &&
|
||||
typeof value.getMarkdownFiles === 'function' &&
|
||||
'read' in value &&
|
||||
typeof value.read === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
class VaultIndexer {
|
||||
private ollamaClient: OllamaClient | null = null;
|
||||
private summaries: Map<string, string> = new Map();
|
||||
private vault: any;
|
||||
private vault: VaultLike | null = null;
|
||||
|
||||
constructor(vaultOrClient: any) {
|
||||
constructor(vaultOrClient: VaultLike | OllamaClient | null) {
|
||||
// Support both old (OllamaClient) and new (VaultLike) interfaces
|
||||
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
|
||||
if (isVaultLike(vaultOrClient)) {
|
||||
this.vault = vaultOrClient;
|
||||
} else <span class="missing-if-branch" title="else path not taken" >E</span>{
|
||||
<span class="cstat-no" title="statement not covered" > this.ollamaClient = vaultOrClient || null;</span>
|
||||
<span class="cstat-no" title="statement not covered" > this.ollamaClient = vaultOrClient;</span>
|
||||
}
|
||||
}
|
||||
|
||||
<span class="fstat-no" title="function not covered" > async </span>indexVault(vaultPath: string): Promise<void> {
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
const files = <span class="cstat-no" title="statement not covered" >await this.getMarkdownFilesInVault(vaultPath);</span>
|
||||
const files = <span class="cstat-no" title="statement not covered" >this.getMarkdownFilesInVault(vaultPath);</span>
|
||||
<span class="cstat-no" title="statement not covered" > for (const file of files) {</span>
|
||||
const content = <span class="cstat-no" title="statement not covered" >await this.readFileContent(file.path);</span>
|
||||
const content = <span class="cstat-no" title="statement not covered" >this.readFileContent(file.path);</span>
|
||||
const summary = <span class="cstat-no" title="statement not covered" >await this.summarizeFile(content);</span>
|
||||
<span class="cstat-no" title="statement not covered" > this.storeSummary(file.path, summary);</span>
|
||||
}
|
||||
@@ -765,7 +869,7 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
private <span class="fstat-no" title="function not covered" >async </span>getMarkdownFilesInVault(vaultPath: string): Promise<{ path: string }[]> {
|
||||
private <span class="fstat-no" title="function not covered" >getMarkdownFilesInVault(</span>vaultPath: string): { path: string }[] {
|
||||
// Simulate getting markdown files from the vault
|
||||
const sanitizedPath = <span class="cstat-no" title="statement not covered" >sanitizeFilePath(vaultPath);</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
@@ -776,7 +880,7 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
private <span class="fstat-no" title="function not covered" >async </span>readFileContent(filePath: string): Promise<string> {
|
||||
private <span class="fstat-no" title="function not covered" >readFileContent(</span>filePath: string): string {
|
||||
const sanitizedPath = <span class="cstat-no" title="statement not covered" >sanitizeFilePath(filePath);</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
// This is a placeholder for actual file reading operations
|
||||
@@ -821,7 +925,7 @@ class VaultIndexer {
|
||||
<span class="cstat-no" title="statement not covered" > return this.summaries.get(sanitizedPath);</span>
|
||||
}
|
||||
|
||||
async searchVault(query: string, limit: number = <span class="branch-0 cbranch-no" title="branch not covered" >5)</span>: Promise<any[]> {
|
||||
async searchVault(query: string, limit: number = <span class="branch-0 cbranch-no" title="branch not covered" >5)</span>: Promise<VaultIndexEntry[]> {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
@@ -831,8 +935,9 @@ class VaultIndexer {
|
||||
}
|
||||
|
||||
const queryTokens = this.tokenize(query.trim());
|
||||
const allFiles = this.vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(allFiles, queryTokens);
|
||||
const vault = this.vault;
|
||||
const allFiles = vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
|
||||
|
||||
return results
|
||||
.filter((result): result is NonNullable<typeof result> => result !== null)
|
||||
@@ -840,9 +945,13 @@ class VaultIndexer {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private async processFilesInBatches(files: any[], queryTokens: string[]): Promise<any[]> {
|
||||
private async processFilesInBatches(
|
||||
vault: VaultLike,
|
||||
files: VaultFile[],
|
||||
queryTokens: string[]
|
||||
): Promise<Array<VaultIndexEntry | null>> {
|
||||
const batchSize = 10;
|
||||
const results: any[] = [];
|
||||
const results: VaultIndexEntry[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
@@ -850,11 +959,11 @@ class VaultIndexer {
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (file) => {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content, file);
|
||||
const content = await vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content);
|
||||
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
|
||||
if (scoreResult.score > 0) {
|
||||
const entry = {
|
||||
const entry: VaultIndexEntry = {
|
||||
path: file.path,
|
||||
title: file.basename.replace(/\.md$/, ''),
|
||||
content: content.substring(0, 500),
|
||||
@@ -868,8 +977,9 @@ class VaultIndexer {
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to process ${file.path}: ${error instanceof Error ? error.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(error)}</span>`
|
||||
Logger.warn(
|
||||
`Failed to process ${file.path}: ${error instanceof Error ? error.message : <span class="branch-1 cbranch-no" title="branch not covered" >String(error)}</span>`,
|
||||
'vault-indexer'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -923,10 +1033,10 @@ class VaultIndexer {
|
||||
.filter((token) => token.length > 1 && !stopWords.has(token));
|
||||
}
|
||||
|
||||
private tokenizeContent(content: string, file: any): any {
|
||||
private tokenizeContent(content: string): TokenizedContent {
|
||||
const tokens: string[] = [];
|
||||
const headings: string[] = [];
|
||||
let frontmatter: Frontmatter = {};
|
||||
const frontmatter: Frontmatter = {};
|
||||
let firstParagraph: string | undefined;
|
||||
|
||||
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
||||
@@ -948,8 +1058,8 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.warn('Failed to parse frontmatter');</span>
|
||||
} catch {
|
||||
<span class="cstat-no" title="statement not covered" > Logger.warn('Failed to parse frontmatter', 'vault-indexer');</span>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,7 +1084,11 @@ class VaultIndexer {
|
||||
return { tokens, headings, frontmatter, firstParagraph };
|
||||
}
|
||||
|
||||
private calculateWeightedScore(tokenized: any, queryTokens: string[], file?: any): any {
|
||||
private calculateWeightedScore(
|
||||
tokenized: TokenizedContent,
|
||||
queryTokens: string[],
|
||||
file?: VaultFile
|
||||
): ScoreResult {
|
||||
let totalScore = 0;
|
||||
const matchedTokens: Set<string> = new Set<string>();
|
||||
|
||||
@@ -1051,7 +1165,7 @@ export { VaultIndexer };
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-05-06T15:30:53.720Z
|
||||
at 2026-05-06T17:23:45.797Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
|
||||
+878
-859
File diff suppressed because it is too large
Load Diff
+94
-78
@@ -30,6 +30,9 @@ class ChatView extends obsidian_1.ItemView {
|
||||
this.sendButtonClickHandler = null;
|
||||
this.inputKeyDownHandler = null;
|
||||
this.newChatButtonClickHandler = null;
|
||||
this.sendButtonEventHandler = null;
|
||||
this.inputKeyDownEventHandler = null;
|
||||
this.newChatButtonEventHandler = null;
|
||||
this.listenersAttached = false;
|
||||
this.settings = settings;
|
||||
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
|
||||
@@ -46,15 +49,16 @@ class ChatView extends obsidian_1.ItemView {
|
||||
getDisplayText() {
|
||||
return 'Ollama Chat';
|
||||
}
|
||||
async onOpen() {
|
||||
await this.render();
|
||||
onOpen() {
|
||||
this.render();
|
||||
this.removeEventListeners(); // Clean up any existing listeners before reattaching
|
||||
this.setupEventListeners();
|
||||
return Promise.resolve();
|
||||
}
|
||||
onSettingsChange(newSettings) {
|
||||
this.updateSettings(newSettings);
|
||||
}
|
||||
async onClose() {
|
||||
onClose() {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
this.cleanupStreamingResources();
|
||||
@@ -62,6 +66,7 @@ class ChatView extends obsidian_1.ItemView {
|
||||
this.sendButton = null;
|
||||
this.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
cleanupStreamingResources() {
|
||||
// Ensure any ongoing streaming is properly cleaned up
|
||||
@@ -70,7 +75,7 @@ class ChatView extends obsidian_1.ItemView {
|
||||
this.lastMessageEl = null;
|
||||
}
|
||||
}
|
||||
async render() {
|
||||
render() {
|
||||
const container = this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
|
||||
this.chatContainer = container;
|
||||
const inputContainer = this.contentEl.querySelector('.ollama-input-container') ||
|
||||
@@ -99,7 +104,6 @@ class ChatView extends obsidian_1.ItemView {
|
||||
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
|
||||
// Differential update: only update messages that have changed
|
||||
const existingMessages = container.querySelectorAll('.ollama-message');
|
||||
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
if (existingEl) {
|
||||
@@ -151,26 +155,38 @@ class ChatView extends obsidian_1.ItemView {
|
||||
};
|
||||
}
|
||||
// Add event listeners
|
||||
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
|
||||
this.sendButtonEventHandler = () => {
|
||||
void this.sendButtonClickHandler?.();
|
||||
};
|
||||
this.inputKeyDownEventHandler = (e) => {
|
||||
void this.inputKeyDownHandler?.(e);
|
||||
};
|
||||
this.sendButton.addEventListener('click', this.sendButtonEventHandler);
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownEventHandler);
|
||||
if (this.newChatButton) {
|
||||
if (!this.newChatButtonClickHandler) {
|
||||
this.newChatButtonClickHandler = () => this.clearConversation();
|
||||
}
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
|
||||
this.newChatButtonEventHandler = () => {
|
||||
this.newChatButtonClickHandler?.();
|
||||
};
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonEventHandler);
|
||||
}
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
removeEventListeners() {
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
|
||||
if (this.sendButton && this.sendButtonEventHandler) {
|
||||
this.sendButton.removeEventListener('click', this.sendButtonEventHandler);
|
||||
}
|
||||
if (this.inputEl && this.inputKeyDownHandler) {
|
||||
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
|
||||
if (this.inputEl && this.inputKeyDownEventHandler) {
|
||||
this.inputEl.removeEventListener('keydown', this.inputKeyDownEventHandler);
|
||||
}
|
||||
if (this.newChatButton && this.newChatButtonClickHandler) {
|
||||
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
|
||||
if (this.newChatButton && this.newChatButtonEventHandler) {
|
||||
this.newChatButton.removeEventListener('click', this.newChatButtonEventHandler);
|
||||
}
|
||||
this.sendButtonEventHandler = null;
|
||||
this.inputKeyDownEventHandler = null;
|
||||
this.newChatButtonEventHandler = null;
|
||||
this.listenersAttached = false;
|
||||
}
|
||||
clearConversation() {
|
||||
@@ -191,7 +207,7 @@ class ChatView extends obsidian_1.ItemView {
|
||||
];
|
||||
return true;
|
||||
}
|
||||
async updateLastMessage(content) {
|
||||
updateLastMessage(content) {
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && !this.lastMessageEl) {
|
||||
this.lastMessageEl = this.contentEl.createEl('div', {
|
||||
@@ -214,7 +230,9 @@ class ChatView extends obsidian_1.ItemView {
|
||||
return;
|
||||
// Search vault using user message as query
|
||||
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
|
||||
let context = entries.map((e) => `### ${e.title}\n${e.content}`).join('\n\n');
|
||||
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) {
|
||||
@@ -273,14 +291,14 @@ class ChatView extends obsidian_1.ItemView {
|
||||
};
|
||||
// Update messages immutably
|
||||
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
||||
await this.render();
|
||||
const stream = await this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
try {
|
||||
this.render();
|
||||
const stream = this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
for await (const chunk of stream) {
|
||||
chunkCount++;
|
||||
if (chunkCount > maxChunks) {
|
||||
@@ -292,67 +310,65 @@ class ChatView extends obsidian_1.ItemView {
|
||||
if (chunk.tool_calls) {
|
||||
toolCalls = toolCalls.concat(chunk.tool_calls);
|
||||
}
|
||||
await this.updateLastMessage(fullResponse);
|
||||
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
|
||||
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');
|
||||
}
|
||||
}
|
||||
// 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 });
|
||||
}
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
// Update the assistant message with the full response immutably
|
||||
if (!this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
tool_calls: toolCalls,
|
||||
})) {
|
||||
throw new Error('Assistant message not found');
|
||||
}
|
||||
// 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)));
|
||||
let 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');
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
await this.updateLastMessage(fullResponse);
|
||||
// Update the assistant message with the final response immutably
|
||||
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
|
||||
}
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
await this.render();
|
||||
}
|
||||
catch (error) {
|
||||
// Use centralized error handler
|
||||
|
||||
+111
-98
@@ -1,9 +1,10 @@
|
||||
import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian';
|
||||
import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
|
||||
/// <reference lib="dom" />
|
||||
// Use global types from JSDOM setup
|
||||
type KeyboardEvent = globalThis.KeyboardEvent;
|
||||
type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
|
||||
type HTMLButtonElement = globalThis.HTMLButtonElement;
|
||||
type MouseEvent = globalThis.MouseEvent;
|
||||
|
||||
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
|
||||
const MAX_MESSAGE_HISTORY = 50;
|
||||
@@ -34,6 +35,9 @@ export class ChatView extends ItemView {
|
||||
private sendButtonClickHandler: (() => Promise<void>) | null = null;
|
||||
private inputKeyDownHandler: ((e: KeyboardEvent) => Promise<void>) | null = null;
|
||||
private newChatButtonClickHandler: (() => void) | null = null;
|
||||
private sendButtonEventHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private inputKeyDownEventHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonEventHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private listenersAttached = false;
|
||||
|
||||
// Getters for testing
|
||||
@@ -70,17 +74,18 @@ export class ChatView extends ItemView {
|
||||
return 'Ollama Chat';
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
await this.render();
|
||||
onOpen(): Promise<void> {
|
||||
this.render();
|
||||
this.removeEventListeners(); // Clean up any existing listeners before reattaching
|
||||
this.setupEventListeners();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public onSettingsChange(newSettings: PluginSettings): void {
|
||||
this.updateSettings(newSettings);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
onClose(): Promise<void> {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
this.cleanupStreamingResources();
|
||||
@@ -88,6 +93,7 @@ export class ChatView extends ItemView {
|
||||
this.sendButton = null;
|
||||
this.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private cleanupStreamingResources(): void {
|
||||
@@ -98,7 +104,7 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
async render() {
|
||||
render() {
|
||||
const container =
|
||||
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
|
||||
this.chatContainer = container;
|
||||
@@ -112,7 +118,7 @@ export class ChatView extends ItemView {
|
||||
if (!this.sendButton) {
|
||||
this.sendButton = inputContainer.createEl('button', {
|
||||
cls: 'ollama-send-button',
|
||||
}) as HTMLButtonElement;
|
||||
});
|
||||
(this.sendButton as HTMLButtonElement).textContent = 'Send';
|
||||
}
|
||||
|
||||
@@ -122,7 +128,7 @@ export class ChatView extends ItemView {
|
||||
this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
|
||||
this.newChatButton = newChatContainer.createEl('button', {
|
||||
cls: 'ollama-new-chat-button',
|
||||
}) as HTMLButtonElement;
|
||||
});
|
||||
(this.newChatButton as HTMLButtonElement).textContent = '🔄 New Chat';
|
||||
(this.newChatButton as HTMLButtonElement).title = 'Start a new conversation';
|
||||
}
|
||||
@@ -135,12 +141,9 @@ export class ChatView extends ItemView {
|
||||
|
||||
// Differential update: only update messages that have changed
|
||||
const existingMessages = container.querySelectorAll('.ollama-message');
|
||||
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
|
||||
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(
|
||||
`.ollama-message[data-msg-id="${msg.id}"]`
|
||||
) as HTMLElement | null;
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
if (existingEl) {
|
||||
existingEl.textContent = msg.content;
|
||||
} else {
|
||||
@@ -194,39 +197,51 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonClickHandler!);
|
||||
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownHandler!);
|
||||
this.sendButtonEventHandler = () => {
|
||||
void this.sendButtonClickHandler?.();
|
||||
};
|
||||
this.inputKeyDownEventHandler = (e: KeyboardEvent) => {
|
||||
void this.inputKeyDownHandler?.(e);
|
||||
};
|
||||
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonEventHandler);
|
||||
(this.inputEl as HTMLTextAreaElement).addEventListener('keydown', this.inputKeyDownEventHandler);
|
||||
if (this.newChatButton) {
|
||||
if (!this.newChatButtonClickHandler) {
|
||||
this.newChatButtonClickHandler = () => this.clearConversation();
|
||||
}
|
||||
this.newChatButtonEventHandler = () => {
|
||||
this.newChatButtonClickHandler?.();
|
||||
};
|
||||
(this.newChatButton as HTMLButtonElement).addEventListener(
|
||||
'click',
|
||||
this.newChatButtonClickHandler!
|
||||
this.newChatButtonEventHandler
|
||||
);
|
||||
}
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
if (this.sendButton && this.sendButtonEventHandler) {
|
||||
(this.sendButton as HTMLButtonElement).removeEventListener(
|
||||
'click',
|
||||
this.sendButtonClickHandler!
|
||||
this.sendButtonEventHandler
|
||||
);
|
||||
}
|
||||
if (this.inputEl && this.inputKeyDownHandler) {
|
||||
if (this.inputEl && this.inputKeyDownEventHandler) {
|
||||
(this.inputEl as HTMLTextAreaElement).removeEventListener(
|
||||
'keydown',
|
||||
this.inputKeyDownHandler!
|
||||
this.inputKeyDownEventHandler
|
||||
);
|
||||
}
|
||||
if (this.newChatButton && this.newChatButtonClickHandler) {
|
||||
if (this.newChatButton && this.newChatButtonEventHandler) {
|
||||
(this.newChatButton as HTMLButtonElement).removeEventListener(
|
||||
'click',
|
||||
this.newChatButtonClickHandler!
|
||||
this.newChatButtonEventHandler
|
||||
);
|
||||
}
|
||||
this.sendButtonEventHandler = null;
|
||||
this.inputKeyDownEventHandler = null;
|
||||
this.newChatButtonEventHandler = null;
|
||||
this.listenersAttached = false;
|
||||
}
|
||||
|
||||
@@ -249,7 +264,7 @@ export class ChatView extends ItemView {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async updateLastMessage(content: string) {
|
||||
private updateLastMessage(content: string) {
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && !this.lastMessageEl) {
|
||||
this.lastMessageEl = this.contentEl.createEl('div', {
|
||||
@@ -273,7 +288,9 @@ export class ChatView extends ItemView {
|
||||
|
||||
// Search vault using user message as query
|
||||
const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT);
|
||||
let context = entries.map((e) => `### ${e.title}\n${e.content}`).join('\n\n');
|
||||
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;
|
||||
@@ -345,16 +362,16 @@ export class ChatView extends ItemView {
|
||||
// Update messages immutably
|
||||
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
||||
|
||||
await this.render();
|
||||
|
||||
const stream = await this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls: ToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
|
||||
try {
|
||||
this.render();
|
||||
|
||||
const stream = this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls: ToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunkCount++;
|
||||
if (chunkCount > maxChunks) {
|
||||
@@ -369,78 +386,74 @@ export class ChatView extends ItemView {
|
||||
toolCalls = toolCalls.concat(chunk.tool_calls);
|
||||
}
|
||||
|
||||
await this.updateLastMessage(fullResponse);
|
||||
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
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
this.render();
|
||||
} finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
|
||||
// Update the assistant message with the full response immutably
|
||||
if (
|
||||
!this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
tool_calls: toolCalls,
|
||||
})
|
||||
) {
|
||||
throw new Error('Assistant message not found');
|
||||
}
|
||||
|
||||
// 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))
|
||||
);
|
||||
|
||||
let 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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
await this.updateLastMessage(fullResponse);
|
||||
|
||||
// Update the assistant message with the final response immutably
|
||||
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
|
||||
}
|
||||
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
await this.render();
|
||||
} catch (error) {
|
||||
// Use centralized error handler
|
||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
|
||||
@@ -10,13 +10,17 @@ class ErrorHandler {
|
||||
new obsidian_1.Notice(message);
|
||||
if (error instanceof Error) {
|
||||
const ctx = context ? ` [${context}]` : '';
|
||||
// Use console.error instead of ErrorHandler.error for fatal errors
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
|
||||
if (error.stack) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error.stack);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const ctx = context ? ` [${context}]` : '';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ollama Plugin Error${ctx}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,16 @@ export class ErrorHandler {
|
||||
|
||||
if (error instanceof Error) {
|
||||
const ctx = context ? ` [${context}]` : '';
|
||||
// Use console.error instead of ErrorHandler.error for fatal errors
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ollama Plugin Error${ctx}: ${error.message}`);
|
||||
if (error.stack) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error.stack);
|
||||
}
|
||||
} else {
|
||||
const ctx = context ? ` [${context}]` : '';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Ollama Plugin Error${ctx}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-42
@@ -1,42 +1,10 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const obsidian_1 = require("obsidian");
|
||||
const chat_view_1 = require("./chat-view");
|
||||
const utils_1 = require("./utils");
|
||||
const constants_1 = require("./constants");
|
||||
const error_handler_1 = require("./error-handler");
|
||||
class OllamaPlugin extends obsidian_1.Plugin {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
@@ -62,7 +30,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
|
||||
type: 'ollama-chat-view',
|
||||
active: true,
|
||||
});
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
await this.app.workspace.revealLeaf(leaf);
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
@@ -74,16 +42,14 @@ class OllamaPlugin extends obsidian_1.Plugin {
|
||||
}
|
||||
async loadSettings() {
|
||||
try {
|
||||
const data = await this.loadData();
|
||||
const data = (await this.loadData());
|
||||
if (data) {
|
||||
utils_1.Logger.debug('Loading saved settings', 'settings');
|
||||
this.settings = Object.assign({}, this.settings, data);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Use centralized error handling
|
||||
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
|
||||
ErrorHandler.handleError(error, 'settings load');
|
||||
error_handler_1.ErrorHandler.handleError(error, 'settings load');
|
||||
}
|
||||
}
|
||||
async saveSettings() {
|
||||
@@ -101,9 +67,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
// Use centralized error handling
|
||||
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
|
||||
ErrorHandler.handleError(error, 'settings save');
|
||||
error_handler_1.ErrorHandler.handleError(error, 'settings save');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +76,7 @@ class OllamaPlugin extends obsidian_1.Plugin {
|
||||
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||||
leaves.forEach((leaf) => {
|
||||
const view = leaf.view;
|
||||
if (view && view.onSettingsChange) {
|
||||
if (view instanceof chat_view_1.ChatView) {
|
||||
view.onSettingsChange(this.settings);
|
||||
}
|
||||
});
|
||||
|
||||
+6
-15
@@ -1,14 +1,9 @@
|
||||
import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab } from 'obsidian';
|
||||
import { ChatView } from './chat-view';
|
||||
import { PluginSettings } from './types';
|
||||
import {
|
||||
isValidHttpUrl,
|
||||
validatePluginSettings,
|
||||
validateOllamaUrl,
|
||||
validateModelName,
|
||||
Logger,
|
||||
} from './utils';
|
||||
import { validatePluginSettings, validateOllamaUrl, validateModelName, Logger } from './utils';
|
||||
import { DEFAULT_SETTINGS } from './constants';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
|
||||
export default class OllamaPlugin extends Plugin {
|
||||
settings: PluginSettings = DEFAULT_SETTINGS;
|
||||
@@ -38,7 +33,7 @@ export default class OllamaPlugin extends Plugin {
|
||||
type: 'ollama-chat-view',
|
||||
active: true,
|
||||
});
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
await this.app.workspace.revealLeaf(leaf);
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error('Failed to add ribbon icon: ' + (error as Error).message, 'plugin');
|
||||
@@ -51,14 +46,12 @@ export default class OllamaPlugin extends Plugin {
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const data = await this.loadData();
|
||||
const data = (await this.loadData()) as Partial<PluginSettings> | null;
|
||||
if (data) {
|
||||
Logger.debug('Loading saved settings', 'settings');
|
||||
this.settings = Object.assign({}, this.settings, data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Use centralized error handling
|
||||
const { ErrorHandler } = await import('./error-handler');
|
||||
ErrorHandler.handleError(error, 'settings load');
|
||||
}
|
||||
}
|
||||
@@ -81,8 +74,6 @@ export default class OllamaPlugin extends Plugin {
|
||||
Logger.info('Settings saved successfully', 'settings');
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Use centralized error handling
|
||||
const { ErrorHandler } = await import('./error-handler');
|
||||
ErrorHandler.handleError(error, 'settings save');
|
||||
return false;
|
||||
}
|
||||
@@ -92,8 +83,8 @@ export default class OllamaPlugin extends Plugin {
|
||||
public notifyChatViews(): void {
|
||||
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||||
leaves.forEach((leaf) => {
|
||||
const view = leaf.view as ChatView;
|
||||
if (view && view.onSettingsChange) {
|
||||
const view = leaf.view;
|
||||
if (view instanceof ChatView) {
|
||||
view.onSettingsChange(this.settings);
|
||||
}
|
||||
});
|
||||
|
||||
+60
-87
@@ -21,22 +21,12 @@ class OllamaClient {
|
||||
async *streamChat(messages, tools = []) {
|
||||
yield* this.streamChatWithRetry(messages, tools, 0);
|
||||
}
|
||||
/**
|
||||
* Wrapper method for testing that converts async generator to Promise
|
||||
* This allows testing with .rejects.toThrow() syntax
|
||||
*/
|
||||
async streamChatAsPromise(messages, tools = []) {
|
||||
const chunks = [];
|
||||
try {
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
catch (error) {
|
||||
// Re-throw the error so tests can catch it
|
||||
throw error;
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
|
||||
this.abortController = new AbortController();
|
||||
@@ -48,16 +38,15 @@ class OllamaClient {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
messages,
|
||||
tools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
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');
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
yield* this.streamChatWithRetry(messages, tools, attempt + 1);
|
||||
@@ -76,7 +65,7 @@ class OllamaClient {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let malformedCount = 0;
|
||||
const MAX_MALFORMED = 50;
|
||||
const maxMalformed = 50;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -84,58 +73,44 @@ class OllamaClient {
|
||||
break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim())
|
||||
continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// Check for Ollama error in stream
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
const message = parsed.message;
|
||||
this.throwIfOllamaError(parsed);
|
||||
const message = this.toOllamaMessage(parsed.message);
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
malformedCount = 0; // Reset on successful parse
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
malformedCount = 0;
|
||||
yield message;
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e; // Re-throw Ollama errors
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
||||
throw error;
|
||||
}
|
||||
malformedCount++;
|
||||
if (malformedCount > MAX_MALFORMED) {
|
||||
if (malformedCount > maxMalformed) {
|
||||
throw new Error('Too many malformed chunks in stream');
|
||||
}
|
||||
utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${e.message}`, 'ollama-client');
|
||||
utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${error.message}`, 'ollama-client');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer);
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
const message = parsed.message;
|
||||
this.throwIfOllamaError(parsed);
|
||||
const message = this.toOllamaMessage(parsed.message);
|
||||
if (message) {
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e;
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
||||
throw error;
|
||||
}
|
||||
utils_1.Logger.warn(`Failed to parse final chunk: ${buffer.substring(0, 80)}...`, 'ollama-client');
|
||||
}
|
||||
@@ -154,48 +129,46 @@ class OllamaClient {
|
||||
}
|
||||
async chatWithRetry(messages, tools = [], attempt = 0) {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
utils_1.Logger.warn(`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, 'ollama-client');
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
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');
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
const data = await response.json();
|
||||
// Handle missing message content gracefully
|
||||
if (!data.message) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: data.message.role ?? 'assistant',
|
||||
content: typeof data.message.content === 'string' ? data.message.content : '',
|
||||
tool_calls: data.message.tool_calls ?? [],
|
||||
};
|
||||
throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
finally {
|
||||
// No need to abort after successful response, but signal is available
|
||||
const data = (await response.json());
|
||||
return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
|
||||
}
|
||||
throwIfOllamaError(parsed) {
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
}
|
||||
toOllamaMessage(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const record = value;
|
||||
return {
|
||||
role: record.role ?? 'assistant',
|
||||
content: typeof record.content === 'string' ? record.content : '',
|
||||
tool_calls: record.tool_calls ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.OllamaClient = OllamaClient;
|
||||
|
||||
+71
-93
@@ -1,9 +1,13 @@
|
||||
// src/ollama-client.ts
|
||||
|
||||
import type { OllamaMessage, OllamaTool } from './types';
|
||||
import { ApiError, NetworkError } from './types';
|
||||
import { ApiError } from './types';
|
||||
import { Logger } from './utils';
|
||||
|
||||
interface OllamaChatResponse {
|
||||
message?: Partial<OllamaMessage>;
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
private baseURL: string;
|
||||
private model: string;
|
||||
@@ -31,24 +35,15 @@ export class OllamaClient {
|
||||
yield* this.streamChatWithRetry(messages, tools, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper method for testing that converts async generator to Promise
|
||||
* This allows testing with .rejects.toThrow() syntax
|
||||
*/
|
||||
async streamChatAsPromise(
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[] = []
|
||||
): Promise<OllamaMessage[]> {
|
||||
const chunks: OllamaMessage[] = [];
|
||||
try {
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
} catch (error) {
|
||||
// Re-throw the error so tests can catch it
|
||||
throw error;
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private async *streamChatWithRetry(
|
||||
@@ -66,17 +61,16 @@ export class OllamaClient {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
messages,
|
||||
tools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
Logger.warn(
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
@@ -101,7 +95,7 @@ export class OllamaClient {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let malformedCount = 0;
|
||||
const MAX_MALFORMED = 50;
|
||||
const maxMalformed = 50;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -111,68 +105,52 @@ export class OllamaClient {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
this.throwIfOllamaError(parsed);
|
||||
|
||||
// Check for Ollama error in stream
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
|
||||
const message = parsed.message as OllamaMessage | undefined;
|
||||
const message = this.toOllamaMessage(parsed.message);
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
malformedCount = 0; // Reset on successful parse
|
||||
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e; // Re-throw Ollama errors
|
||||
malformedCount = 0;
|
||||
yield message;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
malformedCount++;
|
||||
if (malformedCount > MAX_MALFORMED) {
|
||||
if (malformedCount > maxMalformed) {
|
||||
throw new Error('Too many malformed chunks in stream');
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e as Error).message}`,
|
||||
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`,
|
||||
'ollama-client'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer) as Record<string, unknown>;
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
this.throwIfOllamaError(parsed);
|
||||
|
||||
const message = parsed.message as OllamaMessage | undefined;
|
||||
const message = this.toOllamaMessage(parsed.message);
|
||||
if (message) {
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
yield message;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Ollama error:')) {
|
||||
throw error;
|
||||
}
|
||||
Logger.warn(
|
||||
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
|
||||
@@ -199,53 +177,53 @@ export class OllamaClient {
|
||||
): Promise<OllamaMessage> {
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
Logger.warn(
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100;
|
||||
Logger.warn(
|
||||
`Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return this.chatWithRetry(messages, tools, attempt + 1);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as OllamaChatResponse;
|
||||
return this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] };
|
||||
}
|
||||
|
||||
// Handle missing message content gracefully
|
||||
if (!data.message) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: data.message.role ?? 'assistant',
|
||||
content: typeof data.message.content === 'string' ? data.message.content : '',
|
||||
tool_calls: data.message.tool_calls ?? [],
|
||||
};
|
||||
} finally {
|
||||
// No need to abort after successful response, but signal is available
|
||||
private throwIfOllamaError(parsed: Record<string, unknown>): void {
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private toOllamaMessage(value: unknown): OllamaMessage | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Partial<OllamaMessage>;
|
||||
return {
|
||||
role: record.role ?? 'assistant',
|
||||
content: typeof record.content === 'string' ? record.content : '',
|
||||
tool_calls: record.tool_calls ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class ToolExecutor {
|
||||
throw new Error('Invalid JSON arguments');
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||
parsedArgs = rawArgs as Record<string, unknown>;
|
||||
parsedArgs = rawArgs;
|
||||
} else {
|
||||
throw new Error('Arguments must be an object or JSON string');
|
||||
}
|
||||
|
||||
+9
-2
@@ -27,7 +27,8 @@ const SEVERITY_ORDER = {
|
||||
class Logger {
|
||||
static setLevel(level) {
|
||||
if (typeof level === 'string') {
|
||||
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
|
||||
const lowerLevel = level.toLowerCase();
|
||||
Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;
|
||||
}
|
||||
else {
|
||||
Logger.minLevel = level;
|
||||
@@ -35,21 +36,25 @@ class Logger {
|
||||
}
|
||||
static debug(message, category = 'general') {
|
||||
if (LogLevel.DEBUG >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[${category}] DEBUG: ${message}`);
|
||||
}
|
||||
}
|
||||
static info(message, category = 'general') {
|
||||
if (LogLevel.INFO >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(`[${category}] INFO: ${message}`);
|
||||
}
|
||||
}
|
||||
static warn(message, category = 'general') {
|
||||
if (LogLevel.WARN >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${category}] WARN: ${message}`);
|
||||
}
|
||||
}
|
||||
static error(message, category = 'general') {
|
||||
if (LogLevel.ERROR >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${category}] ERROR: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -164,9 +169,11 @@ function sanitizeFilePath(path) {
|
||||
}
|
||||
return path;
|
||||
}
|
||||
async function safeWriteFile(filePath, content) {
|
||||
function safeWriteFile(filePath, content) {
|
||||
const sanitizedPath = sanitizeFilePath(filePath);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Writing to ${sanitizedPath}:`, content);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// ==================== HTTP Helpers ====================
|
||||
function isValidHttpUrl(url) {
|
||||
|
||||
+9
-2
@@ -21,7 +21,8 @@ export class Logger {
|
||||
|
||||
static setLevel(level: string | LogLevel): void {
|
||||
if (typeof level === 'string') {
|
||||
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
|
||||
const lowerLevel = level.toLowerCase();
|
||||
Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG;
|
||||
} else {
|
||||
Logger.minLevel = level;
|
||||
}
|
||||
@@ -29,24 +30,28 @@ export class Logger {
|
||||
|
||||
static debug(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.DEBUG >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[${category}] DEBUG: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static info(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.INFO >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(`[${category}] INFO: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static warn(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.WARN >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${category}] WARN: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static error(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.ERROR >= Logger.minLevel) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${category}] ERROR: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -186,9 +191,11 @@ export function sanitizeFilePath(path: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function safeWriteFile(filePath: string, content: string): Promise<void> {
|
||||
export function safeWriteFile(filePath: string, content: string): Promise<void> {
|
||||
const sanitizedPath = sanitizeFilePath(filePath);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Writing to ${sanitizedPath}:`, content);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// ==================== HTTP Helpers ====================
|
||||
|
||||
+26
-16
@@ -5,23 +5,32 @@ exports.VaultIndexer = void 0;
|
||||
const types_1 = require("./types");
|
||||
const error_handler_1 = require("./error-handler");
|
||||
const utils_1 = require("./utils");
|
||||
function isVaultLike(value) {
|
||||
return (typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'getMarkdownFiles' in value &&
|
||||
typeof value.getMarkdownFiles === 'function' &&
|
||||
'read' in value &&
|
||||
typeof value.read === 'function');
|
||||
}
|
||||
class VaultIndexer {
|
||||
constructor(vaultOrClient) {
|
||||
this.ollamaClient = null;
|
||||
this.summaries = new Map();
|
||||
this.vault = null;
|
||||
// Support both old (OllamaClient) and new (VaultLike) interfaces
|
||||
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
|
||||
if (isVaultLike(vaultOrClient)) {
|
||||
this.vault = vaultOrClient;
|
||||
}
|
||||
else {
|
||||
this.ollamaClient = vaultOrClient || null;
|
||||
this.ollamaClient = vaultOrClient;
|
||||
}
|
||||
}
|
||||
async indexVault(vaultPath) {
|
||||
try {
|
||||
const files = await this.getMarkdownFilesInVault(vaultPath);
|
||||
const files = this.getMarkdownFilesInVault(vaultPath);
|
||||
for (const file of files) {
|
||||
const content = await this.readFileContent(file.path);
|
||||
const content = this.readFileContent(file.path);
|
||||
const summary = await this.summarizeFile(content);
|
||||
this.storeSummary(file.path, summary);
|
||||
}
|
||||
@@ -35,7 +44,7 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
}
|
||||
async getMarkdownFilesInVault(vaultPath) {
|
||||
getMarkdownFilesInVault(vaultPath) {
|
||||
// Simulate getting markdown files from the vault
|
||||
const sanitizedPath = (0, utils_1.sanitizeFilePath)(vaultPath);
|
||||
try {
|
||||
@@ -46,7 +55,7 @@ class VaultIndexer {
|
||||
throw new types_1.ValidationError('Failed to get markdown files from vault');
|
||||
}
|
||||
}
|
||||
async readFileContent(filePath) {
|
||||
readFileContent(filePath) {
|
||||
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
|
||||
try {
|
||||
// This is a placeholder for actual file reading operations
|
||||
@@ -95,14 +104,15 @@ class VaultIndexer {
|
||||
throw new Error('Vault-like object not provided to VaultIndexer');
|
||||
}
|
||||
const queryTokens = this.tokenize(query.trim());
|
||||
const allFiles = this.vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(allFiles, queryTokens);
|
||||
const vault = this.vault;
|
||||
const allFiles = vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
|
||||
return results
|
||||
.filter((result) => result !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
async processFilesInBatches(files, queryTokens) {
|
||||
async processFilesInBatches(vault, files, queryTokens) {
|
||||
const batchSize = 10;
|
||||
const results = [];
|
||||
const seenPaths = new Set();
|
||||
@@ -110,8 +120,8 @@ class VaultIndexer {
|
||||
const batch = files.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(batch.map(async (file) => {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content, file);
|
||||
const content = await vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content);
|
||||
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
|
||||
if (scoreResult.score > 0) {
|
||||
const entry = {
|
||||
@@ -129,7 +139,7 @@ class VaultIndexer {
|
||||
return null;
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
utils_1.Logger.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer');
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
@@ -174,10 +184,10 @@ class VaultIndexer {
|
||||
.split(/\W+/)
|
||||
.filter((token) => token.length > 1 && !stopWords.has(token));
|
||||
}
|
||||
tokenizeContent(content, file) {
|
||||
tokenizeContent(content) {
|
||||
const tokens = [];
|
||||
const headings = [];
|
||||
let frontmatter = {};
|
||||
const frontmatter = {};
|
||||
let firstParagraph;
|
||||
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
||||
if (frontmatterMatch) {
|
||||
@@ -201,8 +211,8 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to parse frontmatter');
|
||||
catch {
|
||||
utils_1.Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
||||
}
|
||||
}
|
||||
const headingMatches = content.match(/^# (.*?)$/gm);
|
||||
|
||||
+68
-30
@@ -1,39 +1,67 @@
|
||||
// src/vault-indexer.ts
|
||||
|
||||
import { OllamaClient } from './ollama-client';
|
||||
import { ApiError, NetworkError, ValidationError } from './types';
|
||||
import { ValidationError, VaultIndexEntry } from './types';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
import { sanitizeFilePath } from './utils';
|
||||
|
||||
interface FileSummary {
|
||||
path: string;
|
||||
summary: string;
|
||||
}
|
||||
import { Logger, sanitizeFilePath } from './utils';
|
||||
|
||||
interface Frontmatter {
|
||||
title?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
interface VaultFile {
|
||||
basename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface VaultLike {
|
||||
getMarkdownFiles(): VaultFile[];
|
||||
read(file: VaultFile): Promise<string>;
|
||||
}
|
||||
|
||||
interface TokenizedContent {
|
||||
tokens: string[];
|
||||
headings: string[];
|
||||
frontmatter: Frontmatter;
|
||||
firstParagraph?: string;
|
||||
}
|
||||
|
||||
interface ScoreResult {
|
||||
score: number;
|
||||
matchedFields: string[];
|
||||
}
|
||||
|
||||
function isVaultLike(value: unknown): value is VaultLike {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'getMarkdownFiles' in value &&
|
||||
typeof value.getMarkdownFiles === 'function' &&
|
||||
'read' in value &&
|
||||
typeof value.read === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
class VaultIndexer {
|
||||
private ollamaClient: OllamaClient | null = null;
|
||||
private summaries: Map<string, string> = new Map();
|
||||
private vault: any;
|
||||
private vault: VaultLike | null = null;
|
||||
|
||||
constructor(vaultOrClient: any) {
|
||||
constructor(vaultOrClient: VaultLike | OllamaClient | null) {
|
||||
// Support both old (OllamaClient) and new (VaultLike) interfaces
|
||||
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
|
||||
if (isVaultLike(vaultOrClient)) {
|
||||
this.vault = vaultOrClient;
|
||||
} else {
|
||||
this.ollamaClient = vaultOrClient || null;
|
||||
this.ollamaClient = vaultOrClient;
|
||||
}
|
||||
}
|
||||
|
||||
async indexVault(vaultPath: string): Promise<void> {
|
||||
try {
|
||||
const files = await this.getMarkdownFilesInVault(vaultPath);
|
||||
const files = this.getMarkdownFilesInVault(vaultPath);
|
||||
for (const file of files) {
|
||||
const content = await this.readFileContent(file.path);
|
||||
const content = this.readFileContent(file.path);
|
||||
const summary = await this.summarizeFile(content);
|
||||
this.storeSummary(file.path, summary);
|
||||
}
|
||||
@@ -46,7 +74,7 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
private async getMarkdownFilesInVault(vaultPath: string): Promise<{ path: string }[]> {
|
||||
private getMarkdownFilesInVault(vaultPath: string): { path: string }[] {
|
||||
// Simulate getting markdown files from the vault
|
||||
const sanitizedPath = sanitizeFilePath(vaultPath);
|
||||
try {
|
||||
@@ -57,7 +85,7 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
private async readFileContent(filePath: string): Promise<string> {
|
||||
private readFileContent(filePath: string): string {
|
||||
const sanitizedPath = sanitizeFilePath(filePath);
|
||||
try {
|
||||
// This is a placeholder for actual file reading operations
|
||||
@@ -102,7 +130,7 @@ class VaultIndexer {
|
||||
return this.summaries.get(sanitizedPath);
|
||||
}
|
||||
|
||||
async searchVault(query: string, limit: number = 5): Promise<any[]> {
|
||||
async searchVault(query: string, limit: number = 5): Promise<VaultIndexEntry[]> {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
@@ -112,8 +140,9 @@ class VaultIndexer {
|
||||
}
|
||||
|
||||
const queryTokens = this.tokenize(query.trim());
|
||||
const allFiles = this.vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(allFiles, queryTokens);
|
||||
const vault = this.vault;
|
||||
const allFiles = vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(vault, allFiles, queryTokens);
|
||||
|
||||
return results
|
||||
.filter((result): result is NonNullable<typeof result> => result !== null)
|
||||
@@ -121,9 +150,13 @@ class VaultIndexer {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private async processFilesInBatches(files: any[], queryTokens: string[]): Promise<any[]> {
|
||||
private async processFilesInBatches(
|
||||
vault: VaultLike,
|
||||
files: VaultFile[],
|
||||
queryTokens: string[]
|
||||
): Promise<Array<VaultIndexEntry | null>> {
|
||||
const batchSize = 10;
|
||||
const results: any[] = [];
|
||||
const results: VaultIndexEntry[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
@@ -131,11 +164,11 @@ class VaultIndexer {
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (file) => {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content, file);
|
||||
const content = await vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content);
|
||||
const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file);
|
||||
if (scoreResult.score > 0) {
|
||||
const entry = {
|
||||
const entry: VaultIndexEntry = {
|
||||
path: file.path,
|
||||
title: file.basename.replace(/\.md$/, ''),
|
||||
content: content.substring(0, 500),
|
||||
@@ -149,8 +182,9 @@ class VaultIndexer {
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
Logger.warn(
|
||||
`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'vault-indexer'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -204,10 +238,10 @@ class VaultIndexer {
|
||||
.filter((token) => token.length > 1 && !stopWords.has(token));
|
||||
}
|
||||
|
||||
private tokenizeContent(content: string, file: any): any {
|
||||
private tokenizeContent(content: string): TokenizedContent {
|
||||
const tokens: string[] = [];
|
||||
const headings: string[] = [];
|
||||
let frontmatter: Frontmatter = {};
|
||||
const frontmatter: Frontmatter = {};
|
||||
let firstParagraph: string | undefined;
|
||||
|
||||
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
||||
@@ -229,8 +263,8 @@ class VaultIndexer {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse frontmatter');
|
||||
} catch {
|
||||
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +289,11 @@ class VaultIndexer {
|
||||
return { tokens, headings, frontmatter, firstParagraph };
|
||||
}
|
||||
|
||||
private calculateWeightedScore(tokenized: any, queryTokens: string[], file?: any): any {
|
||||
private calculateWeightedScore(
|
||||
tokenized: TokenizedContent,
|
||||
queryTokens: string[],
|
||||
file?: VaultFile
|
||||
): ScoreResult {
|
||||
let totalScore = 0;
|
||||
const matchedTokens: Set<string> = new Set<string>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user