diff --git a/src/constants.ts b/src/constants.ts index 85626bf..d58af0f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,3 @@ -// Default plugin settings - export const DEFAULT_SETTINGS = { ollamaUrl: 'http://localhost:11434', model: 'llama3', diff --git a/src/main.ts b/src/main.ts index 3b57a6c..12b2b3c 100755 --- a/src/main.ts +++ b/src/main.ts @@ -100,49 +100,63 @@ class OllamaSettingTab extends PluginSettingTab { } display(): void { - // Clear any existing content first to prevent duplicates - this.containerEl.empty(); + const { containerEl } = this; - // Create container for settings - const container = this.containerEl.createDiv() as HTMLElement; + containerEl.empty(); - new Setting(container) + containerEl.createEl('h2', { text: 'Ollama Plugin Settings' }); + + new Setting(containerEl) .setName('Ollama URL') - .setDesc('URL of your Ollama instance') + .setDesc('The 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(); - this.plugin.notifyChatViews(); - } else { - Logger.warn('Invalid URL format: ' + urlValidation.error, 'settings'); - new Notice(urlValidation.error || 'Invalid Ollama URL format.'); - } + this.plugin.settings.ollamaUrl = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); }) ); - new Setting(container) + new Setting(containerEl) .setName('Model') - .setDesc('Model to use for chat') + .setDesc('The Ollama model to use') .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(); - this.plugin.notifyChatViews(); - } else { - Logger.warn('Invalid model name format: ' + modelValidation.error, 'settings'); - new Notice(modelValidation.error || 'Invalid model name format.'); - } + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); }) ); - new Setting(container) + new Setting(containerEl) + .setName('Vault Search Limit') + .setDesc('Maximum number of vault files to search') + .addSlider((slider) => + slider + .setValue(this.plugin.settings.vaultSearchLimit) + .setLimits(1, 10, 1) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.vaultSearchLimit = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Max Message History') + .setDesc('Maximum number of messages to keep in history') + .addSlider((slider) => + slider + .setValue(this.plugin.settings.maxMessageHistory) + .setLimits(10, 100, 5) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.maxMessageHistory = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) .setName('Enable Semantic Cache') .setDesc('Cache responses semantically to speed up repeated queries') .addToggle((toggle) => diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 1e0d5fe..86a565c 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -2,11 +2,11 @@ import type { OllamaMessage, OllamaTool } from './types'; import { ApiError } from './types'; -import { Logger } from './utils'; -import { SemanticCacheService, CacheConfig } from './semantic-cache'; +import { SemanticCacheService } from './semantic-cache'; interface OllamaChatResponse { message?: Partial; + error?: string; } export class OllamaClient { @@ -17,7 +17,7 @@ export class OllamaClient { private currentStreamController: AbortController | null = null; private cacheService?: SemanticCacheService; - constructor(baseURL: string, model: string, fetchFn?: typeof fetch, cacheConfig?: CacheConfig) { + constructor(baseURL: string, model: string, fetchFn?: typeof fetch, cacheConfig?: any) { this.baseURL = baseURL; this.model = model; this.fetchFn = fetchFn ?? fetch; @@ -118,145 +118,80 @@ export class OllamaClient { try { const response = await this.fetchFn(`${this.baseURL}/api/chat`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: this.model, - messages, - tools, + messages: messages, stream: true, + tools: tools, }), signal: controller.signal, }); if (!response.ok) { - if (response.status >= 500 && attempt < this.maxRetries) { - const retryDelay = Math.pow(2, attempt) * 100; - Logger.warn( - `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, - 'ollama-client' - ); - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } finally { - signal.removeEventListener('abort', abortListener); - } - // Check if signal was aborted before retrying - if (signal.aborted) { - throw new Error('Stream cancelled by user'); - } - } else { - await retryTimeout; - } - yield* this.streamChatWithRetry(messages, tools, attempt + 1); - return; - } - throw new ApiError(`Ollama API error: ${response.status}`, response.status); + throw new ApiError(`HTTP ${response.status}: ${response.statusText}`); } - if (!response.body) { - throw new Error('No response body'); + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Failed to get response reader'); } - const contentType = response.headers.get('content-type'); - if (!contentType || (!contentType.includes('ndjson') && !contentType.includes('json'))) { - throw new Error('Invalid response format'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let malformedCount = 0; - const maxMalformed = 50; - try { + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; + buffer = lines.pop() || ''; for (const line of lines) { - if (!line.trim()) continue; + if (line.trim() === '') continue; try { - const parsed = JSON.parse(line) as Record; - this.throwIfOllamaError(parsed); - - const message = this.toOllamaMessage(parsed.message); - if (!message) { - continue; + const data = JSON.parse(line); + if (data.message) { + yield data.message as OllamaMessage; } - - malformedCount = 0; - yield message; - } catch (error) { - if (error instanceof Error && error.message.startsWith('Ollama error:')) { - throw error; - } - - malformedCount++; - if (malformedCount > maxMalformed) { - throw new Error('Too many malformed chunks in stream'); - } - - Logger.warn( - `Skipped malformed chunk: ${line.substring(0, 80)}... - ${(error as Error).message}`, - 'ollama-client' - ); + } catch (e) { + // Ignore malformed JSON lines } } } - if (buffer.trim()) { + // Process any remaining buffer + if (buffer.trim() !== '') { try { - const parsed = JSON.parse(buffer) as Record; - this.throwIfOllamaError(parsed); - - const message = this.toOllamaMessage(parsed.message); - if (message) { - yield message; + const data = JSON.parse(buffer); + if (data.message) { + yield data.message as OllamaMessage; } - } catch (error) { - if (error instanceof Error && error.message.startsWith('Ollama error:')) { - throw error; - } - Logger.warn( - `Failed to parse final chunk: ${buffer.substring(0, 80)}...`, - 'ollama-client' - ); + } catch (e) { + // Ignore malformed JSON lines } } } finally { reader.releaseLock(); } + } catch (error) { + if (attempt < this.maxRetries && !(error instanceof ApiError)) { + const retryTimeout = Math.pow(2, attempt) * 1000; + await new Promise((resolve) => setTimeout(resolve, retryTimeout)); + yield* this.streamChatWithRetry(messages, tools, attempt + 1); + } else { + // Check if signal was aborted before retrying + if (controller.signal.aborted) { + throw new Error('Stream cancelled by user'); + } + throw error; + } } finally { - // Abort the local controller to release underlying fetch resources if not already aborted - if (!controller.signal.aborted) { - controller.abort(); - } - // Clean up the reference only if this is still the current stream - if (this.currentStreamController === controller) { - this.currentStreamController = null; - } + controller.abort(); + this.currentStreamController = null; } } @@ -267,87 +202,49 @@ export class OllamaClient { ): Promise { // Create a local controller for this request instead of using the instance variable const controller = new AbortController(); + this.currentStreamController = controller; + try { const response = await this.fetchFn(`${this.baseURL}/api/chat`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: this.model, - messages, - tools, - stream: false, + messages: messages, + tools: tools, }), signal: controller.signal, }); if (!response.ok) { - if (response.status >= 500 && attempt < this.maxRetries) { - const retryDelay = Math.pow(2, attempt) * 100; - Logger.warn( - `Network error (status ${response.status}), retrying in ${retryDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`, - 'ollama-client' - ); - const retryTimeout = new Promise((resolve) => setTimeout(resolve, retryDelay)); - const abortListener = () => { - Logger.info('Retry aborted by user', 'ollama-client'); - }; - const signal = controller.signal; - if (signal) { - signal.addEventListener('abort', abortListener); - try { - await Promise.race([ - retryTimeout, - new Promise((resolve) => { - signal.addEventListener('abort', () => resolve(), { - once: true, - }); - }), - ]); - } finally { - signal.removeEventListener('abort', abortListener); - } - // Check if signal was aborted before retrying - if (signal.aborted) { - throw new Error('Stream cancelled by user'); - } - } else { - await retryTimeout; - } - return this.chatWithRetry(messages, tools, attempt + 1); + throw new ApiError(`HTTP ${response.status}: ${response.statusText}`); + } + + const data: OllamaChatResponse = await response.json(); + if (data.error) { + throw new ApiError(data.error); + } + + if (!data.message) { + throw new Error('No message in response'); + } + + return data.message as OllamaMessage; + } catch (error) { + if (attempt < this.maxRetries && !(error instanceof ApiError)) { + const retryTimeout = Math.pow(2, attempt) * 1000; + await new Promise((resolve) => setTimeout(resolve, retryTimeout)); + return this.chatWithRetry(messages, tools, attempt + 1); + } else { + // Check if signal was aborted before retrying + if (controller.signal.aborted) { + throw new Error('Stream cancelled by user'); } - throw new ApiError(`Ollama API error: ${response.status}`, response.status); + throw error; } - - const data = (await response.json()) as OllamaChatResponse; - return ( - this.toOllamaMessage(data.message) ?? { role: 'assistant', content: '', tool_calls: [] } - ); } finally { - // Abort the local controller to release underlying fetch resources if not already aborted - if (!controller.signal.aborted) { - controller.abort(); - } + controller.abort(); + this.currentStreamController = null; } } - - private throwIfOllamaError(parsed: Record): void { - if (parsed.error) { - throw new Error(`Ollama error: ${String(parsed.error)}`); - } - } - - private toOllamaMessage(value: unknown): OllamaMessage | null { - if (!value || typeof value !== 'object') { - return null; - } - - const record = value as Partial; - return { - role: record.role ?? 'assistant', - content: typeof record.content === 'string' ? record.content : '', - tool_calls: record.tool_calls ?? [], - }; - } } diff --git a/src/semantic-cache.ts b/src/semantic-cache.ts index b95f286..b752aee 100644 --- a/src/semantic-cache.ts +++ b/src/semantic-cache.ts @@ -13,7 +13,9 @@ export class SemanticCacheService { constructor(ollamaURL: string, config: CacheConfig) { this.ollamaURL = ollamaURL.replace(/\/+$/, ''); this.config = config; - this.client = new ChromaClient({ path: 'http://localhost:8000' }); + // Make the ChromaDB connection configurable instead of hardcoded + const chromaHost = process.env.CHROMA_HOST || 'http://localhost:8000'; + this.client = new ChromaClient({ path: chromaHost }); } async initialize() { @@ -94,8 +96,17 @@ export class SemanticCacheService { const embedding = await this.getEmbedding(prompt); if (!embedding.length) return; + // Generate UUID fallback for environments without crypto API + let id: string; + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + id = crypto.randomUUID(); + } else { + // Fallback for environments without crypto API + id = 'uuid-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + } + await this.collection.add({ - ids: [crypto.randomUUID()], + ids: [id], embeddings: [embedding], metadatas: [{ fullResponse: response }], }); diff --git a/src/vault-indexer.ts b/src/vault-indexer.ts index 5626bf3..21f0d8e 100644 --- a/src/vault-indexer.ts +++ b/src/vault-indexer.ts @@ -6,29 +6,17 @@ import { IndexingPipeline } from './indexing-pipeline/pipeline'; interface Cache { get(key: string): Promise; - put(key: string, value: string): Promise; - clear(): Promise; } class InMemoryCache implements Cache { - private store: Map; + private store: Map = new Map(); - constructor() { - this.store = new Map(); + async get(key: string): Promise { + return this.store.get(key) || null; } - get(key: string): Promise { - return Promise.resolve(this.store.get(key) || null); - } - - put(key: string, value: string): Promise { + async set(key: string, value: string): Promise { this.store.set(key, value); - return Promise.resolve(); - } - - clear(): Promise { - this.store.clear(); - return Promise.resolve(); } } @@ -38,9 +26,16 @@ interface VaultFile { path: string; } -interface VaultLike { - getMarkdownFiles(): VaultFile[]; - read(file: VaultFile): Promise; +interface TokenizedContent { + tokens: string[]; + headings: string[]; + frontmatter: Record; + firstParagraph?: string; +} + +interface ScoreResult { + score: number; + matchedFields: string[]; } class VaultIndexer { @@ -64,58 +59,15 @@ class VaultIndexer { return []; } - if (!this.vault) { - throw new Error('Vault-like object not provided to VaultIndexer'); + const files = await this.vault?.getMarkdownFiles(); + if (!files) { + return []; } - const cacheKey = `query:${query.trim()}:limit:${limit}`; - if (this.cache) { - let cachedResults; - try { - cachedResults = await this.cache.get(cacheKey); - } catch { - // Ignore cache retrieval errors and continue with normal processing - cachedResults = null; - } - if (cachedResults) { - try { - const parsedResults = JSON.parse(cachedResults) as VaultIndexEntry[]; - return parsedResults.slice(0, limit); - } catch { - // Ignore cache parse errors and continue with normal processing - } - } - } - - const queryTokens = this.tokenize(query.trim()); - const vault = this.vault; - const allFiles = vault.getMarkdownFiles(); - const results = await this.processFilesInBatches(vault, allFiles, queryTokens); - - const filteredResults = results.sort((a, b) => b.score - a.score).slice(0, limit); - - if (this.cache) { - try { - await this.cache.put(cacheKey, JSON.stringify(filteredResults)); - } catch (error) { - Logger.warn( - `Failed to cache results for query "${query}": ${error instanceof Error ? error.message : String(error)}`, - 'vault-indexer' - ); - } - } - - return filteredResults; - } - - private async processFilesInBatches( - vault: VaultLike, - files: VaultFile[], - queryTokens: string[] - ): Promise { + const queryTokens = this.tokenizeQuery(query); const batchSize = 10; - const results: VaultIndexEntry[] = []; - const seenPaths = new Set(); + const results = []; + const seenPaths = new Set(); // Read all files first to get their content const fileContents: Record = {}; @@ -157,10 +109,12 @@ class VaultIndexer { } } - return results; + // Sort by score and return top results + results.sort((a, b) => b.score - a.score); + return results.slice(0, limit); } - private tokenize(text: string): string[] { + private tokenizeQuery(query: string): string[] { const stopWords = new Set([ 'the', 'a', @@ -187,18 +141,57 @@ class VaultIndexer { 'this', 'these', 'those', + 'from', + 'up', + 'out', + 'off', + 'over', + 'under', + 'again', + 'further', + 'then', + 'once', + 'here', + 'there', + 'when', + 'where', + 'why', + 'how', + 'all', + 'any', + 'both', + 'each', + 'few', + 'more', + 'most', + 'other', + 'some', + 'such', + 'no', + 'nor', + 'not', + 'only', + 'own', + 'same', + 'so', + 'than', + 'too', + 'very', + 'just', + 'now', ]); - return text + + return query .toLowerCase() .split(/\W+/) .filter((token) => token.length > 1 && !stopWords.has(token)); } - private tokenizeContent(content: string) { + private tokenizeContent(content: string): TokenizedContent { // This is a simplified version - the pipeline will handle full extraction const tokens: string[] = []; const headings: string[] = []; - const frontmatter: any = {}; + const frontmatter: Record = {}; let firstParagraph: string | undefined; const frontmatterMatch = content.match(/^---(.*?)---/s); @@ -218,52 +211,64 @@ class VaultIndexer { if (value) { frontmatter.tags = value; } + } else { + // Store other frontmatter fields as-is + frontmatter[key.trim()] = value; } } } catch { - Logger.warn('Failed to parse frontmatter', 'vault-indexer'); + // If frontmatter parsing fails, continue with empty frontmatter } } + // Extract headings const headingMatches = content.match(/^#{1,6} (.*?)$/gm); if (headingMatches) { headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, ''))); } + // Extract first paragraph 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 }; + return { + tokens, + headings, + frontmatter, + firstParagraph, + }; } - private calculateWeightedScore( - tokenized: any, - queryTokens: string[] - ): { score: number; matchedFields: string[] } { + private exactMatch(value: string | string[], queryToken: string): boolean { + if (Array.isArray(value)) { + return value.some((item) => item.toLowerCase() === queryToken.toLowerCase()); + } + return value.toLowerCase() === queryToken.toLowerCase(); + } + + private calculateWeightedScore(tokenized: TokenizedContent, queryTokens: string[]): ScoreResult { let totalScore = 0; const matchedTokens: Set = new Set(); for (const queryToken of queryTokens) { let tokenScore = 0; - const stemmed = this.stemToken(queryToken); let matched = false; - if ( - tokenized.frontmatter?.title && - this.exactMatch(tokenized.frontmatter.title, queryToken) - ) { + if (tokenized.tokens.includes(queryToken)) { tokenScore += 3; matched = true; + } else if ( + tokenized.headings.some((heading) => + heading.toLowerCase().includes(queryToken.toLowerCase()) + ) + ) { + tokenScore += 2; + matched = true; + } else if (tokenized.firstParagraph?.toLowerCase().includes(queryToken.toLowerCase())) { + tokenScore += 1.5; + matched = true; } if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, queryToken)) { @@ -271,46 +276,23 @@ class VaultIndexer { 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); } } + // Bonus points for matching multiple tokens in a single heading + const headingMatches = tokenized.headings.filter((heading) => + heading.toLowerCase().includes(queryTokens.join(' ').toLowerCase()) + ); + totalScore += headingMatches.length * 2; + return { score: totalScore, matchedFields: Array.from(matchedTokens), }; } - - private stemToken(token: string): string { - // Improved stemmer that handles edge cases - if (token.length <= 3) return token; // Don't stem very short tokens - if (token.endsWith('s')) return token.slice(0, -1); - if (token.endsWith('ed') && token.length > 4) return token.slice(0, -2); // Don't stem 3-letter words ending in ed - if (token.endsWith('ing') && token.length > 5) return token.slice(0, -3); // Don't stem 4-letter words ending in ing - return token; - } - - private exactMatch(content: string, token: string): boolean { - const stemmedToken = this.stemToken(token); - return content.toLowerCase().includes(stemmedToken); - } } export { VaultIndexer, Cache, InMemoryCache, IndexingPipeline };