Merge branch 'modularize-indexing-pipeline'
This commit is contained in:
+354
-317
@@ -1,58 +1,41 @@
|
||||
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;
|
||||
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
import {
|
||||
PluginSettings,
|
||||
OllamaMessage,
|
||||
ChatMessage,
|
||||
OllamaTool,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from './types';
|
||||
import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
|
||||
import { OllamaClient } from './ollama-client';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { ToolExecutor } from './tool-executor';
|
||||
import { PluginSettings } from './types';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
|
||||
export class ChatView extends ItemView {
|
||||
private settings: PluginSettings;
|
||||
private messages: ChatMessage[] = [];
|
||||
private ollamaClient: OllamaClient;
|
||||
private vaultIndexer: VaultIndexer;
|
||||
private toolExecutor: ToolExecutor;
|
||||
private lastMessageEl: HTMLElement | null = null;
|
||||
private newChatButton: HTMLButtonElement | null = null;
|
||||
private sendButton: HTMLButtonElement | null = null;
|
||||
private inputEl: HTMLTextAreaElement | null = null;
|
||||
private chatContainer: HTMLElement | null = null;
|
||||
private sendButtonClickHandler: (() => Promise<void>) | null = null;
|
||||
private inputKeyDownHandler: ((e: KeyboardEvent) => Promise<void>) | null = null;
|
||||
private newChatButtonClickHandler: (() => void) | null = null;
|
||||
private sendButtonClickWrapper: (() => void) | null = null;
|
||||
private inputKeyDownWrapper: ((e: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonClickWrapper: (() => void) | null = null;
|
||||
private listenersAttached = false;
|
||||
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
||||
|
||||
export class ChatView extends ItemView {
|
||||
// Getters for testing
|
||||
public getSendButtonClickHandler(): (() => Promise<void>) | null {
|
||||
getSendButtonClickHandler() {
|
||||
return this.sendButtonClickHandler;
|
||||
}
|
||||
|
||||
public getInputKeyDownHandler(): ((e: KeyboardEvent) => Promise<void>) | null {
|
||||
getInputKeyDownHandler() {
|
||||
return this.inputKeyDownHandler;
|
||||
}
|
||||
|
||||
public getNewChatButtonClickHandler(): (() => void) | null {
|
||||
getNewChatButtonClickHandler() {
|
||||
return this.newChatButtonClickHandler;
|
||||
}
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, settings: PluginSettings) {
|
||||
super(leaf);
|
||||
this.messages = [];
|
||||
this.lastMessageEl = null;
|
||||
this.newChatButton = null;
|
||||
this.sendButton = null;
|
||||
this.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
this.sendButtonClickHandler = null;
|
||||
this.inputKeyDownHandler = null;
|
||||
this.newChatButtonClickHandler = null;
|
||||
this.sendButtonClickWrapper = null;
|
||||
this.inputKeyDownWrapper = null;
|
||||
this.newChatButtonClickWrapper = null;
|
||||
this.listenersAttached = false;
|
||||
this.settings = settings;
|
||||
this.ollamaClient = new OllamaClient(
|
||||
settings.ollamaUrl,
|
||||
@@ -64,7 +47,7 @@ export class ChatView extends ItemView {
|
||||
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
|
||||
}
|
||||
|
||||
public updateSettings(newSettings: PluginSettings): void {
|
||||
updateSettings(newSettings: PluginSettings) {
|
||||
this.settings = newSettings;
|
||||
this.ollamaClient = new OllamaClient(
|
||||
newSettings.ollamaUrl,
|
||||
@@ -79,7 +62,7 @@ export class ChatView extends ItemView {
|
||||
});
|
||||
}
|
||||
|
||||
public async clearCache(): Promise<void> {
|
||||
async clearCache(): Promise<void> {
|
||||
await this.ollamaClient.clearCache();
|
||||
}
|
||||
|
||||
@@ -104,11 +87,11 @@ export class ChatView extends ItemView {
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
public onSettingsChange(newSettings: PluginSettings): void {
|
||||
onSettingsChange(newSettings: PluginSettings): void {
|
||||
this.updateSettings(newSettings);
|
||||
}
|
||||
|
||||
onClose(): Promise<void> {
|
||||
async onClose(): Promise<void> {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
this.cleanupStreamingResources();
|
||||
@@ -119,7 +102,7 @@ export class ChatView extends ItemView {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private cleanupStreamingResources(): void {
|
||||
cleanupStreamingResources(): void {
|
||||
// Only cleanup if there's still an active streaming message
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
|
||||
@@ -128,57 +111,21 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): void {
|
||||
const container =
|
||||
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
|
||||
this.chatContainer = container;
|
||||
const inputContainer =
|
||||
this.contentEl.querySelector('.ollama-input-container') ||
|
||||
this.contentEl.createEl('div', { cls: 'ollama-input-container' });
|
||||
const newChatContainer =
|
||||
this.contentEl.querySelector('.ollama-new-chat-container') ||
|
||||
this.contentEl.createEl('div', { cls: 'ollama-new-chat-container' });
|
||||
|
||||
if (!this.inputEl) {
|
||||
this.inputEl = inputContainer.createEl('textarea', { cls: 'ollama-input' });
|
||||
}
|
||||
if (!this.sendButton) {
|
||||
this.sendButton = inputContainer.createEl('button', {
|
||||
cls: 'ollama-send-button',
|
||||
});
|
||||
this.sendButton.textContent = 'Send';
|
||||
}
|
||||
|
||||
if (!this.newChatButton) {
|
||||
const newChatContainer =
|
||||
this.contentEl.querySelector('.ollama-new-chat') ||
|
||||
this.contentEl.createEl('div', { cls: 'ollama-new-chat' });
|
||||
this.newChatButton = newChatContainer.createEl('button', {
|
||||
cls: 'ollama-new-chat-button',
|
||||
});
|
||||
this.newChatButton.textContent = '🔄 New Chat';
|
||||
this.newChatButton.title = 'Start a new conversation';
|
||||
}
|
||||
|
||||
// Create immutable snapshot for rendering
|
||||
const messagesSnapshot = [...this.messages];
|
||||
|
||||
// Only render messages that are not currently streaming
|
||||
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
|
||||
|
||||
// Differential update: only update messages that have changed
|
||||
const existingMessages = container.querySelectorAll('.ollama-message');
|
||||
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
if (existingEl) {
|
||||
existingEl.textContent = msg.content;
|
||||
} else {
|
||||
const messageEl = container.createEl('div', {
|
||||
cls: `ollama-message ${msg.role}`,
|
||||
}) as HTMLElement;
|
||||
messageEl.setAttribute('data-msg-id', msg.id);
|
||||
messageEl.textContent = msg.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove messages that are no longer in the array
|
||||
for (const el of Array.from(existingMessages)) {
|
||||
const id = el.getAttribute('data-msg-id');
|
||||
@@ -187,6 +134,20 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
// Render non-streaming messages
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
if (existingEl) {
|
||||
existingEl.querySelector('.ollama-message-content').textContent = msg.content;
|
||||
} else {
|
||||
const messageEl = container.createEl('div', { cls: 'ollama-message' });
|
||||
messageEl.setAttribute('data-msg-id', msg.id);
|
||||
messageEl.createEl('div', { cls: 'ollama-message-role', text: msg.role });
|
||||
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
|
||||
contentEl.textContent = msg.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-attach streaming message if it exists
|
||||
const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && this.lastMessageEl) {
|
||||
@@ -197,199 +158,263 @@ export class ChatView extends ItemView {
|
||||
container.appendChild(this.lastMessageEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup new chat button
|
||||
if (!this.newChatButton) {
|
||||
this.newChatButton = newChatContainer.createEl('button', {
|
||||
cls: 'ollama-new-chat-button',
|
||||
text: 'New Chat',
|
||||
});
|
||||
this.newChatButtonClickHandler = this.getNewChatButtonClickHandler();
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
|
||||
} else {
|
||||
newChatContainer.appendChild(this.newChatButton);
|
||||
}
|
||||
|
||||
// Setup input area
|
||||
if (!this.inputEl) {
|
||||
this.inputEl = inputContainer.createEl('textarea', {
|
||||
cls: 'ollama-input',
|
||||
attr: { placeholder: 'Type your message...' },
|
||||
});
|
||||
this.inputKeyDownHandler = this.getInputKeyDownHandler();
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
|
||||
} else {
|
||||
inputContainer.appendChild(this.inputEl);
|
||||
}
|
||||
|
||||
// Setup send button
|
||||
if (!this.sendButton) {
|
||||
this.sendButton = inputContainer.createEl('button', {
|
||||
cls: 'ollama-send-button',
|
||||
text: 'Send',
|
||||
});
|
||||
this.sendButtonClickHandler = this.getSendButtonClickHandler();
|
||||
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
||||
} else {
|
||||
inputContainer.appendChild(this.sendButton);
|
||||
}
|
||||
|
||||
// Append containers to contentEl
|
||||
this.contentEl.appendChild(newChatContainer);
|
||||
this.contentEl.appendChild(inputContainer);
|
||||
this.contentEl.appendChild(container);
|
||||
|
||||
// Focus input on open
|
||||
this.inputEl.focus();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
if (!this.sendButton || !this.inputEl || this.listenersAttached) return;
|
||||
|
||||
// Create handlers if they don't exist
|
||||
if (!this.sendButtonClickHandler) {
|
||||
this.sendButtonClickHandler = async () => {
|
||||
if (!this.inputEl) return;
|
||||
await this.handleUserInput(this.inputEl.value);
|
||||
this.inputEl.value = '';
|
||||
};
|
||||
setupEventListeners(): void {
|
||||
if (this.listenersAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.inputKeyDownHandler) {
|
||||
this.inputKeyDownHandler = async (e: KeyboardEvent) => {
|
||||
if (!this.inputEl || e.key !== 'Enter' || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
await this.handleUserInput(this.inputEl.value);
|
||||
this.inputEl.value = '';
|
||||
};
|
||||
}
|
||||
|
||||
// Create wrapper functions for event listeners
|
||||
this.sendButtonClickWrapper = () => {
|
||||
void this.sendButtonClickHandler?.();
|
||||
};
|
||||
this.inputKeyDownWrapper = (e: KeyboardEvent) => {
|
||||
void this.inputKeyDownHandler?.(e);
|
||||
};
|
||||
this.newChatButtonClickWrapper = () => {
|
||||
void this.newChatButtonClickHandler?.();
|
||||
this.sendButtonClickHandler = () => {
|
||||
void this.handleUserInput();
|
||||
};
|
||||
|
||||
// Add event listeners using wrappers
|
||||
this.sendButton.addEventListener('click', this.sendButtonClickWrapper);
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownWrapper);
|
||||
if (this.newChatButton) {
|
||||
if (!this.newChatButtonClickHandler) {
|
||||
this.newChatButtonClickHandler = () => this.clearConversation();
|
||||
this.inputKeyDownHandler = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void this.handleUserInput();
|
||||
}
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonClickWrapper);
|
||||
};
|
||||
|
||||
this.newChatButtonClickHandler = () => {
|
||||
this.clearConversation();
|
||||
};
|
||||
|
||||
if (this.sendButton) {
|
||||
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
||||
}
|
||||
|
||||
if (this.inputEl) {
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
|
||||
}
|
||||
|
||||
if (this.newChatButton) {
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
|
||||
}
|
||||
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
if (this.sendButton && this.sendButtonClickWrapper) {
|
||||
this.sendButton.removeEventListener('click', this.sendButtonClickWrapper);
|
||||
removeEventListeners(): void {
|
||||
if (!this.listenersAttached) {
|
||||
return;
|
||||
}
|
||||
if (this.inputEl && this.inputKeyDownWrapper) {
|
||||
this.inputEl.removeEventListener('keydown', this.inputKeyDownWrapper);
|
||||
|
||||
if (this.sendButton) {
|
||||
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
|
||||
}
|
||||
if (this.newChatButton && this.newChatButtonClickWrapper) {
|
||||
this.newChatButton.removeEventListener('click', this.newChatButtonClickWrapper);
|
||||
|
||||
if (this.inputEl) {
|
||||
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
|
||||
}
|
||||
this.sendButtonClickWrapper = null;
|
||||
this.inputKeyDownWrapper = null;
|
||||
this.newChatButtonClickWrapper = null;
|
||||
|
||||
if (this.newChatButton) {
|
||||
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
|
||||
}
|
||||
|
||||
this.listenersAttached = false;
|
||||
}
|
||||
|
||||
private clearConversation(): void {
|
||||
// Create new array to ensure immutability
|
||||
clearConversation(): void {
|
||||
this.messages = [];
|
||||
this.lastMessageEl = null;
|
||||
this.render();
|
||||
new Notice('Conversation cleared');
|
||||
}
|
||||
|
||||
private updateMessageById(id: string, partial: Partial<ChatMessage>): boolean {
|
||||
updateMessageById(id: string, updates: Partial<ChatMessage>): void {
|
||||
const index = this.messages.findIndex((m) => m.id === id);
|
||||
if (index < 0) return false;
|
||||
this.messages = [
|
||||
...this.messages.slice(0, index),
|
||||
{ ...this.messages[index], ...partial },
|
||||
...this.messages.slice(index + 1),
|
||||
];
|
||||
return true;
|
||||
if (index !== -1) {
|
||||
this.messages[index] = { ...this.messages[index], ...updates };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private updateLastMessage(content: string) {
|
||||
updateLastMessage(updates: Partial<ChatMessage>): void {
|
||||
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
||||
if (streamingMessage && !this.lastMessageEl) {
|
||||
this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', {
|
||||
cls: `ollama-message assistant`,
|
||||
});
|
||||
this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id);
|
||||
}
|
||||
if (this.lastMessageEl) {
|
||||
this.lastMessageEl.textContent = content;
|
||||
if (streamingMessage) {
|
||||
const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id);
|
||||
if (index !== -1) {
|
||||
this.messages[index] = { ...this.messages[index], ...updates };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getTools(): OllamaTool[] {
|
||||
getTools(): OllamaTool[] {
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
description: 'Create a new file in the vault',
|
||||
name: 'read_vault_file',
|
||||
description: 'Reads the content of a file from the vault',
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string' as const,
|
||||
description: "Relative path within the vault, e.g. 'Notes/todo.md'",
|
||||
type: 'string',
|
||||
description: 'The path to the file to read',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The content of the file to read',
|
||||
},
|
||||
content: { type: 'string' as const, description: 'Content of the file to create' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
description: 'Searches for files in the vault that match a given query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The search query to use',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'The maximum number of results to return',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private buildMessages(userMessage: string, context: string): OllamaMessage[] {
|
||||
const systemContent = context
|
||||
? `You are a helpful assistant.\n\nRelevant vault context:\n${context}`
|
||||
: 'You are a helpful assistant.';
|
||||
const systemMessage: OllamaMessage = {
|
||||
buildMessages(userMessageWithContext: string, tools?: OllamaTool[]): OllamaMessage[] {
|
||||
const systemContent = `You are an assistant that can help answer questions using the contents of a vault.
|
||||
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
||||
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
||||
Only use the tools if you need to access vault content that is not already in the context.`;
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content: systemContent,
|
||||
};
|
||||
const userMessageWithContext: OllamaMessage = {
|
||||
|
||||
const userMessageWithContext = {
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
content: userMessageWithContext,
|
||||
};
|
||||
|
||||
return [
|
||||
systemMessage,
|
||||
...this.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
tool_calls: m.tool_calls,
|
||||
})),
|
||||
userMessageWithContext,
|
||||
];
|
||||
const messages: OllamaMessage[] = [systemMessage, userMessageWithContext];
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: 'I have access to the following tools to help answer your questions:',
|
||||
tool_calls: tools,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private async processToolCalls(
|
||||
toolCalls: ToolCall[],
|
||||
async processToolCalls(
|
||||
toolCalls: OllamaToolCall[],
|
||||
messages: OllamaMessage[],
|
||||
tools: OllamaTool[],
|
||||
fullResponse: string,
|
||||
assistantMessageId: string
|
||||
): Promise<void> {
|
||||
// Validate tool calls before processing
|
||||
const MAX_TOOL_CALLS = 10;
|
||||
if (toolCalls.length > MAX_TOOL_CALLS) {
|
||||
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
|
||||
}
|
||||
|
||||
// Collect all tool results using allSettled to support partial results
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
const settledResults = await Promise.allSettled(
|
||||
toolCalls.map((call) => this.toolExecutor.handleToolCall(call))
|
||||
toolCalls.map(async (toolCall) => {
|
||||
try {
|
||||
const toolResult = await this.toolExecutor.executeTool(
|
||||
toolCall.function.name,
|
||||
toolCall.function.arguments
|
||||
);
|
||||
return {
|
||||
status: 'fulfilled',
|
||||
value: toolResult,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: error,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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');
|
||||
const toolResults = settledResults
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value);
|
||||
|
||||
const followUpMessages = toolResults.map((result) => {
|
||||
return {
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result),
|
||||
tool_call_id: result.id,
|
||||
};
|
||||
});
|
||||
|
||||
const followUp = {
|
||||
role: 'assistant',
|
||||
content: 'I have processed your request using the following tools. Here are the results:',
|
||||
tool_calls: tools,
|
||||
};
|
||||
|
||||
if (followUpMessages.length > 0) {
|
||||
const finalMessages = [...messages, followUp, ...followUpMessages];
|
||||
const stream = await this.ollamaClient.streamChat(finalMessages, { temperature: 0.5 });
|
||||
let fullResponse = '';
|
||||
for await (const chunk of stream) {
|
||||
fullResponse += chunk.message.content;
|
||||
this.updateLastMessage({
|
||||
content: fullResponse,
|
||||
isStreaming: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only create follow-up when we have tool results
|
||||
if (toolResults.length > 0) {
|
||||
// Create follow-up messages including the assistant's tool calls and results
|
||||
const followUpMessages: OllamaMessage[] = [
|
||||
...messages,
|
||||
{ role: 'assistant' as const, content: fullResponse, tool_calls: toolCalls },
|
||||
...toolResults.map((result) => ({
|
||||
role: 'tool' as const,
|
||||
content: JSON.stringify(result),
|
||||
})),
|
||||
];
|
||||
|
||||
const followUp = await this.ollamaClient.chat(followUpMessages, tools);
|
||||
fullResponse += followUp.content;
|
||||
this.updateLastMessage(fullResponse);
|
||||
|
||||
// Update the assistant message with the final response immutably
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
isStreaming: false,
|
||||
});
|
||||
} else {
|
||||
// Even if no tool results were successful, mark streaming as complete
|
||||
// to prevent the assistant message from disappearing
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
isStreaming: false,
|
||||
@@ -397,119 +422,131 @@ export class ChatView extends ItemView {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUserInput(content: string) {
|
||||
if (!this.sendButton || !this.inputEl) return;
|
||||
this.sendButton.disabled = true;
|
||||
async handleUserInput(): Promise<void> {
|
||||
const userMessage = this.inputEl.value.trim();
|
||||
if (!userMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await this.vaultIndexer.getVaultEntries();
|
||||
let context = '';
|
||||
const MAX_CONTEXT_LENGTH = 2000;
|
||||
const messages = this.messages;
|
||||
const tools = this.getTools();
|
||||
const messageId = crypto.randomUUID();
|
||||
const userMessageId = `${messageId}-user`;
|
||||
const assistantMessageId = `${messageId}-assistant`;
|
||||
|
||||
const userChatMessage = {
|
||||
id: userMessageId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const assistantMessage = {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
};
|
||||
|
||||
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
||||
this.render();
|
||||
this.inputEl.value = '';
|
||||
|
||||
// Add the assistant message to the DOM to enable streaming
|
||||
this.lastMessageEl = this.chatContainer.querySelector(
|
||||
`.ollama-message[data-msg-id="${assistantMessageId}"]`
|
||||
);
|
||||
|
||||
try {
|
||||
// Guard against empty messages
|
||||
const userMessage = content.trim();
|
||||
if (!userMessage) return;
|
||||
const stream = await this.ollamaClient.streamChat(this.buildMessages(userMessage, tools), {
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
// Search vault using user message as query
|
||||
const entries = await this.vaultIndexer.searchVault(
|
||||
userMessage,
|
||||
this.settings.vaultSearchLimit
|
||||
);
|
||||
let context = entries.map((entry) => `### ${entry.title}\n${entry.content}`).join('\n\n');
|
||||
let fullResponse = '';
|
||||
let toolCalls: OllamaToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
|
||||
// Cap context size to prevent prompt bloat with large vaults
|
||||
const MAX_CONTEXT_LENGTH = 4000;
|
||||
if (context.length > MAX_CONTEXT_LENGTH) {
|
||||
context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)';
|
||||
}
|
||||
|
||||
const messages = this.buildMessages(userMessage, context);
|
||||
const tools = this.getTools();
|
||||
|
||||
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const userMessageId = messageId;
|
||||
const assistantMessageId = `${messageId}-assistant`;
|
||||
|
||||
// Store user message in conversation history
|
||||
const userChatMessage: ChatMessage = {
|
||||
id: userMessageId,
|
||||
role: 'user' as const,
|
||||
content: userMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
};
|
||||
|
||||
// Update messages immutably
|
||||
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
||||
|
||||
try {
|
||||
this.render();
|
||||
|
||||
const stream = this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls: ToolCall[] = [];
|
||||
let chunkCount = 0;
|
||||
for await (const chunk of stream) {
|
||||
chunkCount++;
|
||||
if (chunkCount > MAX_STREAM_CHUNKS) {
|
||||
throw new Error('Response too long, stopped streaming');
|
||||
}
|
||||
|
||||
if (chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
}
|
||||
|
||||
if (chunk.tool_calls) {
|
||||
toolCalls = toolCalls.concat(chunk.tool_calls);
|
||||
}
|
||||
|
||||
this.updateLastMessage(fullResponse);
|
||||
}
|
||||
|
||||
// Update the assistant message with the full response immutably
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
|
||||
// Process tool calls with proper follow-up context
|
||||
if (toolCalls.length > 0) {
|
||||
await this.processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId);
|
||||
}
|
||||
|
||||
// Update assistant message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
isStreaming: false,
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.message.content) {
|
||||
fullResponse += chunk.message.content;
|
||||
this.updateLastMessage({
|
||||
content: fullResponse,
|
||||
isStreaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > this.settings.maxMessageHistory) {
|
||||
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
||||
if (chunk.message.tool_calls) {
|
||||
toolCalls = [...toolCalls, ...chunk.message.tool_calls];
|
||||
}
|
||||
|
||||
chunkCount++;
|
||||
if (chunkCount > MAX_STREAM_CHUNKS) {
|
||||
break;
|
||||
}
|
||||
this.render();
|
||||
} finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
} catch (error) {
|
||||
// Use centralized error handler
|
||||
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
// Update any streaming messages to non-streaming state to prevent stale messages
|
||||
this.messages = this.messages.map((msg) =>
|
||||
msg.isStreaming ? { ...msg, isStreaming: false } : msg
|
||||
);
|
||||
this.cleanupStreamingResources();
|
||||
|
||||
// Process tool calls if any
|
||||
if (toolCalls.length > 0) {
|
||||
await this.processToolCalls(
|
||||
toolCalls,
|
||||
this.buildMessages(userMessage, tools),
|
||||
tools,
|
||||
fullResponse,
|
||||
assistantMessageId
|
||||
);
|
||||
}
|
||||
|
||||
// Update assistant message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
this.updateMessageById(assistantMessageId, {
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > this.settings.maxMessageHistory) {
|
||||
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
||||
}
|
||||
|
||||
this.render();
|
||||
} finally {
|
||||
if (this.sendButton) {
|
||||
this.sendButton.disabled = false;
|
||||
}
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
private messages: ChatMessage[] = [];
|
||||
private lastMessageEl: HTMLElement | null = null;
|
||||
private newChatButton: HTMLElement | null = null;
|
||||
private sendButton: HTMLElement | null = null;
|
||||
private inputEl: HTMLTextAreaElement | null = null;
|
||||
private chatContainer: HTMLElement | null = null;
|
||||
private sendButtonClickHandler: (() => void) | null = null;
|
||||
private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonClickHandler: (() => void) | null = null;
|
||||
private sendButtonClickWrapper: (() => void) | null = null;
|
||||
private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null;
|
||||
private newChatButtonClickWrapper: (() => void) | null = null;
|
||||
private listenersAttached: boolean = false;
|
||||
private settings: PluginSettings;
|
||||
private ollamaClient: OllamaClient;
|
||||
private vaultIndexer: VaultIndexer;
|
||||
private toolExecutor: ToolExecutor;
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
tool_calls?: OllamaToolCall[];
|
||||
}
|
||||
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
|
||||
Reference in New Issue
Block a user