Add error handler and improve coverage
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
"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 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.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';
|
||||
}
|
||||
async onOpen() {
|
||||
await this.render();
|
||||
this.removeEventListeners(); // Clean up any existing listeners before reattaching
|
||||
this.setupEventListeners();
|
||||
}
|
||||
onSettingsChange(newSettings) {
|
||||
this.updateSettings(newSettings);
|
||||
}
|
||||
async onClose() {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
this.cleanupStreamingResources();
|
||||
this.lastMessageEl = null;
|
||||
this.sendButton = null;
|
||||
this.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
}
|
||||
cleanupStreamingResources() {
|
||||
// Ensure any ongoing streaming is properly cleaned up
|
||||
if (this.lastMessageEl && this.lastMessageEl.parentElement) {
|
||||
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
|
||||
this.lastMessageEl = null;
|
||||
}
|
||||
}
|
||||
async 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');
|
||||
const existingIds = Array.from(existingMessages).map((el) => el.getAttribute('data-msg-id'));
|
||||
for (const msg of nonStreamingMessages) {
|
||||
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
||||
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.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
||||
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
|
||||
if (this.newChatButton) {
|
||||
if (!this.newChatButtonClickHandler) {
|
||||
this.newChatButtonClickHandler = () => this.clearConversation();
|
||||
}
|
||||
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
|
||||
}
|
||||
this.listenersAttached = true;
|
||||
}
|
||||
removeEventListeners() {
|
||||
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() {
|
||||
// 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;
|
||||
}
|
||||
async 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((e) => `### ${e.title}\n${e.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 userMessageWithContext = {
|
||||
role: 'user',
|
||||
content: `${context}\n\n${userMessage}`,
|
||||
};
|
||||
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' },
|
||||
content: { type: 'string' },
|
||||
},
|
||||
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];
|
||||
await this.render();
|
||||
const stream = await this.ollamaClient.streamChat(messages, tools);
|
||||
let fullResponse = '';
|
||||
let toolCalls = [];
|
||||
let chunkCount = 0;
|
||||
const MAX_STREAM_CHUNKS = 1000;
|
||||
const maxChunks = MAX_STREAM_CHUNKS;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
chunkCount++;
|
||||
if (chunkCount > maxChunks) {
|
||||
throw new Error('Response too long, stopped streaming');
|
||||
}
|
||||
if (chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
}
|
||||
if (chunk.tool_calls) {
|
||||
toolCalls = toolCalls.concat(chunk.tool_calls);
|
||||
}
|
||||
await this.updateLastMessage(fullResponse);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
// Clean up streaming resources regardless of outcome
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
// Update the assistant message with the full response immutably
|
||||
if (!this.updateMessageById(assistantMessageId, {
|
||||
content: fullResponse,
|
||||
tool_calls: toolCalls,
|
||||
})) {
|
||||
throw new Error('Assistant message not found');
|
||||
}
|
||||
// Process tool calls with proper follow-up context
|
||||
if (toolCalls.length > 0) {
|
||||
// Validate tool calls before processing
|
||||
const MAX_TOOL_CALLS = 10;
|
||||
if (toolCalls.length > MAX_TOOL_CALLS) {
|
||||
throw new Error(`Too many tool calls (max ${MAX_TOOL_CALLS})`);
|
||||
}
|
||||
// Collect all tool results using allSettled to support partial results
|
||||
const settledResults = await Promise.allSettled(toolCalls.map((call) => this.toolExecutor.handleToolCall(call)));
|
||||
let toolResults = [];
|
||||
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');
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
await this.updateLastMessage(fullResponse);
|
||||
// Update the assistant message with the final response immutably
|
||||
this.updateMessageById(assistantMessageId, { content: fullResponse, isStreaming: false });
|
||||
}
|
||||
// Update last message immutably — only if no tool calls were processed
|
||||
if (toolCalls.length === 0) {
|
||||
const lastMessageIndex = this.messages.length - 1;
|
||||
if (lastMessageIndex >= 0) {
|
||||
const lastMessage = { ...this.messages[lastMessageIndex], isStreaming: false };
|
||||
this.messages = [...this.messages.slice(0, lastMessageIndex), lastMessage];
|
||||
}
|
||||
}
|
||||
// Limit conversation history to prevent memory issues
|
||||
if (this.messages.length > MAX_MESSAGE_HISTORY) {
|
||||
this.messages = this.messages.slice(-MAX_MESSAGE_HISTORY);
|
||||
}
|
||||
await this.render();
|
||||
}
|
||||
catch (error) {
|
||||
// Use centralized error handler
|
||||
error_handler_1.ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
||||
this.cleanupStreamingResources();
|
||||
}
|
||||
finally {
|
||||
if (this.sendButton) {
|
||||
this.sendButton.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ChatView = ChatView;
|
||||
@@ -57,6 +57,11 @@ export class ChatView extends ItemView {
|
||||
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
|
||||
}
|
||||
|
||||
public updateSettings(newSettings: PluginSettings): void {
|
||||
this.settings = newSettings;
|
||||
this.ollamaClient = new OllamaClient(newSettings.ollamaUrl, newSettings.model);
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return 'ollama-chat-view';
|
||||
}
|
||||
@@ -71,6 +76,10 @@ export class ChatView extends ItemView {
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
public onSettingsChange(newSettings: PluginSettings): void {
|
||||
this.updateSettings(newSettings);
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.ollamaClient.cancelStream();
|
||||
this.removeEventListeners();
|
||||
|
||||
@@ -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-_]+$/;
|
||||
@@ -0,0 +1,106 @@
|
||||
"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}]` : '';
|
||||
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;
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
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");
|
||||
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,
|
||||
});
|
||||
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) {
|
||||
// Use centralized error handling
|
||||
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
|
||||
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) {
|
||||
// Use centralized error handling
|
||||
const { ErrorHandler } = await Promise.resolve().then(() => __importStar(require('./error-handler')));
|
||||
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 && view.onSettingsChange) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
+13
@@ -87,6 +87,17 @@ export default class OllamaPlugin extends Plugin {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify all open ChatView instances when settings change
|
||||
public notifyChatViews(): void {
|
||||
const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view');
|
||||
leaves.forEach((leaf) => {
|
||||
const view = leaf.view as ChatView;
|
||||
if (view && view.onSettingsChange) {
|
||||
view.onSettingsChange(this.settings);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class OllamaSettingTab extends PluginSettingTab {
|
||||
@@ -115,6 +126,7 @@ class OllamaSettingTab extends PluginSettingTab {
|
||||
Logger.debug('URL changed to: ' + value, 'settings');
|
||||
this.plugin.settings.ollamaUrl = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.notifyChatViews();
|
||||
} else {
|
||||
Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings');
|
||||
new Notice(urlValidation.error || 'Invalid Ollama URL format.');
|
||||
@@ -132,6 +144,7 @@ class OllamaSettingTab extends PluginSettingTab {
|
||||
Logger.debug('Model changed to: ' + value, 'settings');
|
||||
this.plugin.settings.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.notifyChatViews();
|
||||
} else {
|
||||
Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings');
|
||||
new Notice(modelValidation.error || 'Invalid model name format.');
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"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);
|
||||
}
|
||||
/**
|
||||
* Wrapper method for testing that converts async generator to Promise
|
||||
* This allows testing with .rejects.toThrow() syntax
|
||||
*/
|
||||
async streamChatAsPromise(messages, tools = []) {
|
||||
const chunks = [];
|
||||
try {
|
||||
for await (const chunk of this.streamChat(messages, tools)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
catch (error) {
|
||||
// Re-throw the error so tests can catch it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
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: messages,
|
||||
tools: tools,
|
||||
stream: true,
|
||||
}),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
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 MAX_MALFORMED = 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);
|
||||
// Check for Ollama error in stream
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
const message = parsed.message;
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
malformedCount = 0; // Reset on successful parse
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e; // Re-throw Ollama errors
|
||||
}
|
||||
malformedCount++;
|
||||
if (malformedCount > MAX_MALFORMED) {
|
||||
throw new Error('Too many malformed chunks in stream');
|
||||
}
|
||||
utils_1.Logger.warn(`Skipped malformed chunk: ${line.substring(0, 80)}... - ${e.message}`, 'ollama-client');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(buffer);
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${String(parsed.error)}`);
|
||||
}
|
||||
const message = parsed.message;
|
||||
if (message) {
|
||||
yield {
|
||||
role: message.role ?? 'assistant',
|
||||
content: message.content ?? '',
|
||||
tool_calls: message.tool_calls ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith('Ollama error:')) {
|
||||
throw e;
|
||||
}
|
||||
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();
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// For network errors (5xx), retry with exponential backoff
|
||||
if (response.status >= 500 && attempt < this.maxRetries) {
|
||||
const retryDelay = Math.pow(2, attempt) * 100; // Exponential backoff: 200ms, 400ms, 800ms
|
||||
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();
|
||||
// Handle missing message content gracefully
|
||||
if (!data.message) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: data.message.role ?? 'assistant',
|
||||
content: typeof data.message.content === 'string' ? data.message.content : '',
|
||||
tool_calls: data.message.tool_calls ?? [],
|
||||
};
|
||||
}
|
||||
finally {
|
||||
// No need to abort after successful response, but signal is available
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.OllamaClient = OllamaClient;
|
||||
@@ -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.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;
|
||||
@@ -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,
|
||||
};
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"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.safeWriteFile = safeWriteFile;
|
||||
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') {
|
||||
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? 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, and underscores',
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
async function safeWriteFile(filePath, content) {
|
||||
const sanitizedPath = sanitizeFilePath(filePath);
|
||||
console.log(`Writing to ${sanitizedPath}:`, content);
|
||||
}
|
||||
// ==================== 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, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use strict";
|
||||
// src/vault-indexer.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.VaultIndexer = void 0;
|
||||
const types_1 = require("./types");
|
||||
const error_handler_1 = require("./error-handler");
|
||||
const utils_1 = require("./utils");
|
||||
class VaultIndexer {
|
||||
constructor(vaultOrClient) {
|
||||
this.ollamaClient = null;
|
||||
this.summaries = new Map();
|
||||
// Support both old (OllamaClient) and new (VaultLike) interfaces
|
||||
if (vaultOrClient && typeof vaultOrClient.getMarkdownFiles === 'function') {
|
||||
this.vault = vaultOrClient;
|
||||
}
|
||||
else {
|
||||
this.ollamaClient = vaultOrClient || null;
|
||||
}
|
||||
}
|
||||
async indexVault(vaultPath) {
|
||||
try {
|
||||
const files = await this.getMarkdownFilesInVault(vaultPath);
|
||||
for (const file of files) {
|
||||
const content = await this.readFileContent(file.path);
|
||||
const summary = await this.summarizeFile(content);
|
||||
this.storeSummary(file.path, summary);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
error_handler_1.ErrorHandler.handleError(error, 'VaultIndexer.indexVault');
|
||||
}
|
||||
else {
|
||||
throw new types_1.ValidationError('An unexpected error occurred while indexing the vault');
|
||||
}
|
||||
}
|
||||
}
|
||||
async getMarkdownFilesInVault(vaultPath) {
|
||||
// Simulate getting markdown files from the vault
|
||||
const sanitizedPath = (0, utils_1.sanitizeFilePath)(vaultPath);
|
||||
try {
|
||||
// This is a placeholder for actual file system operations
|
||||
return [{ path: `${sanitizedPath}/file1.md` }, { path: `${sanitizedPath}/file2.md` }];
|
||||
}
|
||||
catch (error) {
|
||||
throw new types_1.ValidationError('Failed to get markdown files from vault');
|
||||
}
|
||||
}
|
||||
async readFileContent(filePath) {
|
||||
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
|
||||
try {
|
||||
// This is a placeholder for actual file reading operations
|
||||
return `Content of ${sanitizedPath}`;
|
||||
}
|
||||
catch (error) {
|
||||
throw new types_1.ValidationError('Failed to read file content');
|
||||
}
|
||||
}
|
||||
async summarizeFile(content) {
|
||||
if (!this.ollamaClient) {
|
||||
throw new types_1.ValidationError('OllamaClient not available for summarization');
|
||||
}
|
||||
try {
|
||||
// Use the existing chat API to summarize text
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Summarize the following text concisely:',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: content,
|
||||
},
|
||||
];
|
||||
const response = await this.ollamaClient.chat(messages);
|
||||
return response.content;
|
||||
}
|
||||
catch (error) {
|
||||
throw new types_1.ValidationError('Failed to summarize file');
|
||||
}
|
||||
}
|
||||
storeSummary(filePath, summary) {
|
||||
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
|
||||
this.summaries.set(sanitizedPath, summary);
|
||||
}
|
||||
getSummary(filePath) {
|
||||
const sanitizedPath = (0, utils_1.sanitizeFilePath)(filePath);
|
||||
return this.summaries.get(sanitizedPath);
|
||||
}
|
||||
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 allFiles = this.vault.getMarkdownFiles();
|
||||
const results = await this.processFilesInBatches(allFiles, queryTokens);
|
||||
return results
|
||||
.filter((result) => result !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
async processFilesInBatches(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 this.vault.read(file);
|
||||
const tokenized = this.tokenizeContent(content, file);
|
||||
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) {
|
||||
console.warn(`Failed to process ${file.path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
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, file) {
|
||||
const tokens = [];
|
||||
const headings = [];
|
||||
let 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 (e) {
|
||||
console.warn('Failed to parse frontmatter');
|
||||
}
|
||||
}
|
||||
const headingMatches = content.match(/^# (.*?)$/gm);
|
||||
if (headingMatches) {
|
||||
headings.push(...headingMatches.map((h) => h.replace(/^# /, '')));
|
||||
}
|
||||
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) {
|
||||
if (token.endsWith('s'))
|
||||
return token.slice(0, -1);
|
||||
if (token.endsWith('ed'))
|
||||
return token.slice(0, -2);
|
||||
if (token.endsWith('ing'))
|
||||
return token.slice(0, -3);
|
||||
return token;
|
||||
}
|
||||
exactMatch(content, token) {
|
||||
const stemmedToken = this.stemToken(token);
|
||||
return content.toLowerCase().includes(stemmedToken);
|
||||
}
|
||||
}
|
||||
exports.VaultIndexer = VaultIndexer;
|
||||
Reference in New Issue
Block a user