96f201bf3f
Split the single model setting into `chatModel` and `agentModel` to allow using different LLMs for conversational modes (Ask, Research) versus agentic modes (Edit, Organize, Workflow, auto-organizer). Defaults are `deepseek-v4-flash` for chat and `glm-5.1` for agents. Includes backward compatibility migration from legacy `model` field, updated settings UI, per-mode tool filtering via new `agent-modes.ts` configs, and vault search scoring improvements (exact phrase, recency, filename bonuses).
1090 lines
38 KiB
TypeScript
Executable File
1090 lines
38 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 { VaultIndexer } from './vault-indexer';
|
||
import { AutoTagger, AutoLinker } from './auto-organizer';
|
||
import { PluginSettings, StructuredMemoryData, ToolTelemetryData } from './types';
|
||
import { Logger } from './utils';
|
||
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
||
import { AgentMode } from './types';
|
||
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
|
||
import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry';
|
||
|
||
export default class OllamaPlugin extends Plugin {
|
||
settings: PluginSettings = DEFAULT_SETTINGS;
|
||
semanticCache?: SemanticCacheService;
|
||
vaultVectorStore?: VaultVectorStore;
|
||
autoTagger?: AutoTagger;
|
||
autoLinker?: AutoLinker;
|
||
structuredMemoryManager?: StructuredMemoryManager;
|
||
telemetryManager?: TelemetryManager;
|
||
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,
|
||
this.structuredMemoryManager,
|
||
this.telemetryManager
|
||
)
|
||
);
|
||
|
||
// 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 command to auto-tag untagged notes
|
||
this.addCommand({
|
||
id: 'auto-tag-notes',
|
||
name: 'Auto-Tag Untagged Notes',
|
||
callback: () => {
|
||
this.initializeAutoOrganizer();
|
||
if (this.autoTagger) {
|
||
new Notice('Auto-tagging untagged notes...');
|
||
void this.autoTagger.run();
|
||
}
|
||
},
|
||
});
|
||
|
||
// Add a command to auto-link related notes
|
||
this.addCommand({
|
||
id: 'auto-link-notes',
|
||
name: 'Auto-Link Related Notes',
|
||
callback: () => {
|
||
this.initializeAutoOrganizer();
|
||
if (this.autoLinker) {
|
||
new Notice('Auto-linking related notes...');
|
||
void this.autoLinker.run();
|
||
}
|
||
},
|
||
});
|
||
|
||
// Add a command to clear the structured memory
|
||
this.addCommand({
|
||
id: 'clear-structured-memory',
|
||
name: 'Clear Structured Memory',
|
||
callback: async () => {
|
||
this.structuredMemoryManager?.clearAll();
|
||
await this.saveSettings();
|
||
new Notice('Structured memory cleared.');
|
||
},
|
||
});
|
||
|
||
// Add a command to clear tool telemetry
|
||
this.addCommand({
|
||
id: 'clear-tool-telemetry',
|
||
name: 'Clear Tool Telemetry',
|
||
callback: async () => {
|
||
this.telemetryManager?.clear();
|
||
await this.saveSettings();
|
||
new Notice('Tool telemetry cleared.');
|
||
},
|
||
});
|
||
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 data = ((await this.loadData()) ?? {}) as Record<string, unknown>;
|
||
// Backward compatibility: old flat format vs new nested format
|
||
const loadedSettings = (data.settings ?? data) as Partial<PluginSettings>;
|
||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
|
||
const legacyModel = loadedSettings.model ?? DEFAULT_SETTINGS.model;
|
||
this.settings.chatModel = loadedSettings.chatModel ?? legacyModel;
|
||
this.settings.agentModel = loadedSettings.agentModel ?? legacyModel;
|
||
this.settings.model = this.settings.chatModel;
|
||
|
||
const memoryData: StructuredMemoryData =
|
||
(data.structuredMemory as StructuredMemoryData | undefined) ??
|
||
createDefaultStructuredMemoryData();
|
||
this.structuredMemoryManager = new StructuredMemoryManager(
|
||
this.settings.structuredMemoryConfig,
|
||
memoryData
|
||
);
|
||
|
||
const telemetryData: ToolTelemetryData =
|
||
(data.toolTelemetry as ToolTelemetryData | undefined) ?? createDefaultToolTelemetryData();
|
||
this.telemetryManager = new TelemetryManager(this.settings.toolTelemetryConfig, telemetryData);
|
||
}
|
||
|
||
async saveSettings() {
|
||
await this.saveData({
|
||
settings: this.settings,
|
||
structuredMemory:
|
||
this.structuredMemoryManager?.getData() ?? createDefaultStructuredMemoryData(),
|
||
toolTelemetry: this.telemetryManager?.getData() ?? createDefaultToolTelemetryData(),
|
||
});
|
||
}
|
||
|
||
initializeAutoOrganizer(): void {
|
||
this.autoTagger = new AutoTagger(
|
||
this.app.vault,
|
||
this.app,
|
||
this.settings.ollamaUrl,
|
||
this.settings.agentModel,
|
||
this.settings.autoTagConfig
|
||
);
|
||
|
||
if (!this.autoLinker) {
|
||
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
|
||
this.autoLinker = new AutoLinker(
|
||
this.app.vault,
|
||
vaultIndexer,
|
||
this.settings.autoLinkConfig,
|
||
this.settings.autoLinkConfig.targetFolder
|
||
);
|
||
} else {
|
||
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
|
||
}
|
||
}
|
||
|
||
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 = 1;
|
||
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);
|
||
// Process files sequentially to avoid concurrent embedding requests
|
||
for (const file of batch) {
|
||
if (signal.aborted) break;
|
||
try {
|
||
const content = await this.app.vault.read(file);
|
||
if (signal.aborted) break;
|
||
const cache = this.app.metadataCache.getFileCache(file);
|
||
await this.vaultVectorStore.indexFile(file, content, cache ?? undefined);
|
||
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('Chat Model')
|
||
.setDesc('Model for normal chat, Ask mode, and Research mode (default: deepseek-v4-flash)')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.chatModel).onChange(async (value) => {
|
||
this.plugin.settings.chatModel = value.trim();
|
||
this.plugin.settings.model = this.plugin.settings.chatModel;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Agent Model')
|
||
.setDesc('Model for Edit, Organize, Workflow, and auto-organizer tasks (default: glm-5.1)')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.agentModel).onChange(async (value) => {
|
||
this.plugin.settings.agentModel = value.trim();
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Vault Search Limit')
|
||
.setDesc('Maximum number of vault entries to include in context (default: 5)')
|
||
.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 Context Length')
|
||
.setDesc('Maximum characters of vault content to send to the AI per message (default: 8000)')
|
||
.addText((text) =>
|
||
text.setValue(String(this.plugin.settings.maxContextLength)).onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
this.plugin.settings.maxContextLength = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max context length 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.');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Agent Mode Setting
|
||
containerEl.createEl('h3', { text: 'Agent Mode' });
|
||
containerEl.createEl('p', {
|
||
text: 'Default chat mode that controls available tools and system behavior.',
|
||
});
|
||
|
||
new Setting(containerEl)
|
||
.setName('Default Agent Mode')
|
||
.setDesc('Select the default mode for new chat sessions.')
|
||
.addDropdown((dropdown) => {
|
||
for (const mode of ALL_AGENT_MODES) {
|
||
dropdown.addOption(mode, getAgentModeLabel(mode));
|
||
}
|
||
dropdown.setValue(this.plugin.settings.agentMode ?? 'ask');
|
||
dropdown.onChange(async (value) => {
|
||
this.plugin.settings.agentMode = value as AgentMode;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.notifyChatViews();
|
||
});
|
||
});
|
||
|
||
// 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?');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Auto-Organize Settings
|
||
containerEl.createEl('h3', { text: 'Auto-Organize' });
|
||
|
||
// Auto-Tag Settings
|
||
containerEl.createEl('h4', { text: 'Auto-Tagging' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Auto-Tagging')
|
||
.setDesc('Use AI to automatically suggest and apply tags to untagged notes')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.autoTagConfig.enabled).onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.enabled = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Tags Per Note')
|
||
.setDesc('Maximum number of tags to generate for each note (default: 5)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoTagConfig.maxTagsPerNote))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0 && parsed <= 20) {
|
||
this.plugin.settings.autoTagConfig.maxTagsPerNote = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max tags must be between 1 and 20.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Min Note Length')
|
||
.setDesc('Minimum character length for a note to be tagged (default: 50)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoTagConfig.minNoteLength))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0) {
|
||
this.plugin.settings.autoTagConfig.minNoteLength = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Min note length must be a non-negative integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Note Length')
|
||
.setDesc('Maximum characters of content sent to the model for tagging (default: 8000)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoTagConfig.maxNoteLength))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
this.plugin.settings.autoTagConfig.maxNoteLength = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max note length must be a positive integer.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Normalize Tags')
|
||
.setDesc(
|
||
'Normalize generated tags against existing vault tag vocabulary (e.g., prefer "machine-learning" over "machine learning")'
|
||
)
|
||
.addToggle((toggle) =>
|
||
toggle
|
||
.setValue(this.plugin.settings.autoTagConfig.normalizeTags)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.normalizeTags = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Target Folder (Auto-Tag)')
|
||
.setDesc('Only auto-tag notes inside this folder path. Leave empty for all notes.')
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.autoTagConfig.targetFolder).onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Tag Prompt Template')
|
||
.setDesc(
|
||
'Prompt template for tag generation. Use {{maxTags}}, {{title}}, {{content}} as placeholders.'
|
||
)
|
||
.addTextArea((text) =>
|
||
text
|
||
.setValue(this.plugin.settings.autoTagConfig.tagPromptTemplate)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.autoTagConfig.tagPromptTemplate = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Run Auto-Tagging Now')
|
||
.setDesc('Process all untagged notes and generate tags')
|
||
.addButton((button) =>
|
||
button.setButtonText('Auto-Tag Notes').onClick(async () => {
|
||
try {
|
||
this.plugin.initializeAutoOrganizer();
|
||
if (this.plugin.autoTagger) {
|
||
await this.plugin.autoTagger.run();
|
||
}
|
||
} catch {
|
||
new Notice('Auto-tagging failed. Check console for details.');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Auto-Link Settings
|
||
containerEl.createEl('h4', { text: 'Auto-Linking' });
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Auto-Linking')
|
||
.setDesc('Add "Related Notes" sections to notes based on semantic similarity')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.autoLinkConfig.enabled).onChange(async (value) => {
|
||
this.plugin.settings.autoLinkConfig.enabled = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Links Per Note')
|
||
.setDesc('Maximum number of related notes to link (default: 3)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoLinkConfig.maxLinksPerNote))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed > 0 && parsed <= 10) {
|
||
this.plugin.settings.autoLinkConfig.maxLinksPerNote = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max links must be between 1 and 10.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Target Folder (Auto-Link)')
|
||
.setDesc(
|
||
'Only add related links to notes inside this folder path. Leave empty for all notes.'
|
||
)
|
||
.addText((text) =>
|
||
text.setValue(this.plugin.settings.autoLinkConfig.targetFolder).onChange(async (value) => {
|
||
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Dry Run Mode')
|
||
.setDesc('Preview proposed link changes without applying them')
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(this.plugin.settings.autoLinkConfig.dryRun).onChange(async (value) => {
|
||
this.plugin.settings.autoLinkConfig.dryRun = value;
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Auto-Link Similarity Threshold')
|
||
.setDesc('Minimum similarity score for notes to be considered related (default: 0.6)')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.autoLinkConfig.similarityThreshold))
|
||
.onChange(async (value) => {
|
||
const parsed = parseFloat(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||
this.plugin.settings.autoLinkConfig.similarityThreshold = parsed;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Similarity threshold must be between 0 and 1.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Run Auto-Linking Now')
|
||
.setDesc('Process all notes and add related note links')
|
||
.addButton((button) =>
|
||
button.setButtonText('Auto-Link Notes').onClick(async () => {
|
||
try {
|
||
this.plugin.initializeAutoOrganizer();
|
||
if (this.plugin.autoLinker) {
|
||
await this.plugin.autoLinker.run();
|
||
}
|
||
} catch {
|
||
new Notice('Auto-linking failed. Check console for details.');
|
||
}
|
||
})
|
||
);
|
||
|
||
// Structured Memory Settings
|
||
containerEl.createEl('h3', { text: 'Structured Memory' });
|
||
containerEl.createEl('p', {
|
||
text: 'Persist conversation summaries, user preferences, and learned facts across sessions.',
|
||
});
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Structured Memory')
|
||
.setDesc('Inject remembered context from past sessions into the system prompt.')
|
||
.addToggle((toggle) =>
|
||
toggle
|
||
.setValue(this.plugin.settings.structuredMemoryConfig.enabled)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.structuredMemoryConfig.enabled = value;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Conversation Summaries')
|
||
.setDesc('Maximum number of past conversation summaries to retain (default: 10).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxSummaries))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
||
this.plugin.settings.structuredMemoryConfig.maxSummaries = parsed;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max summaries must be between 0 and 100.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max User Preferences')
|
||
.setDesc('Maximum number of user preferences to retain (default: 20).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxPreferences))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 200) {
|
||
this.plugin.settings.structuredMemoryConfig.maxPreferences = parsed;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max preferences must be between 0 and 200.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Learned Facts')
|
||
.setDesc('Maximum number of learned facts to retain (default: 50).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxFacts))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 500) {
|
||
this.plugin.settings.structuredMemoryConfig.maxFacts = parsed;
|
||
this.plugin.structuredMemoryManager!.updateConfig(
|
||
this.plugin.settings.structuredMemoryConfig
|
||
);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max facts must be between 0 and 500.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Clear Structured Memory')
|
||
.setDesc('Delete all stored conversation summaries, preferences, and facts.')
|
||
.addButton((button) =>
|
||
button.setButtonText('Clear Memory').onClick(async () => {
|
||
this.plugin.structuredMemoryManager!.clearAll();
|
||
await this.plugin.saveSettings();
|
||
new Notice('Structured memory cleared.');
|
||
})
|
||
);
|
||
|
||
// Tool Telemetry Settings
|
||
containerEl.createEl('h3', { text: 'Tool Telemetry' });
|
||
containerEl.createEl('p', {
|
||
text: 'Track which tools were called, which notes were searched, and LLM token usage.',
|
||
});
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable Tool Telemetry')
|
||
.setDesc('Record tool calls, searches, and LLM token counts for analysis.')
|
||
.addToggle((toggle) =>
|
||
toggle
|
||
.setValue(this.plugin.settings.toolTelemetryConfig.enabled)
|
||
.onChange(async (value) => {
|
||
this.plugin.settings.toolTelemetryConfig.enabled = value;
|
||
this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig);
|
||
await this.plugin.saveSettings();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Max Telemetry Entries')
|
||
.setDesc('Maximum number of telemetry events to retain (default: 100).')
|
||
.addText((text) =>
|
||
text
|
||
.setValue(String(this.plugin.settings.toolTelemetryConfig.maxEntries))
|
||
.onChange(async (value) => {
|
||
const parsed = parseInt(value);
|
||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1000) {
|
||
this.plugin.settings.toolTelemetryConfig.maxEntries = parsed;
|
||
this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig);
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
new Notice('Max entries must be between 0 and 1000.');
|
||
}
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName('Clear Tool Telemetry')
|
||
.setDesc('Delete all recorded tool telemetry.')
|
||
.addButton((button) =>
|
||
button.setButtonText('Clear Telemetry').onClick(async () => {
|
||
this.plugin.telemetryManager?.clear();
|
||
await this.plugin.saveSettings();
|
||
new Notice('Tool telemetry cleared.');
|
||
})
|
||
);
|
||
|
||
// Recent Telemetry Summary
|
||
const recentEntries = this.plugin.telemetryManager?.getRecentEntries(10) ?? [];
|
||
if (recentEntries.length > 0) {
|
||
containerEl.createEl('h4', { text: 'Recent Activity' });
|
||
const telemetryList = containerEl.createEl('ul');
|
||
for (const entry of recentEntries) {
|
||
const li = telemetryList.createEl('li');
|
||
if (entry.type === 'tool_call') {
|
||
li.setText(
|
||
`${new Date(entry.timestamp).toLocaleString()}: Tool "${entry.toolName}" — ${entry.success ? 'success' : 'failed'} (${entry.durationMs}ms)`
|
||
);
|
||
} else if (entry.type === 'llm_call') {
|
||
li.setText(
|
||
`${new Date(entry.timestamp).toLocaleString()}: LLM call — ${entry.totalTokens} tokens (${entry.durationMs}ms)`
|
||
);
|
||
} else if (entry.type === 'vault_search') {
|
||
li.setText(
|
||
`${new Date(entry.timestamp).toLocaleString()}: Search "${entry.query}" — ${entry.resultsCount} results`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
hide() {
|
||
// Clear the container to prevent duplicate elements
|
||
this.containerEl.empty();
|
||
}
|
||
}
|