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.
This commit is contained in:
@@ -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;
|
||||
@@ -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,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;
|
||||
+134
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
+180
@@ -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, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user