Update coverage reports and improve error handling

This commit is contained in:
2026-05-06 19:28:40 +02:00
parent 2968932d69
commit d57ade0ba8
22 changed files with 1851 additions and 1707 deletions
+111 -98
View File
@@ -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');