cc97d77810
- 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
564 lines
18 KiB
TypeScript
Executable File
564 lines
18 KiB
TypeScript
Executable File
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 (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();
|
||
}
|
||
}
|