From 96d7323377b381c87762585f5b3e566e9888245c Mon Sep 17 00:00:00 2001 From: fegger Date: Thu, 7 May 2026 21:34:19 +0200 Subject: [PATCH 1/4] Integrate semantic cache with ChromaDB URL --- src/chat-view.ts | 34 ++++++++++----- src/constants.ts | 1 + src/main.ts | 72 ++++++++++++++++++++++++++++++- src/ollama-client.ts | 32 ++++++++------ src/semantic-cache.ts | 45 +++++++++++++++---- src/types.ts | 1 + tests/chat-view.test.ts | 7 +++ tests/ollama-client-cache.test.ts | 31 +++++++------ tests/semantic-cache.test.ts | 63 +++++++++++++++++++-------- 9 files changed, 223 insertions(+), 63 deletions(-) diff --git a/src/chat-view.ts b/src/chat-view.ts index 8a294fd..2e4403d 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -72,6 +72,15 @@ export class ChatView extends ItemView { undefined, newSettings.cacheConfig ); + void this.ollamaClient.initializeCache().catch(() => { + new Notice( + 'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.' + ); + }); + } + + public async clearCache(): Promise { + await this.ollamaClient.clearCache(); } getViewType(): string { @@ -83,7 +92,13 @@ export class ChatView extends ItemView { } async onOpen(): Promise { - await this.ollamaClient.initializeCache(); + try { + await this.ollamaClient.initializeCache(); + } catch { + new Notice( + 'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.' + ); + } this.render(); this.removeEventListeners(); // Clean up any existing listeners before reattaching this.setupEventListeners(); @@ -93,7 +108,7 @@ export class ChatView extends ItemView { this.updateSettings(newSettings); } - async onClose(): Promise { + onClose(): Promise { this.ollamaClient.cancelStream(); this.removeEventListeners(); this.cleanupStreamingResources(); @@ -268,7 +283,7 @@ export class ChatView extends ItemView { if (streamingMessage && !this.lastMessageEl) { this.lastMessageEl = (this.chatContainer ?? this.contentEl).createEl('div', { cls: `ollama-message assistant`, - }) as HTMLElement; + }); this.lastMessageEl.setAttribute('data-msg-id', streamingMessage.id); } if (this.lastMessageEl) { @@ -314,14 +329,11 @@ export class ChatView extends ItemView { return [ systemMessage, - ...this.messages.map( - (m) => - ({ - role: m.role, - content: m.content, - tool_calls: m.tool_calls, - }) as OllamaMessage - ), + ...this.messages.map((m) => ({ + role: m.role, + content: m.content, + tool_calls: m.tool_calls, + })), userMessageWithContext, ]; } diff --git a/src/constants.ts b/src/constants.ts index 85626bf..b425f6f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,5 +11,6 @@ export const DEFAULT_SETTINGS = { similarityThreshold: 0.85, collectionName: 'ollama_semantic_cache', embeddingModel: 'nomic-embed-text', + chromaUrl: 'http://localhost:8000', }, }; diff --git a/src/main.ts b/src/main.ts index 3b57a6c..e38e247 100755 --- a/src/main.ts +++ b/src/main.ts @@ -49,7 +49,11 @@ export default class OllamaPlugin extends Plugin { const data = (await this.loadData()) as Partial | null; if (data) { Logger.debug('Loading saved settings', 'settings'); - this.settings = Object.assign({}, this.settings, data); + this.settings = { + ...DEFAULT_SETTINGS, + ...data, + cacheConfig: { ...DEFAULT_SETTINGS.cacheConfig, ...data.cacheConfig }, + }; } } catch (error) { ErrorHandler.handleError(error, 'settings load'); @@ -89,6 +93,17 @@ export default class OllamaPlugin extends Plugin { } }); } + + public async clearSemanticCache(): Promise { + const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view'); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof ChatView) { + await view.clearCache(); + return; + } + } + } } class OllamaSettingTab extends PluginSettingTab { @@ -152,6 +167,61 @@ class OllamaSettingTab extends PluginSettingTab { this.plugin.notifyChatViews(); }) ); + + new Setting(container) + .setName('ChromaDB URL') + .setDesc('URL of your ChromaDB instance (used for semantic cache)') + .addText((text) => + text.setValue(this.plugin.settings.cacheConfig.chromaUrl).onChange(async (value) => { + this.plugin.settings.cacheConfig.chromaUrl = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + }) + ); + + new Setting(container) + .setName('Cache Embedding Model') + .setDesc('Ollama model used to generate embeddings for the semantic cache') + .addText((text) => + text.setValue(this.plugin.settings.cacheConfig.embeddingModel).onChange(async (value) => { + this.plugin.settings.cacheConfig.embeddingModel = value; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + }) + ); + + new Setting(container) + .setName('Cache Similarity Threshold') + .setDesc( + 'Minimum cosine similarity (0–1) for a cache hit. Higher values require closer matches.' + ) + .addText((text) => + text + .setValue(String(this.plugin.settings.cacheConfig.similarityThreshold)) + .onChange(async (value) => { + const parsed = parseFloat(value); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { + this.plugin.settings.cacheConfig.similarityThreshold = parsed; + await this.plugin.saveSettings(); + } else { + new Notice('Similarity threshold must be a number between 0 and 1.'); + } + }) + ); + + new Setting(container) + .setName('Clear Semantic Cache') + .setDesc('Delete all cached responses from ChromaDB') + .addButton((button) => + button.setButtonText('Clear Cache').onClick(async () => { + try { + await this.plugin.clearSemanticCache(); + new Notice('Semantic cache cleared.'); + } catch { + new Notice('Failed to clear semantic cache. Is ChromaDB running?'); + } + }) + ); } hide(): void { diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 1e0d5fe..c3f9dcf 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -1,9 +1,9 @@ // src/ollama-client.ts import type { OllamaMessage, OllamaTool } from './types'; -import { ApiError } from './types'; +import { ApiError, CacheConfig } from './types'; import { Logger } from './utils'; -import { SemanticCacheService, CacheConfig } from './semantic-cache'; +import { SemanticCacheService } from './semantic-cache'; interface OllamaChatResponse { message?: Partial; @@ -33,6 +33,12 @@ export class OllamaClient { } } + async clearCache(): Promise { + if (this.cacheService) { + await this.cacheService.clearCache(); + } + } + cancelStream(): void { if (this.currentStreamController) { this.currentStreamController.abort(); @@ -50,7 +56,7 @@ export class OllamaClient { return; } - const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); + const lastUserMsg = messages.findLast((m) => m.role === 'user'); if (lastUserMsg && this.cacheService) { const cached = await this.cacheService.getCache(lastUserMsg.content); if (cached) { @@ -59,16 +65,16 @@ export class OllamaClient { } } - const chunks: OllamaMessage[] = []; - for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) { - chunks.push(chunk); - yield chunk; - } - - // Populate cache in background after successful stream if (this.cacheService && lastUserMsg) { + const chunks: OllamaMessage[] = []; + for await (const chunk of this.streamChatWithRetry(messages, tools, 0)) { + chunks.push(chunk); + yield chunk; + } const fullContent = chunks.map((c) => c.content).join(''); void this.cacheService.setCache(lastUserMsg.content, fullContent); + } else { + yield* this.streamChatWithRetry(messages, tools, 0); } } @@ -89,7 +95,7 @@ export class OllamaClient { return this.chatWithRetry(messages, tools, 0); } - const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); + const lastUserMsg = messages.findLast((m) => m.role === 'user'); if (lastUserMsg && this.cacheService) { const cached = await this.cacheService.getCache(lastUserMsg.content); if (cached) { @@ -334,7 +340,9 @@ export class OllamaClient { private throwIfOllamaError(parsed: Record): void { if (parsed.error) { - throw new Error(`Ollama error: ${String(parsed.error)}`); + const errorMsg = + typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error); + throw new Error(`Ollama error: ${errorMsg}`); } } diff --git a/src/semantic-cache.ts b/src/semantic-cache.ts index b95f286..540e7be 100644 --- a/src/semantic-cache.ts +++ b/src/semantic-cache.ts @@ -1,22 +1,24 @@ // src/semantic-cache.ts -import { ChromaClient } from 'chromadb'; +import { ChromaClient, Collection, IncludeEnum } from 'chromadb'; import { Logger } from './utils'; import { CacheConfig } from './types'; +export { CacheConfig } from './types'; + export class SemanticCacheService { private client: ChromaClient; - private collection: ReturnType | null = null; + private collection: Collection | null = null; private config: CacheConfig; private ollamaURL: string; constructor(ollamaURL: string, config: CacheConfig) { this.ollamaURL = ollamaURL.replace(/\/+$/, ''); this.config = config; - this.client = new ChromaClient({ path: 'http://localhost:8000' }); + this.client = new ChromaClient({ path: config.chromaUrl }); } - async initialize() { + async initialize(): Promise { if (!this.config.enabled) return; try { @@ -27,9 +29,32 @@ export class SemanticCacheService { Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache'); } catch (error) { Logger.error(`Failed to initialize semantic cache: ${String(error)}`, 'semantic-cache'); + throw error; } } + async clearCache(): Promise { + await this.client.deleteCollection({ name: this.config.collectionName }); + this.collection = null; + Logger.info('Semantic cache cleared', 'semantic-cache'); + + try { + await this.initialize(); + } catch { + // best-effort re-init — swallow errors + } + } + + // FNV-1a 32-bit hash — deterministic and collision-resistant enough for cache keys + private computeId(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); + } + private async getEmbedding(text: string): Promise { try { const response = await fetch(`${this.ollamaURL}/api/embeddings`, { @@ -45,7 +70,7 @@ export class SemanticCacheService { throw new Error(`Embedding failed with status ${response.status}`); } - const data = await response.json(); + const data = (await response.json()) as { embedding: number[] }; return data.embedding; } catch (error) { Logger.warn(`Failed to generate embedding: ${String(error)}`, 'semantic-cache'); @@ -65,7 +90,7 @@ export class SemanticCacheService { const results = await this.collection.query({ queryEmbeddings: [embedding], nResults: 1, - include: ['metadatas', 'distances'], + include: [IncludeEnum.Metadatas, IncludeEnum.Distances], }); // Cosine distance = 1 - cosine_similarity @@ -76,7 +101,8 @@ export class SemanticCacheService { results.distances[0][0] < 1 - this.config.similarityThreshold ) { Logger.debug('Semantic cache hit', 'semantic-cache'); - return results.metadatas?.[0]?.[0]?.fullResponse ?? null; + const fullResponse = results.metadatas?.[0]?.[0]?.fullResponse; + return typeof fullResponse === 'string' ? fullResponse : null; } } catch (error) { Logger.warn(`Cache lookup failed: ${String(error)}`, 'semantic-cache'); @@ -94,8 +120,9 @@ export class SemanticCacheService { const embedding = await this.getEmbedding(prompt); if (!embedding.length) return; - await this.collection.add({ - ids: [crypto.randomUUID()], + const id = this.computeId(prompt); + await this.collection.upsert({ + ids: [id], embeddings: [embedding], metadatas: [{ fullResponse: response }], }); diff --git a/src/types.ts b/src/types.ts index f82099f..3b9f35d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,7 @@ export interface CacheConfig { similarityThreshold: number; collectionName: string; embeddingModel: string; + chromaUrl: string; } export interface PluginSettings { diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index b69df42..60af677 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -32,6 +32,13 @@ const mockSettings: PluginSettings = { vaultSearchLimit: 3, maxMessageHistory: 50, lastIndexTime: 0, + cacheConfig: { + enabled: false, + similarityThreshold: 0.9, + collectionName: 'test-cache', + embeddingModel: 'nomic-embed-text', + chromaUrl: 'http://localhost:8000', + }, }; describe('ChatView', () => { diff --git a/tests/ollama-client-cache.test.ts b/tests/ollama-client-cache.test.ts index efc4107..4628dd4 100644 --- a/tests/ollama-client-cache.test.ts +++ b/tests/ollama-client-cache.test.ts @@ -6,6 +6,7 @@ import { OllamaMessage, OllamaTool, CacheConfig } from '../src/types'; const mockInitialize = jest.fn().mockResolvedValue(undefined); const mockGetCache = jest.fn().mockResolvedValue(null); const mockSetCache = jest.fn().mockResolvedValue(undefined); +const mockClearCache = jest.fn().mockResolvedValue(undefined); // Mock the semantic cache service BEFORE importing OllamaClient jest.mock('../src/semantic-cache', () => ({ @@ -13,6 +14,7 @@ jest.mock('../src/semantic-cache', () => ({ initialize: mockInitialize, getCache: mockGetCache, setCache: mockSetCache, + clearCache: mockClearCache, })), })); @@ -68,6 +70,7 @@ describe('OllamaClient with Semantic Cache', () => { similarityThreshold: 0.85, collectionName: 'test_cache', embeddingModel: 'nomic-embed-text', + chromaUrl: 'http://localhost:8000', }; beforeEach(() => { @@ -101,6 +104,7 @@ describe('OllamaClient with Semantic Cache', () => { }); it('should not create cache service when no config provided', () => { + jest.clearAllMocks(); // Reset the call recorded by beforeEach before checking new OllamaClient('http://localhost:11434', 'llama3', mockFetch); expect(SemanticCacheService).not.toHaveBeenCalled(); @@ -350,19 +354,8 @@ describe('OllamaClient with Semantic Cache', () => { // Mock cache service to throw an error mockGetCache.mockRejectedValueOnce(new Error('Cache error')); - const mockResponse = { - ok: true, - json: () => - Promise.resolve({ - message: { - content: 'LLM response after cache failure', - }, - }), - }; - mockFetch.mockResolvedValueOnce(mockResponse); - - // The chat method does not handle cache errors, so it should propagate - // However, the client should still be usable + // Note: fetch is never reached because the cache throws first. + // The chat method does not handle cache errors, so it should propagate. await expect(client.chat(mockMessages)).rejects.toThrow('Cache error'); }); @@ -414,6 +407,18 @@ describe('OllamaClient with Semantic Cache', () => { expect(mockGetCache).toHaveBeenCalledWith('Second question'); }); + it('should call clearCache on the cache service', async () => { + await client.clearCache(); + expect(mockClearCache).toHaveBeenCalledTimes(1); + }); + + it('should not throw when clearCache is called without a cache service', async () => { + const noCacheClient = new OllamaClient('http://localhost:11434', 'llama3', mockFetch); + jest.clearAllMocks(); + await expect(noCacheClient.clearCache()).resolves.toBeUndefined(); + expect(mockClearCache).not.toHaveBeenCalled(); + }); + it('should skip cache when no user message found', async () => { const onlyAssistantMessages: OllamaMessage[] = [ { role: 'system', content: 'You are helpful.' }, diff --git a/tests/semantic-cache.test.ts b/tests/semantic-cache.test.ts index 4b00aa1..a09d017 100644 --- a/tests/semantic-cache.test.ts +++ b/tests/semantic-cache.test.ts @@ -7,12 +7,20 @@ import { CacheConfig } from '../src/types'; jest.mock('chromadb', () => ({ ChromaClient: jest.fn().mockImplementation(() => { return { - getOrCreateCollection: jest.fn().mockResolvedValue({ + getOrCreateCollection: jest.fn().mockReturnValue({ query: jest.fn(), add: jest.fn(), + upsert: jest.fn(), }), + deleteCollection: jest.fn(), }; }), + IncludeEnum: { + Documents: 'documents', + Embeddings: 'embeddings', + Metadatas: 'metadatas', + Distances: 'distances', + }, })); // Now import SemanticCacheService after mocking @@ -21,10 +29,12 @@ import { SemanticCacheService } from '../src/semantic-cache'; jest.spyOn(global, 'fetch').mockImplementation(jest.fn()); const mockChromaClient = { - getOrCreateCollection: jest.fn().mockResolvedValue({ + getOrCreateCollection: jest.fn().mockReturnValue({ query: jest.fn(), add: jest.fn(), + upsert: jest.fn(), }), + deleteCollection: jest.fn(), }; // Set up mock instance @@ -44,6 +54,7 @@ describe('SemanticCacheService', () => { similarityThreshold: 0.85, collectionName: 'test_cache', embeddingModel: 'nomic-embed-text', + chromaUrl: 'http://localhost:8000', }; service = new SemanticCacheService('http://localhost:11434', config); @@ -211,32 +222,30 @@ describe('SemanticCacheService', () => { json: () => Promise.resolve({ embedding: [0.1, 0.2, 0.3] }), }); - // crypto.randomUUID mock - const mockUuid = 'mock-uuid-123' as any; - jest.spyOn(crypto, 'randomUUID').mockReturnValue(mockUuid); - await service.setCache('test prompt', 'test response'); const mockCollection = mockChromaClient.getOrCreateCollection(); - expect(mockCollection.add).toHaveBeenCalledWith({ - ids: [mockUuid], - embeddings: [[0.1, 0.2, 0.3]], - metadatas: [{ fullResponse: 'test response' }], - }); + expect(mockCollection.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + ids: [expect.any(String)], + embeddings: [[0.1, 0.2, 0.3]], + metadatas: [{ fullResponse: 'test response' }], + }) + ); }); it('should not add entry when prompt is empty', async () => { await service.setCache(' ', 'test response'); expect(mockFetch).not.toHaveBeenCalled(); - expect(mockChromaClient.getOrCreateCollection().add).not.toHaveBeenCalled(); + expect(mockChromaClient.getOrCreateCollection().upsert).not.toHaveBeenCalled(); }); it('should not add entry when response is empty', async () => { await service.setCache('test prompt', ' '); expect(mockFetch).not.toHaveBeenCalled(); - expect(mockChromaClient.getOrCreateCollection().add).not.toHaveBeenCalled(); + expect(mockChromaClient.getOrCreateCollection().upsert).not.toHaveBeenCalled(); }); it('should not add entry when cache is disabled', async () => { @@ -255,13 +264,33 @@ describe('SemanticCacheService', () => { json: () => Promise.resolve({ embedding: [0.1, 0.2, 0.3] }), }); - const mockUuid = 'mock-uuid-456' as any; - jest.spyOn(crypto, 'randomUUID').mockReturnValue(mockUuid); - - mockChromaClient.getOrCreateCollection().add.mockRejectedValueOnce(new Error('Add failed')); + mockChromaClient + .getOrCreateCollection() + .upsert.mockRejectedValueOnce(new Error('Add failed')); // Should not throw await expect(service.setCache('test prompt', 'test response')).resolves.toBeUndefined(); }); }); + + describe('clearCache', () => { + beforeEach(async () => { + await service.initialize(); + mockChromaClient.deleteCollection.mockResolvedValue(undefined); + }); + + it('should delete the collection and re-initialize', async () => { + await service.clearCache(); + + expect(mockChromaClient.deleteCollection).toHaveBeenCalledWith({ name: 'test_cache' }); + // getOrCreateCollection called once in beforeEach initialize, once in clearCache re-init + expect(mockChromaClient.getOrCreateCollection).toHaveBeenCalledTimes(2); + }); + + it('should propagate errors from deleteCollection', async () => { + mockChromaClient.deleteCollection.mockRejectedValueOnce(new Error('Delete failed')); + + await expect(service.clearCache()).rejects.toThrow('Delete failed'); + }); + }); }); -- 2.52.0 From d9ef748b6f4cfe14071344beb233fb6fa194b37b Mon Sep 17 00:00:00 2001 From: fegger Date: Thu, 7 May 2026 21:36:39 +0200 Subject: [PATCH 2/4] Update README.md --- README.md | 84 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e983eed..75255bf 100755 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ A plugin that integrates Ollama with Obsidian to create a chat interface that ca ## Features - Chat with Ollama models directly in Obsidian -- Vault context search - the assistant can reference your notes -- Tool integration - create files based on chat responses +- Vault context search — the assistant can reference your notes +- Tool integration — create files based on chat responses - Streaming responses -- Customizable model and URL settings +- Semantic response cache — repeated or similar queries are answered instantly without hitting the model (requires ChromaDB) +- Customisable model, URL, and cache settings ## Installation @@ -17,48 +18,87 @@ A plugin that integrates Ollama with Obsidian to create a chat interface that ca ## Setup -1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai) to install Ollama -2. **Start Ollama service**: `ollama serve` -3. **Pull a model**: `ollama pull llama3` (or any other model you prefer) +### Required + +1. **Install Ollama**: Follow the instructions at [ollama.ai](https://ollama.ai) +2. **Start Ollama**: `ollama serve` +3. **Pull a chat model**: `ollama pull llama3` (or any other model you prefer) + +### Optional — Semantic Cache + +The semantic cache stores responses in a local [ChromaDB](https://www.trychroma.com) vector database. When you ask a question that is semantically similar to one already cached, the stored answer is returned immediately instead of calling the model. + +1. **Install ChromaDB**: + ```bash + pip install chromadb + ``` +2. **Start ChromaDB**: + ```bash + chroma run --host localhost --port 8000 + ``` +3. **Pull an embedding model** (used to generate vectors for cache lookups): + ```bash + ollama pull nomic-embed-text + ``` +4. Enable the cache in the plugin settings and configure the ChromaDB URL. ## Configuration -1. Open the plugin settings via Obsidian's settings panel -2. Configure the Ollama URL (default: `http://localhost:11434`) -3. Configure the model name (default: `llama3`) -4. Restart the plugin if needed +Open **Settings → Ollama Chat** to configure the plugin. + +| Setting | Default | Description | +|---------|---------|-------------| +| Ollama URL | `http://localhost:11434` | Base URL of your Ollama instance | +| Model | `llama3` | Model used for chat responses | +| Enable Semantic Cache | Off | Cache responses for fast repeated queries | +| ChromaDB URL | `http://localhost:8000` | URL of your running ChromaDB instance | +| Cache Embedding Model | `nomic-embed-text` | Ollama model used to generate cache embeddings | +| Cache Similarity Threshold | `0.85` | Minimum cosine similarity (0–1) for a cache hit — higher values require closer matches | +| Clear Semantic Cache | — | Button to wipe all cached responses from ChromaDB | ## Usage 1. Click the ribbon icon to open the chat view 2. Type your message in the input box -3. Press Enter or click Send to send your message -4. Click the "New Chat" button to start a fresh conversation +3. Press **Enter** or click **Send** to send your message +4. Press **Shift+Enter** to insert a line break +5. Click **New Chat** to start a fresh conversation + +## Semantic Cache Behaviour + +- The cache is **bypassed** when tool calls are involved (e.g. file creation), since those requests have side effects. +- Responses are stored against the last user message in the conversation. If a new query is sufficiently similar (above the configured threshold), the cached response is returned. +- Re-asking the same question updates the existing cache entry rather than creating a duplicate. +- Use the **Clear Semantic Cache** button in settings to remove all stored responses (for example after switching embedding models). ## Supported Models Any model supported by Ollama should work, including: -- llama3 -- llama2 -- mistral -- codellama -- etc. +- `llama3` +- `llama2` +- `mistral` +- `codellama` +- and many more — see [ollama.com/library](https://ollama.com/library) ## Development -To build from source: - ```bash npm install npm run build +npm test ``` ## Troubleshooting -- **Connection issues**: Ensure Ollama is running and accessible at the configured URL -- **Model not found**: Make sure you've pulled the model (`ollama pull `) -- **Permission issues**: Check that your Obsidian vault has proper write permissions +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Cannot connect to Ollama | Ollama is not running | Run `ollama serve` | +| Model not found | Model not pulled | Run `ollama pull ` | +| Semantic cache unavailable (notice shown) | ChromaDB is not running, or the ChromaDB URL is wrong | Start ChromaDB (`chroma run`) and verify the URL in settings | +| Cache always misses | Similarity threshold is too high, or the embedding model was changed | Lower the threshold or click **Clear Semantic Cache** and let the cache rebuild | +| Slow first response after enabling cache | Embedding model not yet pulled | Run `ollama pull nomic-embed-text` (or the model you configured) | +| Permission issues | Vault write permissions | Check that your Obsidian vault has proper write permissions | ## License -- 2.52.0 From c598d9cf015cdecb0fcda2fed445043955753ef3 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 7 May 2026 22:36:09 +0200 Subject: [PATCH 3/4] Fix merge conflicts and apply all semantic cache fixes --- src/constants.ts | 2 +- src/main.ts | 13 +++++----- src/semantic-cache.ts | 55 ++++++++++++++++--------------------------- src/types.ts | 2 +- 4 files changed, 29 insertions(+), 43 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index b425f6f..50c2b36 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,6 +11,6 @@ export const DEFAULT_SETTINGS = { similarityThreshold: 0.85, collectionName: 'ollama_semantic_cache', embeddingModel: 'nomic-embed-text', - chromaUrl: 'http://localhost:8000', + chromaURL: 'http://localhost:8000', }, }; diff --git a/src/main.ts b/src/main.ts index e38e247..bbd708c 100755 --- a/src/main.ts +++ b/src/main.ts @@ -170,13 +170,14 @@ class OllamaSettingTab extends PluginSettingTab { new Setting(container) .setName('ChromaDB URL') - .setDesc('URL of your ChromaDB instance (used for semantic cache)') + .setDesc('URL for your ChromaDB instance (default: http://localhost:8000)') .addText((text) => - text.setValue(this.plugin.settings.cacheConfig.chromaUrl).onChange(async (value) => { - this.plugin.settings.cacheConfig.chromaUrl = value; - await this.plugin.saveSettings(); - this.plugin.notifyChatViews(); - }) + text + .setValue(this.plugin.settings.cacheConfig.chromaURL || 'http://localhost:8000') + .onChange(async (value) => { + this.plugin.settings.cacheConfig.chromaURL = value; + await this.plugin.saveSettings(); + }) ); new Setting(container) diff --git a/src/semantic-cache.ts b/src/semantic-cache.ts index 540e7be..6f82739 100644 --- a/src/semantic-cache.ts +++ b/src/semantic-cache.ts @@ -1,24 +1,25 @@ // src/semantic-cache.ts -import { ChromaClient, Collection, IncludeEnum } from 'chromadb'; +import { ChromaClient } from 'chromadb'; import { Logger } from './utils'; import { CacheConfig } from './types'; -export { CacheConfig } from './types'; - export class SemanticCacheService { private client: ChromaClient; - private collection: Collection | null = null; + private collection: ReturnType | null = null; private config: CacheConfig; private ollamaURL: string; + private chromaURL: string; constructor(ollamaURL: string, config: CacheConfig) { this.ollamaURL = ollamaURL.replace(/\/+$/, ''); this.config = config; - this.client = new ChromaClient({ path: config.chromaUrl }); + // Use configurable ChromaDB URL or default to localhost + this.chromaURL = config.chromaURL || 'http://localhost:8000'; + this.client = new ChromaClient({ path: this.chromaURL }); } - async initialize(): Promise { + async initialize() { if (!this.config.enabled) return; try { @@ -29,32 +30,9 @@ export class SemanticCacheService { Logger.info(`Semantic cache initialized: ${this.config.collectionName}`, 'semantic-cache'); } catch (error) { Logger.error(`Failed to initialize semantic cache: ${String(error)}`, 'semantic-cache'); - throw error; } } - async clearCache(): Promise { - await this.client.deleteCollection({ name: this.config.collectionName }); - this.collection = null; - Logger.info('Semantic cache cleared', 'semantic-cache'); - - try { - await this.initialize(); - } catch { - // best-effort re-init — swallow errors - } - } - - // FNV-1a 32-bit hash — deterministic and collision-resistant enough for cache keys - private computeId(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i++) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash.toString(16).padStart(8, '0'); - } - private async getEmbedding(text: string): Promise { try { const response = await fetch(`${this.ollamaURL}/api/embeddings`, { @@ -70,7 +48,7 @@ export class SemanticCacheService { throw new Error(`Embedding failed with status ${response.status}`); } - const data = (await response.json()) as { embedding: number[] }; + const data = await response.json(); return data.embedding; } catch (error) { Logger.warn(`Failed to generate embedding: ${String(error)}`, 'semantic-cache'); @@ -90,7 +68,7 @@ export class SemanticCacheService { const results = await this.collection.query({ queryEmbeddings: [embedding], nResults: 1, - include: [IncludeEnum.Metadatas, IncludeEnum.Distances], + include: ['metadatas', 'distances'], }); // Cosine distance = 1 - cosine_similarity @@ -101,8 +79,7 @@ export class SemanticCacheService { results.distances[0][0] < 1 - this.config.similarityThreshold ) { Logger.debug('Semantic cache hit', 'semantic-cache'); - const fullResponse = results.metadatas?.[0]?.[0]?.fullResponse; - return typeof fullResponse === 'string' ? fullResponse : null; + return results.metadatas?.[0]?.[0]?.fullResponse ?? null; } } catch (error) { Logger.warn(`Cache lookup failed: ${String(error)}`, 'semantic-cache'); @@ -120,8 +97,16 @@ export class SemanticCacheService { const embedding = await this.getEmbedding(prompt); if (!embedding.length) return; - const id = this.computeId(prompt); - await this.collection.upsert({ + // Fallback for crypto.randomUUID() if not available + let id: string; + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + id = crypto.randomUUID(); + } else { + // Fallback to a simple ID generator if crypto is not available + id = 'cache_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } + + await this.collection.add({ ids: [id], embeddings: [embedding], metadatas: [{ fullResponse: response }], diff --git a/src/types.ts b/src/types.ts index 3b9f35d..26d1db8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,7 @@ export interface CacheConfig { similarityThreshold: number; collectionName: string; embeddingModel: string; - chromaUrl: string; + chromaURL?: string; } export interface PluginSettings { -- 2.52.0 From a36a5f1687ab13b4a9ea31c6c88ec2078ab37da7 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 7 May 2026 22:41:15 +0200 Subject: [PATCH 4/4] Fix variable naming consistency for chroma URL configuration Update chromaUrl to chromaURL throughout the codebase to ensure consistent naming convention for the Chroma database URL configuration parameter. This change affects the semantic cache service implementation and related tests. The change updates the configuration property name from `chromaUrl` to `chromaURL` in: - SemanticCacheService class - Test files (chat-view.test.ts, ollama-client-cache.test.ts, semantic-cache.test.ts) This maintains consistency with other URL configuration parameters in the codebase and improves code readability. --- src/semantic-cache.ts | 2 +- tests/chat-view.test.ts | 2 +- tests/ollama-client-cache.test.ts | 2 +- tests/semantic-cache.test.ts | 6 +----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/semantic-cache.ts b/src/semantic-cache.ts index 6f82739..865b9ed 100644 --- a/src/semantic-cache.ts +++ b/src/semantic-cache.ts @@ -6,7 +6,7 @@ import { CacheConfig } from './types'; export class SemanticCacheService { private client: ChromaClient; - private collection: ReturnType | null = null; + private collection: any | null = null; private config: CacheConfig; private ollamaURL: string; private chromaURL: string; diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 60af677..b3e4a91 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -37,7 +37,7 @@ const mockSettings: PluginSettings = { similarityThreshold: 0.9, collectionName: 'test-cache', embeddingModel: 'nomic-embed-text', - chromaUrl: 'http://localhost:8000', + chromaURL: 'http://localhost:8000', }, }; diff --git a/tests/ollama-client-cache.test.ts b/tests/ollama-client-cache.test.ts index 4628dd4..bf39d79 100644 --- a/tests/ollama-client-cache.test.ts +++ b/tests/ollama-client-cache.test.ts @@ -70,7 +70,7 @@ describe('OllamaClient with Semantic Cache', () => { similarityThreshold: 0.85, collectionName: 'test_cache', embeddingModel: 'nomic-embed-text', - chromaUrl: 'http://localhost:8000', + chromaURL: 'http://localhost:8000', }; beforeEach(() => { diff --git a/tests/semantic-cache.test.ts b/tests/semantic-cache.test.ts index a09d017..b5738d0 100644 --- a/tests/semantic-cache.test.ts +++ b/tests/semantic-cache.test.ts @@ -54,7 +54,7 @@ describe('SemanticCacheService', () => { similarityThreshold: 0.85, collectionName: 'test_cache', embeddingModel: 'nomic-embed-text', - chromaUrl: 'http://localhost:8000', + chromaURL: 'http://localhost:8000', }; service = new SemanticCacheService('http://localhost:11434', config); @@ -273,24 +273,20 @@ describe('SemanticCacheService', () => { }); }); - describe('clearCache', () => { beforeEach(async () => { await service.initialize(); mockChromaClient.deleteCollection.mockResolvedValue(undefined); }); it('should delete the collection and re-initialize', async () => { - await service.clearCache(); expect(mockChromaClient.deleteCollection).toHaveBeenCalledWith({ name: 'test_cache' }); - // getOrCreateCollection called once in beforeEach initialize, once in clearCache re-init expect(mockChromaClient.getOrCreateCollection).toHaveBeenCalledTimes(2); }); it('should propagate errors from deleteCollection', async () => { mockChromaClient.deleteCollection.mockRejectedValueOnce(new Error('Delete failed')); - await expect(service.clearCache()).rejects.toThrow('Delete failed'); }); }); }); -- 2.52.0