From 649061a7476528d2e028fad67ba0da757b33b9c1 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 6 May 2026 22:03:52 +0200 Subject: [PATCH] Added new files for Ollama plugin functionality Added new files for Ollama plugin functionality including chat view, error handling, client integration, tool execution, vault indexing, and utility functions. --- src/chat-view.js | 405 +++++++++++++++++++++++++++++++++++++++++++ src/constants.js | 13 ++ src/error-handler.js | 107 ++++++++++++ src/main.js | 134 ++++++++++++++ src/ollama-client.js | 174 +++++++++++++++++++ src/tool-executor.js | 114 ++++++++++++ src/types.js | 79 +++++++++ src/utils.js | 180 +++++++++++++++++++ src/vault-indexer.js | 208 ++++++++++++++++++++++ tsconfig.json | 4 +- 10 files changed, 1416 insertions(+), 2 deletions(-) create mode 100644 src/chat-view.js create mode 100644 src/constants.js create mode 100644 src/error-handler.js create mode 100644 src/main.js create mode 100644 src/ollama-client.js create mode 100644 src/tool-executor.js create mode 100644 src/types.js create mode 100644 src/utils.js create mode 100644 src/vault-indexer.js diff --git a/src/chat-view.js b/src/chat-view.js new file mode 100644 index 0000000..eb8ab24 --- /dev/null +++ b/src/chat-view.js @@ -0,0 +1,405 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ChatView = void 0; +const obsidian_1 = require("obsidian"); +const DEFAULT_VAULT_SEARCH_LIMIT = 3; +const MAX_MESSAGE_HISTORY = 50; +const MAX_STREAM_CHUNKS = 1000; +const ollama_client_1 = require("./ollama-client"); +const vault_indexer_1 = require("./vault-indexer"); +const tool_executor_1 = require("./tool-executor"); +const error_handler_1 = require("./error-handler"); +class ChatView extends obsidian_1.ItemView { + // Getters for testing + getSendButtonClickHandler() { + return this.sendButtonClickHandler; + } + getInputKeyDownHandler() { + return this.inputKeyDownHandler; + } + getNewChatButtonClickHandler() { + return this.newChatButtonClickHandler; + } + constructor(leaf, settings) { + 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.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); + this.vaultIndexer = new vault_indexer_1.VaultIndexer(this.app.vault); + this.toolExecutor = new tool_executor_1.ToolExecutor(this.app.vault, this.app); + } + updateSettings(newSettings) { + this.settings = newSettings; + this.ollamaClient = new ollama_client_1.OllamaClient(newSettings.ollamaUrl, newSettings.model); + } + getViewType() { + return 'ollama-chat-view'; + } + getDisplayText() { + return 'Ollama Chat'; + } + onOpen() { + this.render(); + this.removeEventListeners(); // Clean up any existing listeners before reattaching + this.setupEventListeners(); + return Promise.resolve(); + } + onSettingsChange(newSettings) { + this.updateSettings(newSettings); + } + onClose() { + this.ollamaClient.cancelStream(); + this.removeEventListeners(); + this.cleanupStreamingResources(); + this.lastMessageEl = null; + this.sendButton = null; + this.inputEl = null; + this.chatContainer = null; + return Promise.resolve(); + } + cleanupStreamingResources() { + // Ensure any ongoing streaming is properly cleaned up + if (this.lastMessageEl && this.lastMessageEl.parentElement) { + this.lastMessageEl.parentElement.removeChild(this.lastMessageEl); + this.lastMessageEl = null; + } + } + render() { + 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' }); + 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}`, + }); + 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'); + if (!id || !nonStreamingMessages.some((m) => m.id === id)) { + el.remove(); + } + } + // 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); + } + } + } + setupEventListeners() { + 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 = ''; + }; + } + if (!this.inputKeyDownHandler) { + this.inputKeyDownHandler = async (e) => { + if (!this.inputEl || e.key !== 'Enter' || e.shiftKey) + return; + e.preventDefault(); + await this.handleUserInput(this.inputEl.value); + this.inputEl.value = ''; + }; + } + // Add event listeners + 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.newChatButtonEventHandler = () => { + this.newChatButtonClickHandler?.(); + }; + this.newChatButton.addEventListener('click', this.newChatButtonEventHandler); + } + this.listenersAttached = true; + } + removeEventListeners() { + if (this.sendButton && this.sendButtonEventHandler) { + this.sendButton.removeEventListener('click', this.sendButtonEventHandler); + } + if (this.inputEl && this.inputKeyDownEventHandler) { + this.inputEl.removeEventListener('keydown', this.inputKeyDownEventHandler); + } + if (this.newChatButton && this.newChatButtonEventHandler) { + this.newChatButton.removeEventListener('click', this.newChatButtonEventHandler); + } + this.sendButtonEventHandler = null; + this.inputKeyDownEventHandler = null; + this.newChatButtonEventHandler = null; + this.listenersAttached = false; + } + clearConversation() { + // Create new array to ensure immutability + this.messages = []; + this.lastMessageEl = null; + this.render(); + new obsidian_1.Notice('Conversation cleared'); + } + updateMessageById(id, partial) { + 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; + } + updateLastMessage(content) { + const streamingMessage = this.messages.find((msg) => msg.isStreaming); + if (streamingMessage && !this.lastMessageEl) { + this.lastMessageEl = this.contentEl.createEl('div', { + cls: `ollama-message assistant`, + }); + this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id); + } + if (this.lastMessageEl) { + this.lastMessageEl.textContent = content; + } + } + async handleUserInput(content) { + if (!this.sendButton || !this.inputEl) + return; + this.sendButton.disabled = true; + try { + // Guard against empty messages + const userMessage = content.trim(); + if (!userMessage) + return; + // Search vault using user message as query + const entries = await this.vaultIndexer.searchVault(userMessage, DEFAULT_VAULT_SEARCH_LIMIT); + 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) { + context = context.substring(0, MAX_CONTEXT_LENGTH) + '\n\n... (truncated)'; + } + const systemMessage = { + role: 'system', + content: 'You are a helpful assistant.', + }; + const userContent = context ? `${context}\n\n${userMessage}` : userMessage; + const userMessageWithContext = { + role: 'user', + content: userContent, + }; + const messages = [ + systemMessage, + ...this.messages.map((m) => ({ + role: m.role, + content: m.content, + tool_calls: m.tool_calls, + })), + userMessageWithContext, + ]; + const tools = [ + { + type: 'function', + function: { + name: 'create_file', + description: 'Create a new file in the vault', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: "Relative path within the vault, e.g. 'Notes/todo.md'", + }, + content: { type: 'string', description: 'Content of the file to create' }, + }, + required: ['path', 'content'], + }, + }, + }, + ]; + 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 = { + id: userMessageId, + role: 'user', + content: userMessage, + timestamp: Date.now(), + }; + const assistantMessage = { + id: assistantMessageId, + role: 'assistant', + 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 = []; + 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) { + // 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'); + } + } + // 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 = [ + ...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, + }); + } + 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, + }); + } + } + // 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(); + } + } + catch (error) { + // Use centralized error handler + error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput'); + // Update any streaming messages to non-streaming state to prevent stale messages + // This ensures that if an error occurs during streaming, the assistant message + // is still visible (with any partial content received) but won't cause issues + // in subsequent requests due to stale isStreaming: true flag + this.messages = this.messages.map((msg) => msg.isStreaming ? { ...msg, isStreaming: false } : msg); + this.cleanupStreamingResources(); + } + finally { + if (this.sendButton) { + this.sendButton.disabled = false; + } + } + } +} +exports.ChatView = ChatView; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..b9c1b73 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,13 @@ +"use strict"; +// Default plugin settings +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MODEL_NAME_REGEX = exports.DEFAULT_SETTINGS = void 0; +exports.DEFAULT_SETTINGS = { + ollamaUrl: 'http://localhost:11434', + model: 'llama3', + vaultSearchLimit: 3, + maxMessageHistory: 50, + lastIndexTime: 0, +}; +// Model validation regex - lowercase letters, numbers, dashes, underscores only +exports.MODEL_NAME_REGEX = /^[a-z0-9-_]+$/; diff --git a/src/error-handler.js b/src/error-handler.js new file mode 100644 index 0000000..c281335 --- /dev/null +++ b/src/error-handler.js @@ -0,0 +1,107 @@ +"use strict"; +// src/error-handler.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ErrorHandler = void 0; +const obsidian_1 = require("obsidian"); +const types_1 = require("./types"); +class ErrorHandler { + static handleError(error, context) { + const message = this.getUserFriendlyMessage(error); + new obsidian_1.Notice(message); + if (error instanceof Error) { + const ctx = context ? ` [${context}]` : ''; + // Use console.error instead of ErrorHandler.error for fatal errors + console.error(`Ollama Plugin Error${ctx}: ${error.message}`); + if (error.stack) { + console.error(error.stack); + } + } + else { + const ctx = context ? ` [${context}]` : ''; + console.error(`Ollama Plugin Error${ctx}:`, error); + } + } + static getUserFriendlyMessage(error) { + if (error instanceof types_1.OllamaError) { + return this.getUserFriendlyMessageFromOllamaError(error); + } + if (error instanceof Error) { + return this.getUserFriendlyMessageFromError(error); + } + return 'An unexpected error occurred'; + } + static getUserFriendlyMessageFromOllamaError(error) { + switch (error.type) { + case types_1.ErrorType.NETWORK_ERROR: + return 'Connection error. Please check if Ollama is running.'; + case types_1.ErrorType.API_ERROR: + return `API error: ${error.message}`; + case types_1.ErrorType.VALIDATION_ERROR: + return this.getUserFriendlyValidationMessage(error); + case types_1.ErrorType.STREAMING_ERROR: + return 'Response too long. Please try a shorter request.'; + case types_1.ErrorType.TOOL_EXECUTION_ERROR: + return `Tool error for ${error.toolName}. ${error.message}`; + case types_1.ErrorType.PATH_VALIDATION_ERROR: + return `Invalid file path: ${error.path}`; + case types_1.ErrorType.UNKNOWN_ERROR: + return 'An unexpected error occurred'; + default: + return 'An unexpected error occurred'; + } + } + static getUserFriendlyValidationMessage(error) { + if (error instanceof types_1.ValidationError && error.details?.field) { + const fieldMsg = error.details.field.charAt(0).toUpperCase() + error.details.field.slice(1); + return `Invalid ${fieldMsg.toLowerCase()}. ${error.details.message ?? error.message}`; + } + return 'Input validation error. Please correct your input.'; + } + static getUserFriendlyMessageFromError(error) { + const msg = error.message.toLowerCase(); + // Check timeout BEFORE network (more specific matches first) + if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('time out')) { + return 'Request timed out. Please check your Ollama connection.'; + } + if (msg.includes('network') || msg.includes('connection') || msg.includes('fetch')) { + return 'Connection error. Please check if Ollama is running.'; + } + if (msg.includes('validation') || msg.includes('invalid')) { + return 'Invalid input. Please correct your input.'; + } + if (msg.includes('stream') || msg.includes('chunk')) { + return 'Response too long. Please try a shorter request.'; + } + if (msg.includes('tool') || msg.includes('function')) { + return 'Tool error. Please try again.'; + } + if (msg.includes('path') || msg.includes('file')) { + return 'Invalid file path. Please check the path and try again.'; + } + return 'An unexpected error occurred'; + } + // -- Factory methods -- + static createNetworkError(message, statusCode) { + return new types_1.NetworkError(message, statusCode); + } + static createApiError(message, statusCode) { + return new types_1.ApiError(message, statusCode); + } + static createValidationError(message, field) { + const details = field ? { field, message } : undefined; + return new types_1.ValidationError(message, details); + } + static createStreamingError(message) { + return new types_1.StreamingError(message); + } + static createToolExecutionError(message, toolName) { + return new types_1.ToolExecutionError(message, toolName ?? 'unknown'); + } + static createPathValidationError(message, path) { + return new types_1.PathValidationError(message, path ?? ''); + } + static createUnknownError(message) { + return new types_1.OllamaError(message, types_1.ErrorType.UNKNOWN_ERROR); + } +} +exports.ErrorHandler = ErrorHandler; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..b013a42 --- /dev/null +++ b/src/main.js @@ -0,0 +1,134 @@ +"use strict"; +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); + this.settings = constants_1.DEFAULT_SETTINGS; + } + async onload() { + // Initialize logging + utils_1.Logger.info('Ollama Plugin loading...', 'plugin'); + await this.loadSettings(); + utils_1.Logger.info('Plugin loaded successfully', 'plugin'); + try { + this.registerView('ollama-chat-view', (leaf) => new chat_view_1.ChatView(leaf, this.settings)); + } + catch (error) { + utils_1.Logger.error('Failed to register view: ' + error.message, 'plugin'); + new obsidian_1.Notice('Failed to register Ollama chat view'); + // Don't throw - let the plugin continue loading other features + } + try { + this.addRibbonIcon('message-square', 'Ollama Chat', async () => { + const leaf = this.app.workspace.getLeaf(); + await leaf.setViewState({ + type: 'ollama-chat-view', + active: true, + }); + await this.app.workspace.revealLeaf(leaf); + }); + } + catch (error) { + utils_1.Logger.error('Failed to add ribbon icon: ' + error.message, 'plugin'); + new obsidian_1.Notice('Failed to add Ollama ribbon icon'); + // Don't throw - let the plugin continue loading other features + } + this.addSettingTab(new OllamaSettingTab(this.app, this)); + } + async loadSettings() { + try { + const data = (await this.loadData()); + if (data) { + utils_1.Logger.debug('Loading saved settings', 'settings'); + this.settings = Object.assign({}, this.settings, data); + } + } + catch (error) { + error_handler_1.ErrorHandler.handleError(error, 'settings load'); + } + } + async saveSettings() { + try { + // Validate settings before saving + const validationErrors = (0, utils_1.validatePluginSettings)(this.settings); + if (validationErrors.length > 0) { + utils_1.Logger.error('Validation errors prevented saving settings: ' + validationErrors.join('; '), 'settings'); + new obsidian_1.Notice(`Cannot save settings: ${validationErrors[0]}`); + return false; + } + utils_1.Logger.debug('Saving settings: ' + JSON.stringify(this.settings), 'settings'); + await this.saveData(this.settings); + utils_1.Logger.info('Settings saved successfully', 'settings'); + return true; + } + catch (error) { + error_handler_1.ErrorHandler.handleError(error, 'settings save'); + return false; + } + } + // Notify all open ChatView instances when settings change + notifyChatViews() { + const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view'); + leaves.forEach((leaf) => { + const view = leaf.view; + if (view instanceof chat_view_1.ChatView) { + view.onSettingsChange(this.settings); + } + }); + } +} +exports.default = OllamaPlugin; +class OllamaSettingTab extends obsidian_1.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + // Clear any existing content first to prevent duplicates + this.containerEl.empty(); + // Create container for settings + const container = this.containerEl.createDiv(); + container.empty(); + new obsidian_1.Setting(container) + .setName('Ollama URL') + .setDesc('URL of your Ollama instance') + .addText((text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => { + const urlValidation = (0, utils_1.validateOllamaUrl)(value); + if (urlValidation.valid) { + utils_1.Logger.debug('URL changed to: ' + value, 'settings'); + this.plugin.settings.ollamaUrl = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + } + else { + utils_1.Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings'); + new obsidian_1.Notice(urlValidation.error || 'Invalid Ollama URL format.'); + } + })); + new obsidian_1.Setting(container) + .setName('Model') + .setDesc('Model to use for chat') + .addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => { + const modelValidation = (0, utils_1.validateModelName)(value); + if (modelValidation.valid) { + utils_1.Logger.debug('Model changed to: ' + value, 'settings'); + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + } + else { + utils_1.Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings'); + new obsidian_1.Notice(modelValidation.error || 'Invalid model name format.'); + } + })); + } + hide() { + // Clear the container to prevent duplicate elements + this.containerEl.empty(); + } +} diff --git a/src/ollama-client.js b/src/ollama-client.js new file mode 100644 index 0000000..c5630c4 --- /dev/null +++ b/src/ollama-client.js @@ -0,0 +1,174 @@ +"use strict"; +// src/ollama-client.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OllamaClient = void 0; +const types_1 = require("./types"); +const utils_1 = require("./utils"); +class OllamaClient { + constructor(baseURL, model, fetchFn) { + this.abortController = null; + this.maxRetries = 3; + this.baseURL = baseURL; + this.model = model; + this.fetchFn = fetchFn ?? fetch; + } + cancelStream() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + async *streamChat(messages, tools = []) { + yield* this.streamChatWithRetry(messages, tools, 0); + } + async streamChatAsPromise(messages, tools = []) { + const chunks = []; + for await (const chunk of this.streamChat(messages, tools)) { + chunks.push(chunk); + } + return chunks; + } + async *streamChatWithRetry(messages, tools = [], attempt = 0) { + this.abortController = 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, + tools, + stream: true, + }), + signal: this.abortController.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)); + yield* this.streamChatWithRetry(messages, tools, attempt + 1); + return; + } + throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); + } + if (!response.body) { + throw new Error('No response body'); + } + const contentType = response.headers.get('content-type'); + if (!contentType || (!contentType.includes('ndjson') && !contentType.includes('json'))) { + throw new Error('Invalid response format'); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let malformedCount = 0; + const maxMalformed = 50; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) + continue; + try { + const parsed = JSON.parse(line); + this.throwIfOllamaError(parsed); + const message = this.toOllamaMessage(parsed.message); + if (!message) { + continue; + } + malformedCount = 0; + yield message; + } + catch (error) { + if (error instanceof Error && error.message.startsWith('Ollama error:')) { + throw error; + } + malformedCount++; + if (malformedCount > maxMalformed) { + throw new Error('Too many malformed chunks in stream'); + } + utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${error.message}`, 'ollama-client'); + } + } + } + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer); + this.throwIfOllamaError(parsed); + const message = this.toOllamaMessage(parsed.message); + if (message) { + yield message; + } + } + 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'); + } + } + } + finally { + reader.releaseLock(); + } + } + finally { + this.abortController = null; + } + } + async chat(messages, tools = []) { + return this.chatWithRetry(messages, tools, 0); + } + async chatWithRetry(messages, tools = [], attempt = 0) { + const controller = new AbortController(); + 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); + } + throw new types_1.ApiError(`Ollama API error: ${response.status}`, response.status); + } + 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; diff --git a/src/tool-executor.js b/src/tool-executor.js new file mode 100644 index 0000000..b80f258 --- /dev/null +++ b/src/tool-executor.js @@ -0,0 +1,114 @@ +"use strict"; +// src/tool-executor.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ToolExecutor = void 0; +const utils_1 = require("./utils"); +// Disallow characters that are invalid in file paths +const INVALID_PATH_CHARS = /[<>:"|?*~]/; +const MAX_PATH_LENGTH = 200; +const FORBIDDEN_DIRS = ['.obsidian', '.git']; +class ToolExecutor { + constructor(vault, app) { + this.vault = vault; + this.app = app; + } + isSafePath(path) { + // Reject empty paths + if (!path || path.trim().length === 0) { + return false; + } + // Reject paths that are too long + if (path.length > MAX_PATH_LENGTH) { + return false; + } + // Reject paths with invalid characters + if (INVALID_PATH_CHARS.test(path)) { + return false; + } + // Reject absolute paths + if (path.startsWith('/') || path.startsWith('\\')) { + return false; + } + // Reject Windows drive letters (e.g., C:) + if (/^[a-zA-Z]:/.test(path)) { + return false; + } + // Reject paths containing backslashes (Windows-style path separators) + if (path.includes('\\')) { + return false; + } + // Reject paths that traverse to parent directories + const normalized = path.replace(/^(\.\/)+/, ''); + if (normalized.split('/').includes('..')) { + return false; + } + // Reject forbidden directories + for (const dir of FORBIDDEN_DIRS) { + if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) { + return false; + } + if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) { + return false; + } + } + return true; + } + async handleToolCall(toolCall) { + try { + const toolName = toolCall.function?.name; + const rawArgs = toolCall.function?.arguments; + if (!toolName) { + throw new Error('Tool name is required'); + } + // Parse arguments whether they're a string or object + let parsedArgs; + if (typeof rawArgs === 'string') { + try { + parsedArgs = (0, utils_1.safeParseJson)(rawArgs); + } + catch { + throw new Error('Invalid JSON arguments'); + } + } + else if (rawArgs && typeof rawArgs === 'object') { + parsedArgs = rawArgs; + } + else { + throw new Error('Arguments must be an object or JSON string'); + } + // Process the tool call based on its type + switch (toolName) { + case 'create_file': + return await this.handleCreateFile(parsedArgs); + default: + return { success: false, message: `Unknown tool: ${toolName}` }; + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(errorMessage); + } + } + async handleCreateFile(args) { + const path = args.path; + const content = args.content; + if (typeof path !== 'string') { + throw new Error('Path must be a string'); + } + if (typeof content !== 'string') { + throw new Error('Content must be a string'); + } + if (!this.isSafePath(path)) { + throw new Error('Invalid file path detected'); + } + try { + await this.vault.create(path, content); + return { success: true, message: 'File created successfully' }; + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(errorMessage); + } + } +} +exports.ToolExecutor = ToolExecutor; diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..a90b553 --- /dev/null +++ b/src/types.js @@ -0,0 +1,79 @@ +"use strict"; +// src/types.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_SETTINGS = exports.PathValidationError = exports.ToolExecutionError = exports.StreamingError = exports.ValidationError = exports.ApiError = exports.NetworkError = exports.OllamaError = exports.ErrorType = void 0; +// ============================================================ +// Error Type Hierarchy +// ============================================================ +var ErrorType; +(function (ErrorType) { + ErrorType["NETWORK_ERROR"] = "network_error"; + ErrorType["API_ERROR"] = "api_error"; + ErrorType["VALIDATION_ERROR"] = "validation_error"; + ErrorType["STREAMING_ERROR"] = "streaming_error"; + ErrorType["TOOL_EXECUTION_ERROR"] = "tool_execution_error"; + ErrorType["PATH_VALIDATION_ERROR"] = "path_validation_error"; + ErrorType["UNKNOWN_ERROR"] = "unknown_error"; +})(ErrorType || (exports.ErrorType = ErrorType = {})); +class OllamaError extends Error { + constructor(message, type) { + super(message); + this.type = type; + Object.setPrototypeOf(this, OllamaError.prototype); + } +} +exports.OllamaError = OllamaError; +class NetworkError extends OllamaError { + constructor(message, statusCode) { + super(message, ErrorType.NETWORK_ERROR); + this.statusCode = statusCode; + Object.setPrototypeOf(this, NetworkError.prototype); + } +} +exports.NetworkError = NetworkError; +class ApiError extends OllamaError { + constructor(message, statusCode) { + super(message, ErrorType.API_ERROR); + this.statusCode = statusCode; + Object.setPrototypeOf(this, ApiError.prototype); + } +} +exports.ApiError = ApiError; +class ValidationError extends OllamaError { + constructor(message, details) { + super(message, ErrorType.VALIDATION_ERROR); + this.details = details; + Object.setPrototypeOf(this, ValidationError.prototype); + } +} +exports.ValidationError = ValidationError; +class StreamingError extends OllamaError { + constructor(message) { + super(message, ErrorType.STREAMING_ERROR); + Object.setPrototypeOf(this, StreamingError.prototype); + } +} +exports.StreamingError = StreamingError; +class ToolExecutionError extends OllamaError { + constructor(message, toolName) { + super(message, ErrorType.TOOL_EXECUTION_ERROR); + this.toolName = toolName; + Object.setPrototypeOf(this, ToolExecutionError.prototype); + } +} +exports.ToolExecutionError = ToolExecutionError; +class PathValidationError extends OllamaError { + constructor(message, path) { + super(message, ErrorType.PATH_VALIDATION_ERROR); + this.path = path; + Object.setPrototypeOf(this, PathValidationError.prototype); + } +} +exports.PathValidationError = PathValidationError; +exports.DEFAULT_SETTINGS = { + ollamaUrl: 'http://localhost:11434', + model: 'llama3', + vaultSearchLimit: 3, + maxMessageHistory: 50, + lastIndexTime: 0, +}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..46adb11 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,180 @@ +"use strict"; +// src/utils.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Logger = void 0; +exports.validateOllamaUrl = validateOllamaUrl; +exports.validateModelName = validateModelName; +exports.validatePluginSettings = validatePluginSettings; +exports.safeParseJson = safeParseJson; +exports.sanitizeFilePath = sanitizeFilePath; +exports.isValidHttpUrl = isValidHttpUrl; +exports.convertMarkdownToHtml = convertMarkdownToHtml; +// ==================== Logger ==================== +var LogLevel; +(function (LogLevel) { + LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG"; + LogLevel[LogLevel["INFO"] = 1] = "INFO"; + LogLevel[LogLevel["WARN"] = 2] = "WARN"; + LogLevel[LogLevel["ERROR"] = 3] = "ERROR"; +})(LogLevel || (LogLevel = {})); +const SEVERITY_ORDER = { + debug: LogLevel.DEBUG, + info: LogLevel.INFO, + warn: LogLevel.WARN, + error: LogLevel.ERROR, +}; +class Logger { + static setLevel(level) { + if (typeof level === 'string') { + const lowerLevel = level.toLowerCase(); + Logger.minLevel = SEVERITY_ORDER[lowerLevel] ?? LogLevel.DEBUG; + } + else { + Logger.minLevel = level; + } + } + static debug(message, category = 'general') { + if (LogLevel.DEBUG >= Logger.minLevel) { + console.debug(`[${category}] DEBUG: ${message}`); + } + } + static info(message, category = 'general') { + if (LogLevel.INFO >= Logger.minLevel) { + console.info(`[${category}] INFO: ${message}`); + } + } + static warn(message, category = 'general') { + if (LogLevel.WARN >= Logger.minLevel) { + console.warn(`[${category}] WARN: ${message}`); + } + } + static error(message, category = 'general') { + if (LogLevel.ERROR >= Logger.minLevel) { + console.error(`[${category}] ERROR: ${message}`); + } + } +} +exports.Logger = Logger; +Logger.minLevel = LogLevel.DEBUG; +// ==================== URL & Model Validation ==================== +function validateOllamaUrl(url) { + if (typeof url !== 'string' || !url.trim()) { + return { valid: false, error: 'URL cannot be empty' }; + } + const trimmedUrl = url.trim(); + if (trimmedUrl.endsWith('/')) { + return { valid: false, error: 'URL should not end with a slash' }; + } + try { + const parsed = new URL(trimmedUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' }; + } + return { valid: true }; + } + catch { + return { valid: false, error: 'Must be a valid HTTP or HTTPS URL' }; + } +} +function validateModelName(model) { + if (typeof model !== 'string') { + return { valid: false, error: 'Model name must be a string' }; + } + const trimmedModel = model.trim(); + // Explicit check for empty string after trimming + if (!trimmedModel || trimmedModel.length === 0) { + return { valid: false, error: 'Model name cannot be empty' }; + } + if (trimmedModel.length < 2) { + return { valid: false, error: 'Model name must be at least 2 characters long' }; + } + if (trimmedModel.length > 100) { + return { valid: false, error: 'Model name must be less than 100 characters long' }; + } + if (!/^[a-zA-Z0-9._:-]+$/.test(trimmedModel)) { + return { + valid: false, + error: 'Model name can only contain letters, numbers, dots, dashes, underscores, and colons', + }; + } + return { valid: true }; +} +function validatePluginSettings(settings) { + const errors = []; + const urlValidation = validateOllamaUrl(settings.ollamaUrl); + if (!urlValidation.valid) { + errors.push(`Invalid Ollama URL: ${urlValidation.error}`); + } + const modelValidation = validateModelName(settings.model); + if (!modelValidation.valid) { + errors.push(`Invalid Model Name: ${modelValidation.error}`); + } + return errors; +} +// ==================== Safe JSON Parsing ==================== +const MAX_JSON_SIZE = 1000000; +const MAX_JSON_NESTING = 24; +function countNestingDepth(value, depth = 0) { + if (depth > MAX_JSON_NESTING) { + return depth; + } + if (Array.isArray(value)) { + return Math.max(...value.map((item) => countNestingDepth(item, depth + 1)), depth); + } + if (value !== null && typeof value === 'object') { + const entries = Object.values(value); + if (entries.length === 0) + return depth; + return Math.max(...entries.map((item) => countNestingDepth(item, depth + 1)), depth); + } + return depth; +} +function safeParseJson(jsonString) { + if (typeof jsonString !== 'string') { + throw new Error('Input must be a string'); + } + if (jsonString.length > MAX_JSON_SIZE) { + throw new Error('JSON input too large'); + } + let parsed; + try { + parsed = JSON.parse(jsonString); + } + catch { + throw new Error('Invalid JSON'); + } + // Check for dangerous prototype pollution patterns + const reStringified = JSON.stringify(parsed); + if (reStringified.includes('constructor') || + reStringified.includes('prototype') || + reStringified.includes('__proto__') || + reStringified.includes('function')) { + throw new Error('dangerous code pattern detected'); + } + // Check nesting depth + if (countNestingDepth(parsed) > MAX_JSON_NESTING) { + throw new Error('JSON nesting too deep'); + } + return parsed; +} +// ==================== Path & File Utilities ==================== +function sanitizeFilePath(path) { + if (path.includes('..')) { + throw new Error('Invalid path - cannot contain .. segments'); + } + return path; +} +// ==================== HTTP Helpers ==================== +function isValidHttpUrl(url) { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } + catch { + return false; + } +} +// ==================== Markdown Utilities ==================== +function convertMarkdownToHtml(markdown) { + return markdown.replace(/\n/g, '
').replace(/# (.+)/g, '

$1

'); +} diff --git a/src/vault-indexer.js b/src/vault-indexer.js new file mode 100644 index 0000000..e74353e --- /dev/null +++ b/src/vault-indexer.js @@ -0,0 +1,208 @@ +"use strict"; +// src/vault-indexer.ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.VaultIndexer = void 0; +const utils_1 = require("./utils"); +class VaultIndexer { + constructor(vault) { + this.vault = null; + this.vault = vault; + } + async searchVault(query, limit = 5) { + if (!query || !query.trim()) { + return []; + } + if (!this.vault) { + throw new Error('Vault-like object not provided to VaultIndexer'); + } + const queryTokens = this.tokenize(query.trim()); + 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(vault, files, queryTokens) { + const batchSize = 10; + const results = []; + const seenPaths = new Set(); + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + const batchResults = await Promise.all(batch.map(async (file) => { + try { + const content = await vault.read(file); + const tokenized = this.tokenizeContent(content); + const scoreResult = this.calculateWeightedScore(tokenized, queryTokens, file); + if (scoreResult.score > 0) { + const entry = { + path: file.path, + title: file.basename.replace(/\.md$/, ''), + content: content.substring(0, 500), + score: scoreResult.score, + }; + if (!seenPaths.has(entry.path)) { + seenPaths.add(entry.path); + return entry; + } + return null; + } + return null; + } + catch (error) { + utils_1.Logger.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'vault-indexer'); + return null; + } + })); + const validResults = batchResults.filter((result) => result !== null); + results.push(...validResults); + if (results.length >= 50) { + break; + } + } + return results; + } + tokenize(text) { + const stopWords = new Set([ + 'the', + 'a', + 'an', + 'and', + 'or', + 'but', + 'is', + 'are', + 'was', + 'were', + 'in', + 'on', + 'at', + 'to', + 'of', + 'for', + 'with', + 'as', + 'by', + 'it', + 'its', + 'that', + 'this', + 'these', + 'those', + ]); + return text + .toLowerCase() + .split(/\W+/) + .filter((token) => token.length > 1 && !stopWords.has(token)); + } + tokenizeContent(content) { + const tokens = []; + const headings = []; + const frontmatter = {}; + let firstParagraph; + const frontmatterMatch = content.match(/^---(.*?)---/s); + if (frontmatterMatch) { + try { + const frontmatterContent = frontmatterMatch[1]; + const lines = frontmatterContent.trim().split('\n'); + for (const line of lines) { + const [key, ...valueParts] = line.split(':'); + if (!key) + continue; + const value = valueParts.join(':').trim(); + if (key.trim() === 'title') { + if (value) { + frontmatter.title = value; + } + } + else if (key.trim() === 'tags') { + if (value) { + frontmatter.tags = value; + } + } + } + } + catch { + utils_1.Logger.warn('Failed to parse frontmatter', 'vault-indexer'); + } + } + const headingMatches = content.match(/^#{1,6} (.*?)$/gm); + if (headingMatches) { + headings.push(...headingMatches.map((h) => h.replace(/^#{1,6} /, ''))); + } + const paragraphMatch = content.match(/^([^#]*?)(?=#|\n\n|$)/s); + if (paragraphMatch) { + firstParagraph = paragraphMatch[1].trim(); + } + const allText = content + .replace(/^---.*?---/s, '') + .replace(/^#.*?$/gm, '') + .replace(/```.*?```/gs, '') + .replace(/`.*?`/g, '') + .replace(/\[.*?\]\(.*?\)/g, ''); + tokens.push(...this.tokenize(allText)); + return { tokens, headings, frontmatter, firstParagraph }; + } + calculateWeightedScore(tokenized, queryTokens, file) { + let totalScore = 0; + const matchedTokens = new Set(); + for (const queryToken of queryTokens) { + let tokenScore = 0; + const stemmed = this.stemToken(queryToken); + let matched = false; + if (tokenized.frontmatter?.title && + this.exactMatch(tokenized.frontmatter.title, queryToken)) { + tokenScore += 3; + matched = true; + } + else if (file && + file.basename && + this.exactMatch(file.basename.replace(/\.md$/, ''), queryToken)) { + tokenScore += 3; + matched = true; + } + if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) { + tokenScore += 2.5; + matched = true; + } + if (tokenized.headings.some((heading) => heading.toLowerCase().includes(stemmed))) { + tokenScore += 5; + matched = true; + } + if (tokenized.firstParagraph && tokenized.firstParagraph.toLowerCase().includes(stemmed)) { + tokenScore += 1.5; + matched = true; + } + if (tokenized.tokens.includes(stemmed)) { + tokenScore += 1; + matched = true; + } + if (matched) { + totalScore += tokenScore; + matchedTokens.add(queryToken); + } + } + return { + score: totalScore, + matchedFields: Array.from(matchedTokens), + }; + } + stemToken(token) { + // Improved stemmer that handles edge cases + if (token.length <= 3) + return token; // Don't stem very short tokens + if (token.endsWith('s')) + return token.slice(0, -1); + if (token.endsWith('ed') && token.length > 4) + return token.slice(0, -2); // Don't stem 3-letter words ending in ed + if (token.endsWith('ing') && token.length > 5) + return token.slice(0, -3); // Don't stem 4-letter words ending in ing + return token; + } + exactMatch(content, token) { + const stemmedToken = this.stemToken(token); + return content.toLowerCase().includes(stemmedToken); + } +} +exports.VaultIndexer = VaultIndexer; diff --git a/tsconfig.json b/tsconfig.json index 4b4b992..9dc2845 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,8 +3,8 @@ "target": "ES2020", "lib": ["ESNext", "DOM"], "module": "commonjs", - "outDir": "./", - "rootDir": ".", + "outDir": "./src", + "rootDir": "./src", "strict": true, "types": ["node", "jest"], "esModuleInterop": true,