Files
obsidian_ollama/src/chat-view.ts
T
fegger 95a6954b50 Add vault note management tools
Expand the tool executor with create_note, append_to_note, replace_note_section,
update_frontmatter, rename_note, move_note, delete_note, and insert_link tools.
Rename create_file to create_note for consistency and add helper methods for
common file operations. Update tests to cover the new tools and renamed
functionality.
2026-05-20 18:19:09 +02:00

761 lines
24 KiB
TypeScript
Executable File

import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
import { OllamaClient } from './ollama-client';
import { VaultIndexer } from './vault-indexer';
import { VaultVectorStore } from './vault-vector-store';
import { ToolExecutor } from './tool-executor';
import { PluginSettings, OllamaMessage, OllamaTool, OllamaToolCall, ChatMessage } from './types';
import { ConversationStateManager } from './conversation-state';
import { ErrorHandler } from './error-handler';
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
export class ChatView extends ItemView {
// Getters for testing
getSendButtonClickHandler() {
return this.sendButtonClickHandler;
}
getInputKeyDownHandler() {
return this.inputKeyDownHandler;
}
getNewChatButtonClickHandler() {
return this.newChatButtonClickHandler;
}
constructor(leaf: WorkspaceLeaf, settings: PluginSettings, vectorStore?: VaultVectorStore) {
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,
settings.model,
undefined,
settings.cacheConfig
);
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
this.conversationStateManager = new ConversationStateManager();
}
updateSettings(newSettings: PluginSettings) {
this.settings = newSettings;
this.ollamaClient = new OllamaClient(
newSettings.ollamaUrl,
newSettings.model,
undefined,
newSettings.cacheConfig
);
void this.ollamaClient.initializeCache().catch(() => {
new Notice(
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
);
});
}
setVectorStore(vectorStore: VaultVectorStore | undefined) {
this.vectorStore = vectorStore;
this.vaultIndexer.setVectorStore(vectorStore);
}
async clearCache(): Promise<void> {
await this.ollamaClient.clearCache();
}
getViewType(): string {
return 'ollama-chat-view';
}
getDisplayText(): string {
return 'Ollama Chat';
}
getIcon(): string {
return 'bot';
}
async onOpen(): Promise<void> {
try {
await this.ollamaClient.initializeCache();
} catch {
new Notice(
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
);
}
this.render();
this.removeEventListeners(); // Clean up any existing listeners before reattaching
this.setupEventListeners();
}
onSettingsChange(newSettings: PluginSettings): void {
this.updateSettings(newSettings);
}
async onClose(): Promise<void> {
this.ollamaClient.cancelStream();
this.removeEventListeners();
this.cleanupStreamingResources();
this.lastMessageEl = null;
this.sendButton = null;
this.inputEl = null;
this.chatContainer = null;
return Promise.resolve();
}
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) {
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
this.lastMessageEl = null;
}
}
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' });
const messagesSnapshot = [...this.messages];
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
const existingMessages = container.querySelectorAll('.ollama-message');
// Remove messages that are no longer in the array
for (const el of Array.from(existingMessages)) {
const id = el.getAttribute('data-msg-id');
if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
el.remove();
}
}
// Render non-streaming messages
for (const msg of nonStreamingMessages) {
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
if (existingEl) {
const contentEl = existingEl.querySelector('.ollama-message-content');
if (contentEl) {
if (msg.isThinking) {
contentEl.innerHTML = '<span class="ollama-thinking-indicator">Thinking…</span>';
} else {
contentEl.textContent = msg.content;
}
}
} else {
const messageEl = container.createEl('div', {
cls: `ollama-message ollama-message-${msg.role}`,
});
messageEl.setAttribute('data-msg-id', msg.id);
const headerEl = messageEl.createEl('div', { cls: 'ollama-message-header' });
headerEl.createEl('span', { cls: 'ollama-message-role', text: msg.role });
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
if (msg.isThinking) {
contentEl.innerHTML = '<span class="ollama-thinking-indicator">Thinking…</span>';
} else {
contentEl.textContent = msg.content;
}
}
}
// Re-attach streaming message if it exists
const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
if (streamingMessage && this.lastMessageEl) {
const existingStreamingEl = container.querySelector(
`.ollama-message[data-msg-id="${streamingMessage.id}"]`
);
if (!existingStreamingEl) {
container.appendChild(this.lastMessageEl);
}
}
// Setup new chat button
if (!this.newChatButton) {
this.newChatButton = newChatContainer.createEl('button', {
cls: 'ollama-new-chat-button',
text: 'New Chat',
});
} else {
newChatContainer.appendChild(this.newChatButton);
}
// Setup input area
if (!this.inputEl) {
this.inputEl = inputContainer.createEl('textarea', {
cls: 'ollama-input',
attr: { placeholder: 'Type your message...' },
});
} else {
inputContainer.appendChild(this.inputEl);
}
// Setup send button
if (!this.sendButton) {
this.sendButton = inputContainer.createEl('button', {
cls: 'ollama-send-button',
text: 'Send',
});
} 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();
}
setupEventListeners(): void {
if (this.listenersAttached) {
return;
}
this.sendButtonClickHandler = () => {
void this.handleUserInput(this.inputEl?.value);
};
this.inputKeyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void this.handleUserInput(this.inputEl?.value);
}
};
this.newChatButtonClickHandler = () => {
this.clearConversation();
};
if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
}
if (this.inputEl && this.inputKeyDownHandler) {
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
}
this.listenersAttached = true;
}
removeEventListeners(): void {
if (!this.listenersAttached) {
return;
}
if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
}
if (this.inputEl && this.inputKeyDownHandler) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
}
if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
}
this.listenersAttached = false;
}
clearConversation(): void {
this.messages = [];
this.conversationStateManager.clear();
this.render();
}
updateMessageById(id: string, updates: Partial<ChatMessage>): void {
const index = this.messages.findIndex((m) => m.id === id);
if (index !== -1) {
this.messages[index] = { ...this.messages[index], ...updates };
this.render();
}
}
updateLastMessage(updates: Partial<ChatMessage>): void {
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage) {
const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id);
if (index !== -1) {
this.messages[index] = { ...this.messages[index], ...updates };
this.render();
}
}
}
getTools(): OllamaTool[] {
return [
{
type: 'function',
function: {
name: 'create_note',
description:
'Creates a new note in the vault at the specified path with the given content',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the new note (e.g., "Projects/My Note.md")',
},
content: {
type: 'string',
description: 'The markdown content for the new note',
},
},
required: ['path', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'read_vault_file',
description: 'Reads the content of a file from the vault',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the file to read',
},
},
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'],
},
},
},
{
type: 'function',
function: {
name: 'append_to_note',
description: 'Appends content to the end of an existing note',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
content: {
type: 'string',
description: 'The content to append',
},
},
required: ['path', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'replace_note_section',
description: 'Replaces the body of a section under the specified heading in a note',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
heading: {
type: 'string',
description: 'The heading text of the section to replace',
},
content: {
type: 'string',
description: 'The new content for the section (heading will be preserved)',
},
},
required: ['path', 'heading', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'update_frontmatter',
description:
'Updates YAML frontmatter fields in a note. Adds, updates, or removes fields.',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note',
},
fields: {
type: 'object',
description:
'An object of frontmatter key-value pairs to set. Use null to remove a field.',
},
},
required: ['path', 'fields'],
},
},
},
{
type: 'function',
function: {
name: 'rename_note',
description: 'Renames a note to a new path within the vault',
parameters: {
type: 'object',
properties: {
oldPath: {
type: 'string',
description: 'The current path to the note',
},
newPath: {
type: 'string',
description: 'The new path for the note',
},
},
required: ['oldPath', 'newPath'],
},
},
},
{
type: 'function',
function: {
name: 'move_note',
description: 'Moves a note into a different folder',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The current path to the note',
},
folder: {
type: 'string',
description: 'The target folder path (e.g., "Projects"). Use "" for vault root.',
},
},
required: ['path', 'folder'],
},
},
},
{
type: 'function',
function: {
name: 'delete_note',
description: 'Deletes a note from the vault',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the note to delete',
},
},
required: ['path'],
},
},
},
{
type: 'function',
function: {
name: 'insert_link',
description: 'Inserts a wikilink to another note at the end of a source note',
parameters: {
type: 'object',
properties: {
sourcePath: {
type: 'string',
description: 'The path to the note that will contain the link',
},
targetPath: {
type: 'string',
description: 'The path to the note being linked to',
},
anchorText: {
type: 'string',
description: 'Optional display text for the link',
},
},
required: ['sourcePath', 'targetPath'],
},
},
},
];
}
buildMessages(userMessageContent: 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.
Vault context includes note titles, content, and any tags (shown as "Tags: ..." at the top of a note entry).
When organizing or categorizing notes, pay attention to tags as they reflect the note's topics and categories.
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: OllamaMessage = {
role: 'system',
content: systemContent,
};
const userMessage: OllamaMessage = {
role: 'user',
content: userMessageContent,
};
const messages: OllamaMessage[] = [systemMessage, userMessage];
if (tools && tools.length > 0) {
messages.push({
role: 'assistant',
content: 'I have access to the following tools to help answer your questions:',
});
}
return messages;
}
async processToolCalls(
toolCalls: OllamaToolCall[],
messages: OllamaMessage[],
tools: OllamaTool[],
fullResponse: string,
assistantMessageId: string
): Promise<void> {
const toolResults = (
await Promise.all(
toolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
try {
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
return { ...toolResult, id: toolCall.id };
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
return null;
}
})
)
).filter((result): result is NonNullable<typeof result> => result !== null);
const followUpMessages: OllamaMessage[] = toolResults.map((result) => {
return {
role: 'tool',
content: JSON.stringify(result),
tool_call_id: result.id ?? '',
};
});
const followUp: OllamaMessage = {
role: 'assistant',
content: 'I have processed your request using the following tools. Here are the results:',
tool_calls: toolCalls,
};
if (followUpMessages.length > 0) {
const finalMessages = [...messages, followUp, ...followUpMessages];
const response = await this.ollamaClient.chat(finalMessages, tools);
const finalResponse = response.content || fullResponse;
this.updateMessageById(assistantMessageId, {
content: finalResponse,
isStreaming: false,
});
}
}
async handleUserInput(inputValue?: string): Promise<void> {
const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim();
if (!userMessage) {
return;
}
const maxContextLength = this.settings.maxContextLength;
const tools = this.getTools();
const messageId = crypto.randomUUID();
const userMessageId = `${messageId}-user`;
const assistantMessageId = `${messageId}-assistant`;
const userChatMessage: ChatMessage = {
id: userMessageId,
role: 'user',
content: userMessage,
timestamp: Date.now(),
};
const assistantMessage: ChatMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
isThinking: true,
};
const previousStreamingEl = this.lastMessageEl;
this.messages = [...this.messages, userChatMessage, assistantMessage];
this.render();
if (this.inputEl) {
this.inputEl.value = '';
}
// Add the assistant message to the DOM to enable streaming
this.lastMessageEl =
this.chatContainer?.querySelector(`.ollama-message[data-msg-id="${assistantMessageId}"]`) ??
null;
if (!this.lastMessageEl && previousStreamingEl) {
previousStreamingEl.classList.add('ollama-message');
previousStreamingEl.setAttribute('data-msg-id', assistantMessageId);
this.contentEl.appendChild(previousStreamingEl);
this.lastMessageEl = previousStreamingEl;
}
try {
const entries = await this.vaultIndexer.searchVault(
userMessage,
this.settings.vaultSearchLimit
);
const context = entries
.map((entry) => {
const parts: string[] = [];
if (entry.tags) {
parts.push(`Tags: ${entry.tags}`);
}
parts.push(entry.title);
parts.push(entry.content);
return parts.join('\n');
})
.join('\n\n')
.slice(0, maxContextLength);
const userMessageWithContext = context
? `Relevant vault context:\n${context}\n\nUser question:\n${userMessage}`
: userMessage;
// Get the complete messages array for the LLM with all context layers
const completeMessages =
this.conversationStateManager.getCompleteMessages(userMessageWithContext);
const stream = this.ollamaClient.streamChat(completeMessages, tools);
let fullResponse = '';
let toolCalls: OllamaToolCall[] = [];
for await (const chunk of stream) {
if (chunk.content) {
fullResponse += chunk.content;
this.updateLastMessage({
content: fullResponse,
isStreaming: true,
isThinking: false,
});
}
if (chunk.tool_calls) {
toolCalls = [...toolCalls, ...chunk.tool_calls];
}
}
// Process tool calls if any
if (toolCalls.length > 0) {
await this.processToolCalls(
toolCalls,
completeMessages,
tools,
fullResponse,
assistantMessageId
);
}
// Update assistant message immutably — only if no tool calls were processed
if (toolCalls.length === 0) {
this.updateMessageById(assistantMessageId, {
content: fullResponse,
isStreaming: false,
});
}
// Update conversation state with the assistant's response
this.conversationStateManager.updateShortTermContext({ role: 'user', content: userMessage });
this.conversationStateManager.updateShortTermContext({
role: 'assistant',
content: fullResponse,
});
// Limit conversation history to prevent memory issues
if (this.messages.length > this.settings.maxMessageHistory) {
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
}
this.render();
} catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
this.updateMessageById(assistantMessageId, {
content: 'An error occurred while processing your request.',
isStreaming: false,
isThinking: false,
});
} finally {
// 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;
private conversationStateManager: ConversationStateManager;
private vectorStore?: VaultVectorStore;
}
const MAX_TOOL_CALLS = 5;