Files
obsidian_ollama/src/main.ts
T
fegger cc97d77810 fix: use collection deletion instead of reset, add embedding retry, slow down indexing
- semantic-cache.clearCache: delete collection instead of client.reset()
  to avoid 403 Forbidden on newer ChromaDB versions
- vault-vector-store.clearIndex: same collection deletion approach
- ContentVectorizer: add 3-attempt retry with exponential backoff (1s, 2s, 4s)
- main.ts: reduce indexing batch size from 5 to 2, increase delay from 100ms to 500ms
- Update semantic-cache tests for deleteCollection
2026-05-19 23:41:05 +02:00

564 lines
18 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
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<PluginSettings>;
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
}
async saveSettings() {
await this.saveData(this.settings);
}
async initializeVaultVectorStore(): Promise<void> {
// 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<void> {
if (this.currentIndexingPromise) {
try {
await this.currentIndexingPromise;
} catch {
// ignore errors from cancelled indexing
}
}
}
async backgroundIndexVault(): Promise<void> {
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<void> {
// 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<void> {
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 (01) 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 (01) 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();
}
}