Refactor error handling, client, and tests for Ollama integration

This commit is contained in:
2026-05-06 16:30:16 +02:00
parent 59df2f6856
commit fd49abcdb9
35 changed files with 4970 additions and 3765 deletions
+13
View File
@@ -36,6 +36,19 @@ export class ChatView extends ItemView {
private newChatButtonClickHandler: (() => void) | null = null;
private listenersAttached = false;
// Getters for testing
public getSendButtonClickHandler(): (() => Promise<void>) | null {
return this.sendButtonClickHandler;
}
public getInputKeyDownHandler(): ((e: KeyboardEvent) => Promise<void>) | null {
return this.inputKeyDownHandler;
}
public getNewChatButtonClickHandler(): (() => void) | null {
return this.newChatButtonClickHandler;
}
constructor(leaf: WorkspaceLeaf, settings: PluginSettings) {
super(leaf);
this.settings = settings;
+12
View File
@@ -0,0 +1,12 @@
// Default plugin settings
export const DEFAULT_SETTINGS = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
// Model validation regex - lowercase letters, numbers, dashes, underscores only
export const MODEL_NAME_REGEX = /^[a-z0-9-_]+$/;
+126 -13
View File
@@ -1,21 +1,134 @@
// src/error-handler.ts
import { NetworkError, ApiError, UserInputError } from './errors';
import { Notice } from 'obsidian';
import {
OllamaError,
ErrorType,
NetworkError,
ApiError,
ValidationError,
StreamingError,
ToolExecutionError,
PathValidationError,
} from './types';
export class ErrorHandler {
static handle(error: unknown): void {
if (error instanceof NetworkError) {
console.error('Network Error:', error.message);
// Handle network errors, e.g., show a notification to the user
} else if (error instanceof ApiError) {
console.error('API Error:', error.message, 'Status Code:', error.statusCode);
// Handle API errors, e.g., show a notification with status code
} else if (error instanceof UserInputError) {
console.warn('User Input Error:', error.message);
// Handle user input errors, e.g., highlight the input field
static handleError(error: unknown, context?: string): void {
const message = this.getUserFriendlyMessage(error);
new 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 {
console.error('Unexpected Error:', error);
// Handle unexpected errors, e.g., log to a service or show a generic message
const ctx = context ? ` [${context}]` : '';
console.error(`Ollama Plugin Error${ctx}:`, error);
}
}
private static getUserFriendlyMessage(error: unknown): string {
if (error instanceof OllamaError) {
return this.getUserFriendlyMessageFromOllamaError(error);
}
if (error instanceof Error) {
return this.getUserFriendlyMessageFromError(error);
}
return 'An unexpected error occurred';
}
private static getUserFriendlyMessageFromOllamaError(error: OllamaError): string {
switch (error.type) {
case ErrorType.NETWORK_ERROR:
return 'Connection error. Please check if Ollama is running.';
case ErrorType.API_ERROR:
return `API error: ${error.message}`;
case ErrorType.VALIDATION_ERROR:
return this.getUserFriendlyValidationMessage(error);
case ErrorType.STREAMING_ERROR:
return 'Response too long. Please try a shorter request.';
case ErrorType.TOOL_EXECUTION_ERROR:
return `Tool error for ${(error as ToolExecutionError).toolName}. ${error.message}`;
case ErrorType.PATH_VALIDATION_ERROR:
return `Invalid file path: ${(error as PathValidationError).path}`;
case ErrorType.UNKNOWN_ERROR:
return 'An unexpected error occurred';
default:
return 'An unexpected error occurred';
}
}
private static getUserFriendlyValidationMessage(error: OllamaError): string {
if (error instanceof 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.';
}
private static getUserFriendlyMessageFromError(error: Error): string {
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: string, statusCode?: number): NetworkError {
return new NetworkError(message, statusCode);
}
static createApiError(message: string, statusCode?: number): ApiError {
return new ApiError(message, statusCode);
}
static createValidationError(message: string, field?: string): ValidationError {
const details = field ? { field, message } : undefined;
return new ValidationError(message, details);
}
static createStreamingError(message: string): StreamingError {
return new StreamingError(message);
}
static createToolExecutionError(message: string, toolName?: string): ToolExecutionError {
return new ToolExecutionError(message, toolName ?? 'unknown');
}
static createPathValidationError(message: string, path?: string): PathValidationError {
return new PathValidationError(message, path ?? '');
}
static createUnknownError(message: string): OllamaError {
return new OllamaError(message, ErrorType.UNKNOWN_ERROR);
}
}
Executable
+147
View File
@@ -0,0 +1,147 @@
import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab } from 'obsidian';
import { ChatView } from './chat-view';
import { PluginSettings } from './types';
import {
isValidHttpUrl,
validatePluginSettings,
validateOllamaUrl,
validateModelName,
Logger,
} from './utils';
import { DEFAULT_SETTINGS } from './constants';
export default class OllamaPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
async onload() {
// Initialize logging
Logger.info('Ollama Plugin loading...', 'plugin');
await this.loadSettings();
Logger.info('Plugin loaded successfully', 'plugin');
try {
this.registerView(
'ollama-chat-view',
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings)
);
} catch (error) {
Logger.error('Failed to register view: ' + (error as Error).message, 'plugin');
new 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) {
Logger.error('Failed to add ribbon icon: ' + (error as Error).message, 'plugin');
new 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) {
Logger.debug('Loading saved settings', 'settings');
this.settings = Object.assign({}, this.settings, data);
}
} catch (error) {
// Use centralized error handling
const { ErrorHandler } = await import('./error-handler');
ErrorHandler.handleError(error, 'settings load');
}
}
async saveSettings() {
try {
// Validate settings before saving
const validationErrors = validatePluginSettings(this.settings);
if (validationErrors.length > 0) {
Logger.error(
'Validation errors prevented saving settings: ' + validationErrors.join('; '),
'settings'
);
new Notice(`Cannot save settings: ${validationErrors[0]}`);
return false;
}
Logger.debug('Saving settings: ' + JSON.stringify(this.settings), 'settings');
await this.saveData(this.settings);
Logger.info('Settings saved successfully', 'settings');
return true;
} catch (error) {
// Use centralized error handling
const { ErrorHandler } = await import('./error-handler');
ErrorHandler.handleError(error, 'settings save');
return false;
}
}
}
class OllamaSettingTab extends PluginSettingTab {
private plugin: OllamaPlugin;
constructor(app: App, plugin: OllamaPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
// Clear any existing content first to prevent duplicates
this.containerEl.empty();
// Create container for settings
const container = this.containerEl.createDiv() as HTMLElement;
container.empty();
new Setting(container)
.setName('Ollama URL')
.setDesc('URL of your Ollama instance')
.addText((text) =>
text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => {
const urlValidation = validateOllamaUrl(value);
if (urlValidation.valid) {
Logger.debug('URL changed to: ' + value, 'settings');
this.plugin.settings.ollamaUrl = value;
await this.plugin.saveSettings();
} else {
Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings');
new Notice(urlValidation.error || 'Invalid Ollama URL format.');
}
})
);
new Setting(container)
.setName('Model')
.setDesc('Model to use for chat')
.addText((text) =>
text.setValue(this.plugin.settings.model).onChange(async (value) => {
const modelValidation = validateModelName(value);
if (modelValidation.valid) {
Logger.debug('Model changed to: ' + value, 'settings');
this.plugin.settings.model = value;
await this.plugin.saveSettings();
} else {
Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings');
new Notice(modelValidation.error || 'Invalid model name format.');
}
})
);
}
hide(): void {
// Clear the container to prevent duplicate elements
this.containerEl.empty();
}
}
+203 -76
View File
@@ -1,124 +1,251 @@
// src/ollama-client.ts
import type { OllamaMessage, ToolCall } from './types';
import { ApiError, NetworkError, UserInputError } from './error-handler';
import type { OllamaMessage, OllamaTool } from './types';
import { ApiError, NetworkError } from './types';
import { Logger } from './utils';
export class OllamaClient {
private url: string;
private baseURL: string;
private model: string;
private abortController: AbortController | null = null;
private fetchFn: typeof fetch;
private readonly maxRetries: number = 3;
constructor(url: string, model: string, fetchFn?: typeof fetch) {
this.url = url;
constructor(baseURL: string, model: string, fetchFn?: typeof fetch) {
this.baseURL = baseURL;
this.model = model;
this.fetchFn = fetchFn ?? fetch;
}
async *streamChatMessages(
prompt: string,
options: { abortSignal?: AbortSignal } = {}
): AsyncGenerator<OllamaMessage, void, unknown> {
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener('abort', () => controller.abort());
cancelStream(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
async *streamChat(
messages: OllamaMessage[],
tools: OllamaTool[] = []
): AsyncGenerator<OllamaMessage, void, unknown> {
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: OllamaMessage[],
tools: OllamaTool[] = []
): Promise<OllamaMessage[]> {
const chunks: OllamaMessage[] = [];
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;
}
}
private async *streamChatWithRetry(
messages: OllamaMessage[],
tools: OllamaTool[] = [],
attempt: number = 0
): AsyncGenerator<OllamaMessage, void, unknown> {
this.abortController = new AbortController();
try {
const response = await this.fetchFn(`${this.url}/chat`, {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, prompt }),
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: true,
}),
signal: this.abortController.signal,
});
if (!response.ok) {
throw new ApiError('Failed to fetch chat messages', response.status);
// 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
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 ApiError(`Ollama API error: ${response.status}`, response.status);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
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;
const chunk = new TextDecoder().decode(value);
const messages: OllamaMessage[] = JSON.parse(chunk);
for (const message of messages) {
yield message;
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) as Record<string, unknown>;
// Check for Ollama error in stream
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message as OllamaMessage | undefined;
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');
}
Logger.warn(
`Skipped malformed chunk: ${line.substring(0, 80)}... - ${(e as Error).message}`,
'ollama-client'
);
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer) as Record<string, unknown>;
if (parsed.error) {
throw new Error(`Ollama error: ${String(parsed.error)}`);
}
const message = parsed.message as OllamaMessage | undefined;
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;
}
Logger.warn(
`Failed to parse final chunk: ${buffer.substring(0, 80)}...`,
'ollama-client'
);
}
}
} finally {
reader.releaseLock();
}
} catch (error) {
controller.abort();
throw error;
} finally {
this.abortController = null;
}
}
async *streamToolMessages(
toolCall: ToolCall,
options: { abortSignal?: AbortSignal } = {}
): AsyncGenerator<OllamaMessage, void, unknown> {
async chat(messages: OllamaMessage[], tools: OllamaTool[] = []): Promise<OllamaMessage> {
return this.chatWithRetry(messages, tools, 0);
}
private async chatWithRetry(
messages: OllamaMessage[],
tools: OllamaTool[] = [],
attempt: number = 0
): Promise<OllamaMessage> {
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener('abort', () => controller.abort());
}
try {
const response = await this.fetchFn(`${this.url}/tool`, {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, toolCall }),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.model,
messages: messages,
tools: tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new ApiError('Failed to fetch tool messages', response.status);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
const messages: OllamaMessage[] = JSON.parse(chunk);
for (const message of messages) {
yield message;
}
// 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
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);
}
} finally {
reader.releaseLock();
}
} catch (error) {
controller.abort();
throw error;
}
}
async summarizeText(text: string): Promise<{ summary: string }> {
try {
const response = await this.fetchFn(`${this.url}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, text }),
});
if (!response.ok) {
throw new ApiError('Failed to summarize text', response.status);
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
}
return await response.json();
} catch (error) {
throw error;
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
}
}
}
+106 -33
View File
@@ -1,58 +1,131 @@
// src/tool-executor.ts
import { OllamaClient } from './ollama-client';
import type { ToolCall, ExecutionResult } from './types';
import { ErrorHandler } from './error-handler';
import { safeWriteFile } from './utils';
import { UserInputError, ApiError } from './errors';
import { Vault, App } from 'obsidian';
import type { ToolCall, ToolResult } from './types';
import { safeParseJson } from './utils';
// Disallow characters that are invalid in file paths
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
const MAX_PATH_LENGTH = 200;
const FORBIDDEN_DIRS = ['.obsidian', '.git'];
export class ToolExecutor {
private ollamaClient: OllamaClient;
private vault: Vault;
private app: App;
constructor(ollamaClient: OllamaClient) {
this.ollamaClient = ollamaClient;
constructor(vault: Vault, app: App) {
this.vault = vault;
this.app = app;
}
async executeTool(toolCall: ToolCall, options: { abortSignal?: AbortSignal } = {}): Promise<ExecutionResult> {
try {
const messages: string[] = [];
private isSafePath(path: string): boolean {
// Reject empty paths
if (!path || path.trim().length === 0) {
return false;
}
for await (const message of this.ollamaClient.streamToolMessages(toolCall, options)) {
if (options.abortSignal?.aborted) {
throw new Error('Operation aborted');
// 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: ToolCall): Promise<ToolResult> {
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: Record<string, unknown>;
if (typeof rawArgs === 'string') {
try {
parsedArgs = safeParseJson(rawArgs) as Record<string, unknown>;
} catch {
throw new Error('Invalid JSON arguments');
}
messages.push(message.content);
} else if (rawArgs && typeof rawArgs === 'object') {
parsedArgs = rawArgs as Record<string, unknown>;
} else {
throw new Error('Arguments must be an object or JSON string');
}
const finalOutput = messages.join('\n');
// Process the tool output based on its type
switch (toolCall.tool_name) {
// Process the tool call based on its type
switch (toolName) {
case 'create_file':
await this.handleCreateFile(toolCall.arguments, finalOutput);
break;
// Add more cases for other tools as needed
return await this.handleCreateFile(parsedArgs);
default:
console.warn(`Unsupported tool: ${toolCall.tool_name}`);
return { success: false, message: `Unknown tool: ${toolName}` };
}
return { success: true, output: finalOutput };
} catch (error) {
ErrorHandler.handle(error);
return { success: false, output: error instanceof Error ? error.message : 'An unknown error occurred' };
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
}
}
private async handleCreateFile(args: Record<string, string>, content: string): Promise<void> {
const filePath = args.path;
if (!filePath) {
throw new UserInputError('Path argument is required for create_file tool');
private async handleCreateFile(args: Record<string, unknown>): Promise<ToolResult> {
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 safeWriteFile(filePath, content);
await this.vault.create(path, content);
return { success: true, message: 'File created successfully' };
} catch (error) {
throw new ApiError('Failed to write file', 500);
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(errorMessage);
}
}
}
+173 -13
View File
@@ -1,26 +1,186 @@
// src/types.ts
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant';
content: string;
// ============================================================
// Error Type Hierarchy
// ============================================================
export enum ErrorType {
NETWORK_ERROR = 'network_error',
API_ERROR = 'api_error',
VALIDATION_ERROR = 'validation_error',
STREAMING_ERROR = 'streaming_error',
TOOL_EXECUTION_ERROR = 'tool_execution_error',
PATH_VALIDATION_ERROR = 'path_validation_error',
UNKNOWN_ERROR = 'unknown_error',
}
export class OllamaError extends Error {
public readonly type: ErrorType;
constructor(message: string, type: ErrorType) {
super(message);
this.type = type;
Object.setPrototypeOf(this, OllamaError.prototype);
}
}
export class NetworkError extends OllamaError {
public readonly statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message, ErrorType.NETWORK_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
export class ApiError extends OllamaError {
public readonly statusCode?: number;
constructor(message: string, statusCode?: number) {
super(message, ErrorType.API_ERROR);
this.statusCode = statusCode;
Object.setPrototypeOf(this, ApiError.prototype);
}
}
export interface ValidationFieldDetails {
field?: string;
message?: string;
}
export class ValidationError extends OllamaError {
public readonly details?: ValidationFieldDetails;
constructor(message: string, details?: ValidationFieldDetails) {
super(message, ErrorType.VALIDATION_ERROR);
this.details = details;
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
export class StreamingError extends OllamaError {
constructor(message: string) {
super(message, ErrorType.STREAMING_ERROR);
Object.setPrototypeOf(this, StreamingError.prototype);
}
}
export class ToolExecutionError extends OllamaError {
public readonly toolName: string;
constructor(message: string, toolName: string) {
super(message, ErrorType.TOOL_EXECUTION_ERROR);
this.toolName = toolName;
Object.setPrototypeOf(this, ToolExecutionError.prototype);
}
}
export class PathValidationError extends OllamaError {
public readonly path: string;
constructor(message: string, path: string) {
super(message, ErrorType.PATH_VALIDATION_ERROR);
this.path = path;
Object.setPrototypeOf(this, PathValidationError.prototype);
}
}
// ============================================================
// Plugin Configuration
// ============================================================
export interface PluginSettings {
ollamaUrl: string;
model: string;
vaultSearchLimit: number;
maxMessageHistory: number;
lastIndexTime: number;
}
export const DEFAULT_SETTINGS: PluginSettings = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
// ============================================================
// Ollama Protocol Types
// ============================================================
export interface OllamaTool {
type: 'function';
function: {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, unknown>;
required: string[];
};
};
}
export interface OllamaToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string | Record<string, unknown>;
};
}
export interface OllamaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
tool_calls?: OllamaToolCall[];
}
// ============================================================
// Tool Execution Types
// ============================================================
export interface ToolCall {
tool_name: string;
arguments: Record<string, unknown>;
id: string;
type: 'function';
function: {
name: string;
arguments: string | Record<string, unknown>;
};
}
export interface ModelConfig {
model: string;
url: string;
}
export enum RequestType {
Chat = 'chat',
Tool = 'tool',
export interface ToolResult {
success: boolean;
message: string;
}
export interface ExecutionResult {
success: boolean;
output: string;
}
// ============================================================
// Chat Message Types
// ============================================================
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
isStreaming?: boolean;
tool_calls?: ToolCall[];
}
// ============================================================
// Vault Index Types
// ============================================================
export interface VaultIndexEntry {
path: string;
title: string;
content: string;
score: number;
}
+193 -13
View File
@@ -1,29 +1,209 @@
// src/utils.ts
import { UserInputError, ApiError } from './errors';
// ==================== Logger ====================
export function convertMarkdownToHtml(markdown: string): string {
// Simple markdown to HTML conversion for demonstration purposes
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
const SEVERITY_ORDER: Record<string, number> = {
debug: LogLevel.DEBUG,
info: LogLevel.INFO,
warn: LogLevel.WARN,
error: LogLevel.ERROR,
};
export class Logger {
private static minLevel: LogLevel = LogLevel.DEBUG;
static setLevel(level: string | LogLevel): void {
if (typeof level === 'string') {
Logger.minLevel = SEVERITY_ORDER[level.toLowerCase()] ?? LogLevel.DEBUG;
} else {
Logger.minLevel = level;
}
}
static debug(message: string, category: string = 'general'): void {
if (LogLevel.DEBUG >= Logger.minLevel) {
console.debug(`[${category}] DEBUG: ${message}`);
}
}
static info(message: string, category: string = 'general'): void {
if (LogLevel.INFO >= Logger.minLevel) {
console.info(`[${category}] INFO: ${message}`);
}
}
static warn(message: string, category: string = 'general'): void {
if (LogLevel.WARN >= Logger.minLevel) {
console.warn(`[${category}] WARN: ${message}`);
}
}
static error(message: string, category: string = 'general'): void {
if (LogLevel.ERROR >= Logger.minLevel) {
console.error(`[${category}] ERROR: ${message}`);
}
}
}
// ==================== URL & Model Validation ====================
export function validateOllamaUrl(url: string): { valid: boolean; error?: string } {
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' };
}
}
export function validateModelName(model: string): { valid: boolean; error?: string } {
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 };
}
export function validatePluginSettings(settings: { ollamaUrl: string; model: string }): string[] {
const errors: string[] = [];
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 = 1_000_000;
const MAX_JSON_NESTING = 24;
function countNestingDepth(value: unknown, depth: number = 0): number {
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 as Record<string, unknown>);
if (entries.length === 0) return depth;
return Math.max(...entries.map((item) => countNestingDepth(item, depth + 1)), depth);
}
return depth;
}
export function safeParseJson(jsonString: string): unknown {
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: unknown;
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 ====================
export function sanitizeFilePath(path: string): string {
if (path.includes('..')) {
throw new UserInputError('Invalid path - cannot contain .. segments');
throw new Error('Invalid path - cannot contain .. segments');
}
return path;
}
export async function safeWriteFile(filePath: string, content: string): Promise<void> {
const sanitizedPath = sanitizeFilePath(filePath);
console.log(`Writing to ${sanitizedPath}:`, content);
}
// ==================== HTTP Helpers ====================
export function isValidHttpUrl(url: string): boolean {
try {
// Simulate file writing operation
console.log(`Writing to ${sanitizedPath}:`, content);
// In a real scenario, you would use fs.promises.writeFile or similar here
} catch (error) {
if (error instanceof UserInputError) {
throw error;
}
throw new ApiError('Failed to write file', 500);
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
// ==================== Markdown Utilities ====================
export function convertMarkdownToHtml(markdown: string): string {
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
}
+263 -15
View File
@@ -1,7 +1,7 @@
// src/vault-indexer.ts
import { OllamaClient } from './ollama-client';
import { ApiError, UserInputError, VaultIndexerError } from './errors';
import { ApiError, NetworkError, ValidationError } from './types';
import { ErrorHandler } from './error-handler';
import { sanitizeFilePath } from './utils';
@@ -10,12 +10,23 @@ interface FileSummary {
summary: string;
}
class VaultIndexer {
private ollamaClient: OllamaClient;
private summaries: Map<string, string> = new Map();
interface Frontmatter {
title?: string;
tags?: string;
}
constructor(ollamaClient: OllamaClient) {
this.ollamaClient = ollamaClient;
class VaultIndexer {
private ollamaClient: OllamaClient | null = null;
private summaries: Map<string, string> = new Map();
private vault: any;
constructor(vaultOrClient: any) {
// 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: string): Promise<void> {
@@ -27,10 +38,10 @@ class VaultIndexer {
this.storeSummary(file.path, summary);
}
} catch (error: unknown) {
if (error instanceof VaultIndexerError) {
ErrorHandler.handle(error);
if (error instanceof Error) {
ErrorHandler.handleError(error, 'VaultIndexer.indexVault');
} else {
throw new VaultIndexerError('An unexpected error occurred while indexing the vault', error);
throw new ValidationError('An unexpected error occurred while indexing the vault');
}
}
}
@@ -42,7 +53,7 @@ class VaultIndexer {
// This is a placeholder for actual file system operations
return [{ path: `${sanitizedPath}/file1.md` }, { path: `${sanitizedPath}/file2.md` }];
} catch (error) {
throw new VaultIndexerError('Failed to get markdown files from vault', error);
throw new ValidationError('Failed to get markdown files from vault');
}
}
@@ -52,16 +63,32 @@ class VaultIndexer {
// This is a placeholder for actual file reading operations
return `Content of ${sanitizedPath}`;
} catch (error) {
throw new VaultIndexerError('Failed to read file content', error);
throw new ValidationError('Failed to read file content');
}
}
private async summarizeFile(content: string): Promise<string> {
if (!this.ollamaClient) {
throw new ValidationError('OllamaClient not available for summarization');
}
try {
const response = await this.ollamaClient.summarizeText(content);
return response.summary;
// Use the existing chat API to summarize text
const messages = [
{
role: 'system' as const,
content: 'Summarize the following text concisely:',
},
{
role: 'user' as const,
content: content,
},
];
const response = await this.ollamaClient.chat(messages);
return response.content;
} catch (error) {
throw new VaultIndexerError('Failed to summarize file', error);
throw new ValidationError('Failed to summarize file');
}
}
@@ -74,6 +101,227 @@ class VaultIndexer {
const sanitizedPath = sanitizeFilePath(filePath);
return this.summaries.get(sanitizedPath);
}
async searchVault(query: string, limit: number = 5): Promise<any[]> {
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 is NonNullable<typeof result> => result !== null)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
private async processFilesInBatches(files: any[], queryTokens: string[]): Promise<any[]> {
const batchSize = 10;
const results: any[] = [];
const seenPaths = new Set<string>();
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 is NonNullable<typeof result> => result !== null
);
results.push(...validResults);
if (results.length >= 50) {
break;
}
}
return results;
}
private tokenize(text: string): string[] {
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));
}
private tokenizeContent(content: string, file: any): any {
const tokens: string[] = [];
const headings: string[] = [];
let frontmatter: Frontmatter = {};
let firstParagraph: string | undefined;
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: string) => 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 };
}
private calculateWeightedScore(tokenized: any, queryTokens: string[], file?: any): any {
let totalScore = 0;
const matchedTokens: Set<string> = new Set<string>();
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: string) => 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),
};
}
private stemToken(token: string): string {
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;
}
private exactMatch(content: string, token: string): boolean {
const stemmedToken = this.stemToken(token);
return content.toLowerCase().includes(stemmedToken);
}
}
export { VaultIndexer };
export { VaultIndexer };