Update coverage reports and improve error handling
This commit is contained in:
+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');
|
||||
|
||||
Reference in New Issue
Block a user