import { Plugin, WorkspaceLeaf, App, Setting, Notice, PluginSettingTab, TFile } from 'obsidian'; import { ChatView } from './chat-view'; import { DEFAULT_SETTINGS } from './constants'; import { SemanticCacheService } from './semantic-cache'; import { VaultVectorStore } from './vault-vector-store'; import { PluginSettings } from './types'; import { Logger } from './utils'; export default class OllamaPlugin extends Plugin { settings: PluginSettings = DEFAULT_SETTINGS; semanticCache?: SemanticCacheService; vaultVectorStore?: VaultVectorStore; private indexingAbortController?: AbortController; private currentIndexingPromise?: Promise; async onload() { await this.loadSettings(); // Initialize vault vector store if enabled if (this.settings.vaultIndexConfig.enabled) { await this.initializeVaultVectorStore(); } // Register the chat view this.registerView( 'ollama-chat-view', (leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings, this.vaultVectorStore) ); // Add a ribbon icon in the left sidebar this.addRibbonIcon('bot', 'Open Ollama Chat', async () => { await this.activateChatView(); }); // Add a command to open the chat view this.addCommand({ id: 'open-ollama-chat', name: 'Open Ollama Chat', callback: async () => { await this.activateChatView(); }, }); // Add a command to clear the semantic cache this.addCommand({ id: 'clear-semantic-cache', name: 'Clear Semantic Cache', callback: async () => { await this.clearSemanticCache(); new Notice('Semantic cache cleared.'); }, }); // Add a command to clear the vault index this.addCommand({ id: 'clear-vault-index', name: 'Clear Vault Index', callback: async () => { await this.clearVaultIndex(); new Notice('Vault index cleared.'); }, }); // Add a command to rebuild the vault index this.addCommand({ id: 'rebuild-vault-index', name: 'Rebuild Vault Index', callback: async () => { new Notice('Rebuilding vault index...'); await this.rebuildVaultIndex(); new Notice('Vault index rebuilt.'); }, }); // Add a settings tab this.addSettingTab(new OllamaSettingTab(this.app, this)); // Initialize the semantic cache if (this.settings.cacheConfig) { this.semanticCache = new SemanticCacheService( this.settings.ollamaUrl, this.settings.cacheConfig ); try { await this.semanticCache.initialize(); } catch { new Notice('Semantic cache initialization failed. Check console for details.'); } } // Set up vault event listeners for incremental indexing this.registerVaultEventListeners(); } // eslint-disable-next-line @typescript-eslint/no-misused-promises onunload() { // Cancel any ongoing indexing this.cancelBackgroundIndexing(); // Clean up any active semantic cache resources on plugin unload // Using fire-and-forget pattern since onunload cannot be async per Obsidian API if (this.semanticCache) { void this.semanticCache.clearCache(); } // No explicit unregisterView needed; relying on Obsidian lifecycle management. } async loadSettings() { const loadedSettings = ((await this.loadData()) ?? {}) as Partial; this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings); } async saveSettings() { await this.saveData(this.settings); } async initializeVaultVectorStore(): Promise { // Abort any ongoing indexing before re-initializing this.cancelBackgroundIndexing(); await this.awaitBackgroundIndexing(); this.vaultVectorStore = new VaultVectorStore( this.settings.ollamaUrl, this.settings.vaultIndexConfig ); try { await this.vaultVectorStore.initialize(); // Run background indexing this.currentIndexingPromise = this.backgroundIndexVault(); void this.currentIndexingPromise .catch((err) => { Logger.warn(`Background vault indexing failed: ${String(err)}`, 'main'); }) .finally(() => { this.currentIndexingPromise = undefined; this.indexingAbortController = undefined; }); } catch { new Notice('Vault vector store initialization failed. Check console for details.'); } } private cancelBackgroundIndexing(): void { if (this.indexingAbortController) { this.indexingAbortController.abort(); } } private async awaitBackgroundIndexing(): Promise { if (this.currentIndexingPromise) { try { await this.currentIndexingPromise; } catch { // ignore errors from cancelled indexing } } } async backgroundIndexVault(): Promise { if (!this.vaultVectorStore) return; this.indexingAbortController = new AbortController(); const signal = this.indexingAbortController.signal; const files = this.app.vault.getMarkdownFiles(); Logger.info(`Starting background vault indexing for ${files.length} files...`, 'main'); let indexed = 0; const BATCH_SIZE = 2; const DELAY_MS = 500; for (let i = 0; i < files.length; i += BATCH_SIZE) { if (signal.aborted) { Logger.info('Vault indexing cancelled.', 'main'); return; } const batch = files.slice(i, i + BATCH_SIZE); await Promise.all( batch.map(async (file) => { if (signal.aborted) return; try { const content = await this.app.vault.read(file); if (signal.aborted) return; await this.vaultVectorStore!.indexFile(file, content); indexed++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); Logger.warn(`Failed to index ${file.path}: ${errorMessage}`, 'main'); } }) ); // Delay between batches to avoid overloading Ollama if (i + BATCH_SIZE < files.length) { await new Promise((resolve) => setTimeout(resolve, DELAY_MS)); } } if (!signal.aborted) { this.settings.lastIndexTime = Date.now(); await this.saveSettings(); Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, 'main'); new Notice(`Vault index updated: ${indexed} files indexed.`); } } async rebuildVaultIndex(): Promise { // Cancel and await any ongoing indexing before clearing this.cancelBackgroundIndexing(); await this.awaitBackgroundIndexing(); if (this.vaultVectorStore) { await this.vaultVectorStore.clearIndex(); await this.vaultVectorStore.initialize(); } this.currentIndexingPromise = this.backgroundIndexVault(); void this.currentIndexingPromise .catch((err) => { Logger.warn(`Rebuild vault indexing failed: ${String(err)}`, 'main'); }) .finally(() => { this.currentIndexingPromise = undefined; this.indexingAbortController = undefined; }); } async clearVaultIndex(): Promise { if (this.vaultVectorStore) { await this.vaultVectorStore.clearIndex(); } } registerVaultEventListeners(): void { // Listen for file creation this.registerEvent( this.app.vault.on('create', (file) => { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { void this.app.vault.read(file).then((content) => { // Skip if a full rebuild is in progress to avoid race conditions if (!this.currentIndexingPromise) { void this.vaultVectorStore?.indexFile(file, content); } }); } }) ); // Listen for file modification this.registerEvent( this.app.vault.on('modify', (file) => { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { void this.app.vault.read(file).then((content) => { if (!this.currentIndexingPromise) { void this.vaultVectorStore?.indexFile(file, content); } }); } }) ); // Listen for file deletion this.registerEvent( this.app.vault.on('delete', (file) => { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { void this.vaultVectorStore.deleteFile(file.path); } }) ); // Listen for file renames this.registerEvent( this.app.vault.on('rename', (file, oldPath) => { if (file instanceof TFile && file.extension === 'md' && this.vaultVectorStore) { void this.vaultVectorStore.deleteFile(oldPath); void this.app.vault.read(file).then((content) => { if (!this.currentIndexingPromise) { void this.vaultVectorStore?.indexFile(file, content); } }); } }) ); } async activateChatView() { const existing = this.app.workspace.getLeavesOfType('ollama-chat-view'); if (existing.length > 0) { await this.app.workspace.revealLeaf(existing[0]); } else { const leaf = this.app.workspace.getRightLeaf(false); if (leaf) { await leaf.setViewState({ type: 'ollama-chat-view', active: true, }); } } } async clearSemanticCache() { if (this.semanticCache) { await this.semanticCache.clearCache(); } } notifyChatViews() { const leaves = this.app.workspace.getLeavesOfType('ollama-chat-view'); leaves.forEach((leaf) => { if (leaf.view instanceof ChatView) { leaf.view.updateSettings(this.settings); leaf.view.setVectorStore(this.vaultVectorStore); } }); } } class OllamaSettingTab extends PluginSettingTab { plugin: OllamaPlugin; constructor(app: App, plugin: OllamaPlugin) { super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'Ollama Settings' }); new Setting(containerEl) .setName('Ollama URL') .setDesc('URL for your Ollama instance (default: http://localhost:11434)') .addText((text) => text.setValue(this.plugin.settings.ollamaUrl).onChange(async (value) => { this.plugin.settings.ollamaUrl = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName('Model') .setDesc('Ollama model to use (default: llama3)') .addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => { this.plugin.settings.model = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName('Vault Search Limit') .setDesc('Maximum number of vault entries to include in context (default: 3)') .addText((text) => text.setValue(String(this.plugin.settings.vaultSearchLimit)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0) { this.plugin.settings.vaultSearchLimit = parsed; await this.plugin.saveSettings(); } else { new Notice('Vault search limit must be a positive integer.'); } }) ); new Setting(containerEl) .setName('Max Message History') .setDesc('Maximum number of messages to keep in conversation history (default: 50)') .addText((text) => text.setValue(String(this.plugin.settings.maxMessageHistory)).onChange(async (value) => { const parsed = parseInt(value); if (!isNaN(parsed) && parsed > 0) { this.plugin.settings.maxMessageHistory = parsed; await this.plugin.saveSettings(); } else { new Notice('Max message history must be a positive integer.'); } }) ); // Vault Index Settings containerEl.createEl('h3', { text: 'Vault Semantic Index' }); new Setting(containerEl) .setName('Enable Vault Semantic Index') .setDesc( 'Automatically index vault notes into a vector database for semantic/RAG search. Requires ChromaDB and an embedding model.' ) .addToggle((toggle) => toggle.setValue(this.plugin.settings.vaultIndexConfig.enabled).onChange(async (value) => { this.plugin.settings.vaultIndexConfig.enabled = value; await this.plugin.saveSettings(); this.plugin.notifyChatViews(); if (value) { new Notice('Vault semantic index enabled. Rebuilding index...'); await this.plugin.initializeVaultVectorStore(); await this.plugin.rebuildVaultIndex(); } else { await this.plugin.clearVaultIndex(); this.plugin.vaultVectorStore = undefined; this.plugin.notifyChatViews(); } }) ); new Setting(containerEl) .setName('Vault Index ChromaDB URL') .setDesc( 'URL for your ChromaDB instance used for the vault index (default: http://localhost:8000)' ) .addText((text) => text .setValue(this.plugin.settings.vaultIndexConfig.chromaURL || 'http://localhost:8000') .onChange(async (value) => { const trimmed = value.trim(); this.plugin.settings.vaultIndexConfig.chromaURL = trimmed && trimmed.includes('://') ? trimmed : 'http://localhost:8000'; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName('Vault Index Embedding Model') .setDesc( 'Ollama model used to generate embeddings for vault notes (default: nomic-embed-text)' ) .addText((text) => text .setValue(this.plugin.settings.vaultIndexConfig.embeddingModel) .onChange(async (value) => { this.plugin.settings.vaultIndexConfig.embeddingModel = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName('Vault Index Similarity Threshold') .setDesc( 'Minimum cosine similarity (0–1) for a vault search hit. Higher values require closer matches (default: 0.75).' ) .addText((text) => text .setValue(String(this.plugin.settings.vaultIndexConfig.similarityThreshold)) .onChange(async (value) => { const parsed = parseFloat(value); if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { this.plugin.settings.vaultIndexConfig.similarityThreshold = parsed; await this.plugin.saveSettings(); } else { new Notice('Similarity threshold must be a number between 0 and 1.'); } }) ); new Setting(containerEl) .setName('Rebuild Vault Index') .setDesc('Delete and rebuild the entire vault semantic index') .addButton((button) => button.setButtonText('Rebuild Index').onClick(async () => { try { new Notice('Rebuilding vault index...'); await this.plugin.rebuildVaultIndex(); new Notice('Vault index rebuilt.'); } catch { new Notice('Failed to rebuild vault index. Is ChromaDB running?'); } }) ); new Setting(containerEl) .setName('Clear Vault Index') .setDesc('Delete all indexed vault notes from ChromaDB') .addButton((button) => button.setButtonText('Clear Index').onClick(async () => { try { await this.plugin.clearVaultIndex(); new Notice('Vault index cleared.'); } catch { new Notice('Failed to clear vault index. Is ChromaDB running?'); } }) ); // Semantic Cache Settings containerEl.createEl('h3', { text: 'Semantic Cache' }); new Setting(containerEl) .setName('Enable Semantic Cache') .setDesc('Use semantic cache to store and retrieve previous responses') .addToggle((toggle) => toggle.setValue(this.plugin.settings.cacheConfig.enabled).onChange(async (value) => { this.plugin.settings.cacheConfig.enabled = value; await this.plugin.saveSettings(); this.plugin.notifyChatViews(); }) ); new Setting(containerEl) .setName('ChromaDB URL') .setDesc('URL for your ChromaDB instance (default: http://localhost:8000)') .addText((text) => text .setValue(this.plugin.settings.cacheConfig.chromaURL || 'http://localhost:8000') .onChange(async (value) => { const trimmed = value.trim(); // Ensure a valid-looking URL; fall back to default if empty or malformed this.plugin.settings.cacheConfig.chromaURL = trimmed && trimmed.includes('://') ? trimmed : 'http://localhost:8000'; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .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(containerEl) .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(containerEl) .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() { // Clear the container to prevent duplicate elements this.containerEl.empty(); } }