Refactor error handling, client, and tests for Ollama integration
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user