feat: add auto-tag and auto-link note organization features
- Add AutoTagger: scans untagged notes, generates AI tags via Ollama, and applies them to frontmatter - Add AutoLinker: finds semantically related notes via vault search and appends a 'Related Notes' section with wiki-links - Add settings UI for both features with configurable thresholds, prompt templates, and limits - Add commands: 'Auto-Tag Untagged Notes' and 'Auto-Link Related Notes' - Add auto-organizer.test.ts with 17 tests covering tagging, linking, frontmatter manipulation, and filtering
This commit is contained in:
@@ -3064,7 +3064,7 @@ __export(main_exports, {
|
||||
default: () => OllamaPlugin
|
||||
});
|
||||
module.exports = __toCommonJS(main_exports);
|
||||
var import_obsidian4 = require("obsidian");
|
||||
var import_obsidian5 = require("obsidian");
|
||||
|
||||
// src/chat-view.ts
|
||||
var import_obsidian3 = require("obsidian");
|
||||
@@ -9437,6 +9437,18 @@ var DEFAULT_SETTINGS = {
|
||||
collectionName: "ollama_vault_index",
|
||||
embeddingModel: "nomic-embed-text",
|
||||
chromaURL: "http://localhost:8000"
|
||||
},
|
||||
autoTagConfig: {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8e3,
|
||||
tagPromptTemplate: "Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}"
|
||||
},
|
||||
autoLinkConfig: {
|
||||
enabled: false,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.6
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9915,8 +9927,226 @@ var VaultVectorStore = class {
|
||||
}
|
||||
};
|
||||
|
||||
// src/auto-organizer.ts
|
||||
var import_obsidian4 = require("obsidian");
|
||||
var AutoTagger = class {
|
||||
constructor(vault, ollamaUrl, model, config) {
|
||||
this.vault = vault;
|
||||
this.config = config;
|
||||
this.ollamaClient = new OllamaClient(ollamaUrl, model);
|
||||
}
|
||||
updateConfig(config) {
|
||||
this.config = config;
|
||||
}
|
||||
/**
|
||||
* Find all markdown files that lack a `tags` frontmatter field.
|
||||
*/
|
||||
async getUntaggedNotes() {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const untagged = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!frontmatterMatch) {
|
||||
untagged.push(file);
|
||||
continue;
|
||||
}
|
||||
const frontmatterText = frontmatterMatch[1];
|
||||
const tagsMatch = frontmatterText.match(/^tags:\s*(.+)$/m);
|
||||
if (!tagsMatch) {
|
||||
untagged.push(file);
|
||||
continue;
|
||||
}
|
||||
const tagsValue = tagsMatch[1].trim();
|
||||
if (tagsValue === "" || tagsValue === "[]" || tagsValue === "null") {
|
||||
untagged.push(file);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return untagged;
|
||||
}
|
||||
/**
|
||||
* Generate tags for a single note using the AI.
|
||||
*/
|
||||
async generateTags(file) {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const length = content.length;
|
||||
if (length < this.config.minNoteLength || length > this.config.maxNoteLength) {
|
||||
Logger.info(`Skipping ${file.path}: content length ${length} out of range`, "auto-tagger");
|
||||
return [];
|
||||
}
|
||||
const truncated = content.substring(0, this.config.maxNoteLength);
|
||||
const prompt = this.config.tagPromptTemplate.replace(/\{\{maxTags\}\}/g, String(this.config.maxTagsPerNote)).replace(/\{\{title\}\}/g, file.basename).replace(/\{\{content\}\}/g, truncated);
|
||||
const response = await this.ollamaClient.chat([{ role: "user", content: prompt }]);
|
||||
const tags = this.parseTagResponse(response.content);
|
||||
Logger.info(`Generated tags for ${file.path}: ${tags.join(", ")}`, "auto-tagger");
|
||||
return tags;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to generate tags for ${file.path}: ${errorMessage}`, "auto-tagger");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Apply tags to a note's frontmatter.
|
||||
*/
|
||||
async applyTags(file, tags) {
|
||||
if (tags.length === 0) return;
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const existingFrontmatter = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
let newContent;
|
||||
if (existingFrontmatter) {
|
||||
const frontmatterText = existingFrontmatter[1];
|
||||
const hasTagsLine = /^tags:/m.test(frontmatterText);
|
||||
if (hasTagsLine) {
|
||||
const updatedFrontmatter = frontmatterText.replace(
|
||||
/^tags:.*$/m,
|
||||
`tags: ${tags.join(", ")}`
|
||||
);
|
||||
newContent = content.replace(existingFrontmatter[0], `---
|
||||
${updatedFrontmatter}
|
||||
---
|
||||
`);
|
||||
} else {
|
||||
const updatedFrontmatter = `tags: ${tags.join(", ")}
|
||||
${frontmatterText}`;
|
||||
newContent = content.replace(existingFrontmatter[0], `---
|
||||
${updatedFrontmatter}
|
||||
---
|
||||
`);
|
||||
}
|
||||
} else {
|
||||
newContent = `---
|
||||
tags: ${tags.join(", ")}
|
||||
---
|
||||
|
||||
${content}`;
|
||||
}
|
||||
await this.vault.modify(file, newContent);
|
||||
Logger.info(`Tagged ${file.path} with: ${tags.join(", ")}`, "auto-tagger");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to apply tags to ${file.path}: ${errorMessage}`, "auto-tagger");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Run auto-tagging on all untagged notes.
|
||||
*/
|
||||
async run() {
|
||||
if (!this.config.enabled) {
|
||||
new import_obsidian4.Notice("Auto-tagging is disabled in settings.");
|
||||
return { tagged: 0, skipped: 0 };
|
||||
}
|
||||
const untagged = await this.getUntaggedNotes();
|
||||
if (untagged.length === 0) {
|
||||
new import_obsidian4.Notice("No untagged notes found.");
|
||||
return { tagged: 0, skipped: 0 };
|
||||
}
|
||||
new import_obsidian4.Notice(`Auto-tagging ${untagged.length} notes...`);
|
||||
let tagged = 0;
|
||||
let skipped = 0;
|
||||
for (const file of untagged) {
|
||||
const tags = await this.generateTags(file);
|
||||
if (tags.length > 0) {
|
||||
await this.applyTags(file, tags);
|
||||
tagged++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
new import_obsidian4.Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`);
|
||||
return { tagged, skipped };
|
||||
}
|
||||
parseTagResponse(response) {
|
||||
return response.split(/[,\n]+/).map((t) => t.trim().replace(/^#+/, "").replace(/['"]+/g, "")).filter((t) => t.length > 0 && t.length < 50).slice(0, this.config.maxTagsPerNote);
|
||||
}
|
||||
};
|
||||
var AutoLinker = class {
|
||||
constructor(vault, vaultIndexer, config) {
|
||||
this.vault = vault;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
this.config = config;
|
||||
}
|
||||
updateConfig(config) {
|
||||
this.config = config;
|
||||
}
|
||||
/**
|
||||
* Find related notes for a given file using semantic search.
|
||||
*/
|
||||
async findRelatedNotes(file) {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const truncated = content.substring(0, 2e3);
|
||||
const results = await this.vaultIndexer.searchVault(
|
||||
truncated,
|
||||
this.config.maxLinksPerNote + 5
|
||||
);
|
||||
return results.filter((r) => r.path !== file.path && r.score >= this.config.similarityThreshold).slice(0, this.config.maxLinksPerNote).map((r) => ({ path: r.path, title: r.title, score: r.score }));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to find related notes for ${file.path}: ${errorMessage}`, "auto-linker");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Add a "Related Notes" section to a note if it doesn't already exist.
|
||||
*/
|
||||
async addRelatedLinks(file, related) {
|
||||
if (related.length === 0) return;
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
if (/^## Related Notes/m.test(content)) {
|
||||
Logger.info(`Skipping ${file.path}: already has Related Notes section`, "auto-linker");
|
||||
return;
|
||||
}
|
||||
const links = related.map((r) => `- [[${r.title}|${r.path.replace(/\.md$/, "")}]]`).join("\n");
|
||||
const section = `
|
||||
|
||||
## Related Notes
|
||||
|
||||
${links}
|
||||
`;
|
||||
await this.vault.modify(file, content + section);
|
||||
Logger.info(`Added ${related.length} related links to ${file.path}`, "auto-linker");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to add links to ${file.path}: ${errorMessage}`, "auto-linker");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Run auto-linking on all notes.
|
||||
*/
|
||||
async run() {
|
||||
if (!this.config.enabled) {
|
||||
new import_obsidian4.Notice("Auto-linking is disabled in settings.");
|
||||
return { linked: 0, skipped: 0 };
|
||||
}
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
new import_obsidian4.Notice(`Auto-linking ${files.length} notes...`);
|
||||
let linked = 0;
|
||||
let skipped = 0;
|
||||
for (const file of files) {
|
||||
const related = await this.findRelatedNotes(file);
|
||||
if (related.length > 0) {
|
||||
await this.addRelatedLinks(file, related);
|
||||
linked++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
new import_obsidian4.Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`);
|
||||
return { linked, skipped };
|
||||
}
|
||||
};
|
||||
|
||||
// src/main.ts
|
||||
var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
var OllamaPlugin = class extends import_obsidian5.Plugin {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.settings = DEFAULT_SETTINGS;
|
||||
@@ -9945,7 +10175,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
name: "Clear Semantic Cache",
|
||||
callback: async () => {
|
||||
await this.clearSemanticCache();
|
||||
new import_obsidian4.Notice("Semantic cache cleared.");
|
||||
new import_obsidian5.Notice("Semantic cache cleared.");
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
@@ -9953,16 +10183,38 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
name: "Clear Vault Index",
|
||||
callback: async () => {
|
||||
await this.clearVaultIndex();
|
||||
new import_obsidian4.Notice("Vault index cleared.");
|
||||
new import_obsidian5.Notice("Vault index cleared.");
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
id: "rebuild-vault-index",
|
||||
name: "Rebuild Vault Index",
|
||||
callback: async () => {
|
||||
new import_obsidian4.Notice("Rebuilding vault index...");
|
||||
new import_obsidian5.Notice("Rebuilding vault index...");
|
||||
await this.rebuildVaultIndex();
|
||||
new import_obsidian4.Notice("Vault index rebuilt.");
|
||||
new import_obsidian5.Notice("Vault index rebuilt.");
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
id: "auto-tag-notes",
|
||||
name: "Auto-Tag Untagged Notes",
|
||||
callback: async () => {
|
||||
await this.initializeAutoOrganizer();
|
||||
if (this.autoTagger) {
|
||||
new import_obsidian5.Notice("Auto-tagging untagged notes...");
|
||||
await this.autoTagger.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
id: "auto-link-notes",
|
||||
name: "Auto-Link Related Notes",
|
||||
callback: async () => {
|
||||
await this.initializeAutoOrganizer();
|
||||
if (this.autoLinker) {
|
||||
new import_obsidian5.Notice("Auto-linking related notes...");
|
||||
await this.autoLinker.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
||||
@@ -9974,7 +10226,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
try {
|
||||
await this.semanticCache.initialize();
|
||||
} catch {
|
||||
new import_obsidian4.Notice("Semantic cache initialization failed. Check console for details.");
|
||||
new import_obsidian5.Notice("Semantic cache initialization failed. Check console for details.");
|
||||
}
|
||||
}
|
||||
this.registerVaultEventListeners();
|
||||
@@ -9993,6 +10245,24 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
async initializeAutoOrganizer() {
|
||||
if (!this.autoTagger) {
|
||||
this.autoTagger = new AutoTagger(
|
||||
this.app.vault,
|
||||
this.settings.ollamaUrl,
|
||||
this.settings.model,
|
||||
this.settings.autoTagConfig
|
||||
);
|
||||
} else {
|
||||
this.autoTagger.updateConfig(this.settings.autoTagConfig);
|
||||
}
|
||||
if (!this.autoLinker) {
|
||||
const vaultIndexer = new VaultIndexer(this.app.vault, void 0, this.vaultVectorStore);
|
||||
this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig);
|
||||
} else {
|
||||
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
|
||||
}
|
||||
}
|
||||
async initializeVaultVectorStore() {
|
||||
this.cancelBackgroundIndexing();
|
||||
await this.awaitBackgroundIndexing();
|
||||
@@ -10010,7 +10280,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
this.indexingAbortController = void 0;
|
||||
});
|
||||
} catch {
|
||||
new import_obsidian4.Notice("Vault vector store initialization failed. Check console for details.");
|
||||
new import_obsidian5.Notice("Vault vector store initialization failed. Check console for details.");
|
||||
}
|
||||
}
|
||||
cancelBackgroundIndexing() {
|
||||
@@ -10061,7 +10331,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
this.settings.lastIndexTime = Date.now();
|
||||
await this.saveSettings();
|
||||
Logger.info(`Vault indexing complete: ${indexed}/${files.length} files indexed.`, "main");
|
||||
new import_obsidian4.Notice(`Vault index updated: ${indexed} files indexed.`);
|
||||
new import_obsidian5.Notice(`Vault index updated: ${indexed} files indexed.`);
|
||||
}
|
||||
}
|
||||
async rebuildVaultIndex() {
|
||||
@@ -10087,7 +10357,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
registerVaultEventListeners() {
|
||||
this.registerEvent(
|
||||
this.app.vault.on("create", (file) => {
|
||||
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
@@ -10098,7 +10368,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("modify", (file) => {
|
||||
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
void this.vaultVectorStore?.indexFile(file, content);
|
||||
@@ -10109,14 +10379,14 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("delete", (file) => {
|
||||
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.vaultVectorStore.deleteFile(file.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("rename", (file, oldPath) => {
|
||||
if (file instanceof import_obsidian4.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
if (file instanceof import_obsidian5.TFile && file.extension === "md" && this.vaultVectorStore) {
|
||||
void this.vaultVectorStore.deleteFile(oldPath);
|
||||
void this.app.vault.read(file).then((content) => {
|
||||
if (!this.currentIndexingPromise) {
|
||||
@@ -10156,7 +10426,7 @@ var OllamaPlugin = class extends import_obsidian4.Plugin {
|
||||
});
|
||||
}
|
||||
};
|
||||
var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
var OllamaSettingTab = class extends import_obsidian5.PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
@@ -10165,42 +10435,42 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: "Ollama Settings" });
|
||||
new import_obsidian4.Setting(containerEl).setName("Ollama URL").setDesc("URL for your Ollama instance (default: http://localhost:11434)").addText(
|
||||
new import_obsidian5.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 import_obsidian4.Setting(containerEl).setName("Model").setDesc("Ollama model to use (default: llama3)").addText(
|
||||
new import_obsidian5.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 import_obsidian4.Setting(containerEl).setName("Vault Search Limit").setDesc("Maximum number of vault entries to include in context (default: 3)").addText(
|
||||
new import_obsidian5.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 import_obsidian4.Notice("Vault search limit must be a positive integer.");
|
||||
new import_obsidian5.Notice("Vault search limit must be a positive integer.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Max Message History").setDesc("Maximum number of messages to keep in conversation history (default: 50)").addText(
|
||||
new import_obsidian5.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 import_obsidian4.Notice("Max message history must be a positive integer.");
|
||||
new import_obsidian5.Notice("Max message history must be a positive integer.");
|
||||
}
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h3", { text: "Vault Semantic Index" });
|
||||
new import_obsidian4.Setting(containerEl).setName("Enable Vault Semantic Index").setDesc(
|
||||
new import_obsidian5.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) => {
|
||||
@@ -10208,7 +10478,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.notifyChatViews();
|
||||
if (value) {
|
||||
new import_obsidian4.Notice("Vault semantic index enabled. Rebuilding index...");
|
||||
new import_obsidian5.Notice("Vault semantic index enabled. Rebuilding index...");
|
||||
await this.plugin.initializeVaultVectorStore();
|
||||
await this.plugin.rebuildVaultIndex();
|
||||
} else {
|
||||
@@ -10218,7 +10488,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Vault Index ChromaDB URL").setDesc(
|
||||
new import_obsidian5.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) => {
|
||||
@@ -10227,7 +10497,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Vault Index Embedding Model").setDesc(
|
||||
new import_obsidian5.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) => {
|
||||
@@ -10235,7 +10505,7 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc(
|
||||
new import_obsidian5.Setting(containerEl).setName("Vault Index Similarity Threshold").setDesc(
|
||||
"Minimum cosine similarity (0\u20131) 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) => {
|
||||
@@ -10244,54 +10514,54 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
this.plugin.settings.vaultIndexConfig.similarityThreshold = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
} else {
|
||||
new import_obsidian4.Notice("Similarity threshold must be a number between 0 and 1.");
|
||||
new import_obsidian5.Notice("Similarity threshold must be a number between 0 and 1.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Rebuild Vault Index").setDesc("Delete and rebuild the entire vault semantic index").addButton(
|
||||
new import_obsidian5.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 import_obsidian4.Notice("Rebuilding vault index...");
|
||||
new import_obsidian5.Notice("Rebuilding vault index...");
|
||||
await this.plugin.rebuildVaultIndex();
|
||||
new import_obsidian4.Notice("Vault index rebuilt.");
|
||||
new import_obsidian5.Notice("Vault index rebuilt.");
|
||||
} catch {
|
||||
new import_obsidian4.Notice("Failed to rebuild vault index. Is ChromaDB running?");
|
||||
new import_obsidian5.Notice("Failed to rebuild vault index. Is ChromaDB running?");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Clear Vault Index").setDesc("Delete all indexed vault notes from ChromaDB").addButton(
|
||||
new import_obsidian5.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 import_obsidian4.Notice("Vault index cleared.");
|
||||
new import_obsidian5.Notice("Vault index cleared.");
|
||||
} catch {
|
||||
new import_obsidian4.Notice("Failed to clear vault index. Is ChromaDB running?");
|
||||
new import_obsidian5.Notice("Failed to clear vault index. Is ChromaDB running?");
|
||||
}
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h3", { text: "Semantic Cache" });
|
||||
new import_obsidian4.Setting(containerEl).setName("Enable Semantic Cache").setDesc("Use semantic cache to store and retrieve previous responses").addToggle(
|
||||
new import_obsidian5.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 import_obsidian4.Setting(containerEl).setName("ChromaDB URL").setDesc("URL for your ChromaDB instance (default: http://localhost:8000)").addText(
|
||||
new import_obsidian5.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();
|
||||
this.plugin.settings.cacheConfig.chromaURL = trimmed && trimmed.includes("://") ? trimmed : "http://localhost:8000";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Cache Embedding Model").setDesc("Ollama model used to generate embeddings for the semantic cache").addText(
|
||||
new import_obsidian5.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 import_obsidian4.Setting(containerEl).setName("Cache Similarity Threshold").setDesc(
|
||||
new import_obsidian5.Setting(containerEl).setName("Cache Similarity Threshold").setDesc(
|
||||
"Minimum cosine similarity (0\u20131) for a cache hit. Higher values require closer matches."
|
||||
).addText(
|
||||
(text) => text.setValue(String(this.plugin.settings.cacheConfig.similarityThreshold)).onChange(async (value) => {
|
||||
@@ -10300,17 +10570,119 @@ var OllamaSettingTab = class extends import_obsidian4.PluginSettingTab {
|
||||
this.plugin.settings.cacheConfig.similarityThreshold = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
} else {
|
||||
new import_obsidian4.Notice("Similarity threshold must be a number between 0 and 1.");
|
||||
new import_obsidian5.Notice("Similarity threshold must be a number between 0 and 1.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian4.Setting(containerEl).setName("Clear Semantic Cache").setDesc("Delete all cached responses from ChromaDB").addButton(
|
||||
new import_obsidian5.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 import_obsidian4.Notice("Semantic cache cleared.");
|
||||
new import_obsidian5.Notice("Semantic cache cleared.");
|
||||
} catch {
|
||||
new import_obsidian4.Notice("Failed to clear semantic cache. Is ChromaDB running?");
|
||||
new import_obsidian5.Notice("Failed to clear semantic cache. Is ChromaDB running?");
|
||||
}
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h3", { text: "Auto-Organize" });
|
||||
containerEl.createEl("h4", { text: "Auto-Tagging" });
|
||||
new import_obsidian5.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 import_obsidian5.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 import_obsidian5.Notice("Max tags must be between 1 and 20.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian5.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 import_obsidian5.Notice("Min note length must be a non-negative integer.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian5.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 import_obsidian5.Notice("Max note length must be a positive integer.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian5.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 import_obsidian5.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 {
|
||||
await this.plugin.initializeAutoOrganizer();
|
||||
if (this.plugin.autoTagger) {
|
||||
await this.plugin.autoTagger.run();
|
||||
}
|
||||
} catch {
|
||||
new import_obsidian5.Notice("Auto-tagging failed. Check console for details.");
|
||||
}
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h4", { text: "Auto-Linking" });
|
||||
new import_obsidian5.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 import_obsidian5.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 import_obsidian5.Notice("Max links must be between 1 and 10.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian5.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 import_obsidian5.Notice("Similarity threshold must be between 0 and 1.");
|
||||
}
|
||||
})
|
||||
);
|
||||
new import_obsidian5.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 {
|
||||
await this.plugin.initializeAutoOrganizer();
|
||||
if (this.plugin.autoLinker) {
|
||||
await this.plugin.autoLinker.run();
|
||||
}
|
||||
} catch {
|
||||
new import_obsidian5.Notice("Auto-linking failed. Check console for details.");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Vault, TFile, Notice } from 'obsidian';
|
||||
import { OllamaClient } from './ollama-client';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { VaultVectorStore } from './vault-vector-store';
|
||||
import { Logger } from './utils';
|
||||
|
||||
export interface AutoOrganizeConfig {
|
||||
enabled: boolean;
|
||||
maxTagsPerNote: number;
|
||||
minNoteLength: number;
|
||||
maxNoteLength: number;
|
||||
tagPromptTemplate: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_AUTO_ORGANIZE_CONFIG: AutoOrganizeConfig = {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate:
|
||||
'Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}',
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically tags untagged notes using the AI model.
|
||||
*/
|
||||
export class AutoTagger {
|
||||
private vault: Vault;
|
||||
private ollamaClient: OllamaClient;
|
||||
private config: AutoOrganizeConfig;
|
||||
|
||||
constructor(vault: Vault, ollamaUrl: string, model: string, config: AutoOrganizeConfig) {
|
||||
this.vault = vault;
|
||||
this.config = config;
|
||||
this.ollamaClient = new OllamaClient(ollamaUrl, model);
|
||||
}
|
||||
|
||||
updateConfig(config: AutoOrganizeConfig): void {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all markdown files that lack a `tags` frontmatter field.
|
||||
*/
|
||||
async getUntaggedNotes(): Promise<TFile[]> {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const untagged: TFile[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!frontmatterMatch) {
|
||||
untagged.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
const frontmatterText = frontmatterMatch[1];
|
||||
const tagsMatch = frontmatterText.match(/^tags:\s*(.+)$/m);
|
||||
if (!tagsMatch) {
|
||||
untagged.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagsValue = tagsMatch[1].trim();
|
||||
if (tagsValue === '' || tagsValue === '[]' || tagsValue === 'null') {
|
||||
untagged.push(file);
|
||||
}
|
||||
} catch {
|
||||
// skip files that can't be read
|
||||
}
|
||||
}
|
||||
return untagged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tags for a single note using the AI.
|
||||
*/
|
||||
async generateTags(file: TFile): Promise<string[]> {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const length = content.length;
|
||||
|
||||
if (length < this.config.minNoteLength || length > this.config.maxNoteLength) {
|
||||
Logger.info(`Skipping ${file.path}: content length ${length} out of range`, 'auto-tagger');
|
||||
return [];
|
||||
}
|
||||
|
||||
const truncated = content.substring(0, this.config.maxNoteLength);
|
||||
const prompt = this.config.tagPromptTemplate
|
||||
.replace(/\{\{maxTags\}\}/g, String(this.config.maxTagsPerNote))
|
||||
.replace(/\{\{title\}\}/g, file.basename)
|
||||
.replace(/\{\{content\}\}/g, truncated);
|
||||
|
||||
const response = await this.ollamaClient.chat([{ role: 'user', content: prompt }]);
|
||||
|
||||
const tags = this.parseTagResponse(response.content);
|
||||
Logger.info(`Generated tags for ${file.path}: ${tags.join(', ')}`, 'auto-tagger');
|
||||
return tags;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to generate tags for ${file.path}: ${errorMessage}`, 'auto-tagger');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply tags to a note's frontmatter.
|
||||
*/
|
||||
async applyTags(file: TFile, tags: string[]): Promise<void> {
|
||||
if (tags.length === 0) return;
|
||||
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const existingFrontmatter = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
|
||||
let newContent: string;
|
||||
if (existingFrontmatter) {
|
||||
// Update existing frontmatter
|
||||
const frontmatterText = existingFrontmatter[1];
|
||||
const hasTagsLine = /^tags:/m.test(frontmatterText);
|
||||
|
||||
if (hasTagsLine) {
|
||||
// Replace existing tags line
|
||||
const updatedFrontmatter = frontmatterText.replace(
|
||||
/^tags:.*$/m,
|
||||
`tags: ${tags.join(', ')}`
|
||||
);
|
||||
newContent = content.replace(existingFrontmatter[0], `---\n${updatedFrontmatter}\n---\n`);
|
||||
} else {
|
||||
// Add tags line to existing frontmatter
|
||||
const updatedFrontmatter = `tags: ${tags.join(', ')}\n${frontmatterText}`;
|
||||
newContent = content.replace(existingFrontmatter[0], `---\n${updatedFrontmatter}\n---\n`);
|
||||
}
|
||||
} else {
|
||||
// Add new frontmatter block
|
||||
newContent = `---\ntags: ${tags.join(', ')}\n---\n\n${content}`;
|
||||
}
|
||||
|
||||
await this.vault.modify(file, newContent);
|
||||
Logger.info(`Tagged ${file.path} with: ${tags.join(', ')}`, 'auto-tagger');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to apply tags to ${file.path}: ${errorMessage}`, 'auto-tagger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-tagging on all untagged notes.
|
||||
*/
|
||||
async run(): Promise<{ tagged: number; skipped: number }> {
|
||||
if (!this.config.enabled) {
|
||||
new Notice('Auto-tagging is disabled in settings.');
|
||||
return { tagged: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const untagged = await this.getUntaggedNotes();
|
||||
if (untagged.length === 0) {
|
||||
new Notice('No untagged notes found.');
|
||||
return { tagged: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
new Notice(`Auto-tagging ${untagged.length} notes...`);
|
||||
let tagged = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of untagged) {
|
||||
const tags = await this.generateTags(file);
|
||||
if (tags.length > 0) {
|
||||
await this.applyTags(file, tags);
|
||||
tagged++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
// Small delay to avoid overloading Ollama
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
new Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`);
|
||||
return { tagged, skipped };
|
||||
}
|
||||
|
||||
private parseTagResponse(response: string): string[] {
|
||||
return response
|
||||
.split(/[,\n]+/)
|
||||
.map((t) => t.trim().replace(/^#+/, '').replace(/['"]+/g, ''))
|
||||
.filter((t) => t.length > 0 && t.length < 50)
|
||||
.slice(0, this.config.maxTagsPerNote);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically adds wiki-links to related notes based on semantic similarity.
|
||||
*/
|
||||
export class AutoLinker {
|
||||
private vault: Vault;
|
||||
private vaultIndexer: VaultIndexer;
|
||||
private config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number };
|
||||
|
||||
constructor(
|
||||
vault: Vault,
|
||||
vaultIndexer: VaultIndexer,
|
||||
config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number }
|
||||
) {
|
||||
this.vault = vault;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
updateConfig(config: {
|
||||
enabled: boolean;
|
||||
maxLinksPerNote: number;
|
||||
similarityThreshold: number;
|
||||
}): void {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find related notes for a given file using semantic search.
|
||||
*/
|
||||
async findRelatedNotes(file: TFile): Promise<{ path: string; title: string; score: number }[]> {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const truncated = content.substring(0, 2000);
|
||||
|
||||
const results = await this.vaultIndexer.searchVault(
|
||||
truncated,
|
||||
this.config.maxLinksPerNote + 5
|
||||
);
|
||||
|
||||
// Filter out self and low-similarity results
|
||||
return results
|
||||
.filter((r) => r.path !== file.path && r.score >= this.config.similarityThreshold)
|
||||
.slice(0, this.config.maxLinksPerNote)
|
||||
.map((r) => ({ path: r.path, title: r.title, score: r.score }));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to find related notes for ${file.path}: ${errorMessage}`, 'auto-linker');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a "Related Notes" section to a note if it doesn't already exist.
|
||||
*/
|
||||
async addRelatedLinks(file: TFile, related: { path: string; title: string }[]): Promise<void> {
|
||||
if (related.length === 0) return;
|
||||
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
|
||||
// Skip if already has a Related Notes section
|
||||
if (/^## Related Notes/m.test(content)) {
|
||||
Logger.info(`Skipping ${file.path}: already has Related Notes section`, 'auto-linker');
|
||||
return;
|
||||
}
|
||||
|
||||
const links = related
|
||||
.map((r) => `- [[${r.title}|${r.path.replace(/\.md$/, '')}]]`)
|
||||
.join('\n');
|
||||
const section = `\n\n## Related Notes\n\n${links}\n`;
|
||||
|
||||
await this.vault.modify(file, content + section);
|
||||
Logger.info(`Added ${related.length} related links to ${file.path}`, 'auto-linker');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to add links to ${file.path}: ${errorMessage}`, 'auto-linker');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-linking on all notes.
|
||||
*/
|
||||
async run(): Promise<{ linked: number; skipped: number }> {
|
||||
if (!this.config.enabled) {
|
||||
new Notice('Auto-linking is disabled in settings.');
|
||||
return { linked: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
new Notice(`Auto-linking ${files.length} notes...`);
|
||||
|
||||
let linked = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const related = await this.findRelatedNotes(file);
|
||||
if (related.length > 0) {
|
||||
await this.addRelatedLinks(file, related);
|
||||
linked++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
// Small delay between files
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
new Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`);
|
||||
return { linked, skipped };
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,17 @@ export const DEFAULT_SETTINGS = {
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
autoTagConfig: {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate:
|
||||
'Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}',
|
||||
},
|
||||
autoLinkConfig: {
|
||||
enabled: false,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.6,
|
||||
},
|
||||
};
|
||||
|
||||
+210
@@ -3,6 +3,8 @@ 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 } from './types';
|
||||
import { Logger } from './utils';
|
||||
|
||||
@@ -10,6 +12,8 @@ export default class OllamaPlugin extends Plugin {
|
||||
settings: PluginSettings = DEFAULT_SETTINGS;
|
||||
semanticCache?: SemanticCacheService;
|
||||
vaultVectorStore?: VaultVectorStore;
|
||||
autoTagger?: AutoTagger;
|
||||
autoLinker?: AutoLinker;
|
||||
private indexingAbortController?: AbortController;
|
||||
private currentIndexingPromise?: Promise<void>;
|
||||
|
||||
@@ -72,6 +76,32 @@ export default class OllamaPlugin extends Plugin {
|
||||
},
|
||||
});
|
||||
|
||||
// Add a command to auto-tag untagged notes
|
||||
this.addCommand({
|
||||
id: 'auto-tag-notes',
|
||||
name: 'Auto-Tag Untagged Notes',
|
||||
callback: async () => {
|
||||
await this.initializeAutoOrganizer();
|
||||
if (this.autoTagger) {
|
||||
new Notice('Auto-tagging untagged notes...');
|
||||
await this.autoTagger.run();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add a command to auto-link related notes
|
||||
this.addCommand({
|
||||
id: 'auto-link-notes',
|
||||
name: 'Auto-Link Related Notes',
|
||||
callback: async () => {
|
||||
await this.initializeAutoOrganizer();
|
||||
if (this.autoLinker) {
|
||||
new Notice('Auto-linking related notes...');
|
||||
await this.autoLinker.run();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add a settings tab
|
||||
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
||||
|
||||
@@ -114,6 +144,26 @@ export default class OllamaPlugin extends Plugin {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
async initializeAutoOrganizer(): Promise<void> {
|
||||
if (!this.autoTagger) {
|
||||
this.autoTagger = new AutoTagger(
|
||||
this.app.vault,
|
||||
this.settings.ollamaUrl,
|
||||
this.settings.model,
|
||||
this.settings.autoTagConfig
|
||||
);
|
||||
} else {
|
||||
this.autoTagger.updateConfig(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);
|
||||
} else {
|
||||
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async initializeVaultVectorStore(): Promise<void> {
|
||||
// Abort any ongoing indexing before re-initializing
|
||||
this.cancelBackgroundIndexing();
|
||||
@@ -553,6 +603,166 @@ class OllamaSettingTab extends PluginSettingTab {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 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('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 {
|
||||
await 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('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 {
|
||||
await this.plugin.initializeAutoOrganizer();
|
||||
if (this.plugin.autoLinker) {
|
||||
await this.plugin.autoLinker.run();
|
||||
}
|
||||
} catch {
|
||||
new Notice('Auto-linking failed. Check console for details.');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
hide() {
|
||||
|
||||
@@ -193,6 +193,18 @@ export interface PluginSettings {
|
||||
lastIndexTime: number;
|
||||
cacheConfig: CacheConfig;
|
||||
vaultIndexConfig: VaultIndexConfig;
|
||||
autoTagConfig: {
|
||||
enabled: boolean;
|
||||
maxTagsPerNote: number;
|
||||
minNoteLength: number;
|
||||
maxNoteLength: number;
|
||||
tagPromptTemplate: string;
|
||||
};
|
||||
autoLinkConfig: {
|
||||
enabled: boolean;
|
||||
maxLinksPerNote: number;
|
||||
similarityThreshold: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { AutoTagger, AutoLinker } from '../src/auto-organizer';
|
||||
import { OllamaClient } from '../src/ollama-client';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../src/ollama-client');
|
||||
jest.mock('../src/utils', () => ({
|
||||
Logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Obsidian
|
||||
const mockModify = jest.fn();
|
||||
const mockCachedRead = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockGetMarkdownFiles = jest.fn();
|
||||
|
||||
const createMockVault = () => ({
|
||||
getMarkdownFiles: mockGetMarkdownFiles,
|
||||
cachedRead: mockCachedRead,
|
||||
read: mockRead,
|
||||
modify: mockModify,
|
||||
});
|
||||
|
||||
describe('AutoTagger', () => {
|
||||
let tagger: AutoTagger;
|
||||
let mockVault: ReturnType<typeof createMockVault>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockVault = createMockVault();
|
||||
tagger = new AutoTagger(mockVault as any, 'http://localhost:11434', 'llama3', {
|
||||
enabled: true,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate: 'Tags for {{title}}: {{content}}',
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUntaggedNotes', () => {
|
||||
it('should return files without frontmatter', async () => {
|
||||
const files = [{ path: 'note1.md' }, { path: 'note2.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockCachedRead
|
||||
.mockResolvedValueOnce('No frontmatter here')
|
||||
.mockResolvedValueOnce('---\ntags: existing\n---\nContent');
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('note1.md');
|
||||
});
|
||||
|
||||
it('should return files with empty tags', async () => {
|
||||
const files = [{ path: 'note1.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockCachedRead.mockResolvedValue('---\ntags: \n---\nContent');
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should skip files with existing tags', async () => {
|
||||
const files = [{ path: 'note1.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockCachedRead.mockResolvedValue('---\ntags: ai, ml\n---\nContent');
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTagResponse', () => {
|
||||
it('should parse comma-separated tags', () => {
|
||||
const tags = (tagger as any).parseTagResponse('ai, machine-learning, obsidian');
|
||||
expect(tags).toEqual(['ai', 'machine-learning', 'obsidian']);
|
||||
});
|
||||
|
||||
it('should clean up hashtags and quotes', () => {
|
||||
const tags = (tagger as any).parseTagResponse('#ai, "machine learning", #obsidian');
|
||||
expect(tags).toEqual(['ai', 'machine learning', 'obsidian']);
|
||||
});
|
||||
|
||||
it('should limit to max tags', () => {
|
||||
const tags = (tagger as any).parseTagResponse('a, b, c, d, e, f, g');
|
||||
expect(tags).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should filter empty tags', () => {
|
||||
const tags = (tagger as any).parseTagResponse('ai,, , ml');
|
||||
expect(tags).toEqual(['ai', 'ml']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTags', () => {
|
||||
it('should add frontmatter to note without it', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockRead.mockResolvedValue('Just content');
|
||||
|
||||
await tagger.applyTags(file, ['ai', 'ml']);
|
||||
|
||||
expect(mockModify).toHaveBeenCalledWith(file, '---\ntags: ai, ml\n---\n\nJust content');
|
||||
});
|
||||
|
||||
it('should update existing frontmatter with tags', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockRead.mockResolvedValue('---\ndate: 2024-01-01\n---\nContent');
|
||||
|
||||
await tagger.applyTags(file, ['ai']);
|
||||
|
||||
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: ai'));
|
||||
});
|
||||
|
||||
it('should replace existing tags line', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockRead.mockResolvedValue('---\ntags: old\n---\nContent');
|
||||
|
||||
await tagger.applyTags(file, ['new']);
|
||||
|
||||
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: new'));
|
||||
expect(mockModify).not.toHaveBeenCalledWith(file, expect.stringContaining('tags: old'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should return early when disabled', async () => {
|
||||
tagger.updateConfig({ ...tagger['config'], enabled: false });
|
||||
const result = await tagger.run();
|
||||
expect(result.tagged).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip notes that are too short', async () => {
|
||||
const files = [{ path: 'note.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockCachedRead.mockResolvedValue('---\n---\nContent');
|
||||
mockRead.mockResolvedValue('Short');
|
||||
|
||||
const result = await tagger.run();
|
||||
expect(result.skipped).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AutoLinker', () => {
|
||||
let linker: AutoLinker;
|
||||
let mockVault: ReturnType<typeof createMockVault>;
|
||||
let mockIndexer: { searchVault: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockVault = createMockVault();
|
||||
mockIndexer = {
|
||||
searchVault: jest.fn(),
|
||||
};
|
||||
|
||||
linker = new AutoLinker(mockVault as any, mockIndexer as any, {
|
||||
enabled: true,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRelatedNotes', () => {
|
||||
it('should return related notes excluding self', async () => {
|
||||
const file = { path: 'note.md', basename: 'Note' } as any;
|
||||
mockVault.read.mockResolvedValue('Content about AI');
|
||||
mockIndexer.searchVault!.mockResolvedValue([
|
||||
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
|
||||
{ path: 'note.md', title: 'Note', score: 0.95, content: '' },
|
||||
]);
|
||||
|
||||
const result = await linker.findRelatedNotes(file);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('other.md');
|
||||
});
|
||||
|
||||
it('should filter by similarity threshold', async () => {
|
||||
const file = { path: 'note.md', basename: 'Note' } as any;
|
||||
mockVault.read.mockResolvedValue('Content');
|
||||
mockIndexer.searchVault!.mockResolvedValue([
|
||||
{ path: 'high.md', title: 'High', score: 0.8, content: '' },
|
||||
{ path: 'low.md', title: 'Low', score: 0.3, content: '' },
|
||||
]);
|
||||
|
||||
const result = await linker.findRelatedNotes(file);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('high.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRelatedLinks', () => {
|
||||
it('should add Related Notes section', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockVault.read.mockResolvedValue('# Note\n\nContent');
|
||||
|
||||
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
|
||||
|
||||
expect(mockVault.modify).toHaveBeenCalledWith(
|
||||
file,
|
||||
expect.stringContaining('## Related Notes')
|
||||
);
|
||||
expect(mockVault.modify).toHaveBeenCalledWith(
|
||||
file,
|
||||
expect.stringContaining('[[Other|other]]')
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip if Related Notes already exists', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockVault.read.mockResolvedValue('# Note\n\n## Related Notes\nAlready linked');
|
||||
|
||||
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
|
||||
expect(mockVault.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should return early when disabled', async () => {
|
||||
linker.updateConfig({ enabled: false, maxLinksPerNote: 3, similarityThreshold: 0.5 });
|
||||
const result = await linker.run();
|
||||
expect(result.linked).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,18 @@ const mockSettings: PluginSettings = {
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
autoTagConfig: {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate: 'Tags: {{content}}',
|
||||
},
|
||||
autoLinkConfig: {
|
||||
enabled: false,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.6,
|
||||
},
|
||||
};
|
||||
|
||||
describe('ChatView', () => {
|
||||
|
||||
Reference in New Issue
Block a user