v1.0.0: Local semantic embeddings, Related Notes panel, folder autocomplete
- EmbedSearch: local ONNX embeddings via @xenova/transformers (BGE Micro v2) - Incremental indexing with per-100-note disk flush - 13s per-embed timeout, 120s for first call (WASM init) - Re-embed on file modify (debounced 2s) - Exclude folders setting - RelatedNotesView: sidebar panel showing semantically related notes - Similarity bar + percentage per result - Auto-refreshes on active note change - Uses cached vectors (no re-embedding on lookup) - Startup: waits for Obsidian Sync to finish before indexing - Settings: folder autocomplete for all folder inputs - Obsidian Notice with N/s speed + ETA during indexing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31134,7 +31134,7 @@ __export(main_exports, {
|
|||||||
default: () => MemexChatPlugin
|
default: () => MemexChatPlugin
|
||||||
});
|
});
|
||||||
module.exports = __toCommonJS(main_exports);
|
module.exports = __toCommonJS(main_exports);
|
||||||
var import_obsidian4 = require("obsidian");
|
var import_obsidian5 = require("obsidian");
|
||||||
|
|
||||||
// src/ChatView.ts
|
// src/ChatView.ts
|
||||||
var import_obsidian = require("obsidian");
|
var import_obsidian = require("obsidian");
|
||||||
@@ -32291,6 +32291,8 @@ var EMBEDDING_MODELS = [
|
|||||||
];
|
];
|
||||||
var EmbedSearch = class {
|
var EmbedSearch = class {
|
||||||
constructor(app, modelId) {
|
constructor(app, modelId) {
|
||||||
|
this.excludeFolders = [];
|
||||||
|
// vault folder prefixes to skip
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
this.pipe = null;
|
this.pipe = null;
|
||||||
this.cache = /* @__PURE__ */ new Map();
|
this.cache = /* @__PURE__ */ new Map();
|
||||||
@@ -32298,6 +32300,8 @@ var EmbedSearch = class {
|
|||||||
this.vecs = /* @__PURE__ */ new Map();
|
this.vecs = /* @__PURE__ */ new Map();
|
||||||
this.indexed = false;
|
this.indexed = false;
|
||||||
this.indexing = false;
|
this.indexing = false;
|
||||||
|
// ─── Incremental re-embed on file change ─────────────────────────────────
|
||||||
|
this.reembedTimers = /* @__PURE__ */ new Map();
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.modelId = modelId;
|
this.modelId = modelId;
|
||||||
}
|
}
|
||||||
@@ -32356,10 +32360,22 @@ var EmbedSearch = class {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async embed(text) {
|
async embed(text) {
|
||||||
|
console.log("[Memex] embed: loadPipeline\u2026");
|
||||||
await this.loadPipeline();
|
await this.loadPipeline();
|
||||||
|
console.log("[Memex] embed: pipe call\u2026");
|
||||||
const result = await this.pipe(text.slice(0, 512), { pooling: "mean", normalize: true });
|
const result = await this.pipe(text.slice(0, 512), { pooling: "mean", normalize: true });
|
||||||
|
console.log("[Memex] embed: done, dims:", result.data.length);
|
||||||
return Array.from(result.data);
|
return Array.from(result.data);
|
||||||
}
|
}
|
||||||
|
/** embed() with a hard timeout; rejects with "embed timeout" if exceeded. */
|
||||||
|
embedWithTimeout(text, ms = 13e3) {
|
||||||
|
return Promise.race([
|
||||||
|
this.embed(text),
|
||||||
|
new Promise(
|
||||||
|
(_, reject) => setTimeout(() => reject(new Error("embed timeout")), ms)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
cosine(a, b) {
|
cosine(a, b) {
|
||||||
let dot2 = 0;
|
let dot2 = 0;
|
||||||
for (let i = 0; i < a.length; i++)
|
for (let i = 0; i < a.length; i++)
|
||||||
@@ -32368,6 +32384,7 @@ var EmbedSearch = class {
|
|||||||
}
|
}
|
||||||
// ─── Index ────────────────────────────────────────────────────────────────
|
// ─── Index ────────────────────────────────────────────────────────────────
|
||||||
async buildIndex() {
|
async buildIndex() {
|
||||||
|
console.log("[Memex] buildIndex START, indexing:", this.indexing);
|
||||||
if (this.indexing)
|
if (this.indexing)
|
||||||
return;
|
return;
|
||||||
this.indexing = true;
|
this.indexing = true;
|
||||||
@@ -32378,13 +32395,17 @@ var EmbedSearch = class {
|
|||||||
try {
|
try {
|
||||||
await import_fs3.promises.mkdir(this.modelsDir, { recursive: true });
|
await import_fs3.promises.mkdir(this.modelsDir, { recursive: true });
|
||||||
await import_fs3.promises.mkdir(this.embedDir, { recursive: true });
|
await import_fs3.promises.mkdir(this.embedDir, { recursive: true });
|
||||||
|
console.log("[Memex] Verzeichnisse OK:", this.embedDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Memex] Verzeichnisse konnten nicht angelegt werden:", e);
|
console.error("[Memex] Verzeichnisse konnten nicht angelegt werden:", e);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.loadCache();
|
await this.loadCache();
|
||||||
const files = this.app.vault.getMarkdownFiles();
|
console.log("[Memex] Cache geladen, Eintr\xE4ge:", this.cache.size);
|
||||||
|
const allFiles = this.app.vault.getMarkdownFiles();
|
||||||
|
const files = this.excludeFolders.length ? allFiles.filter((f) => !this.excludeFolders.some((ex) => f.path.startsWith(ex + "/"))) : allFiles;
|
||||||
const total = files.length;
|
const total = files.length;
|
||||||
|
console.log("[Memex] Dateien gesamt:", total, "(ausgeschlossen:", allFiles.length - total, ")");
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let windowStart = Date.now();
|
let windowStart = Date.now();
|
||||||
let windowEmbedded = 0;
|
let windowEmbedded = 0;
|
||||||
@@ -32399,17 +32420,22 @@ var EmbedSearch = class {
|
|||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
const raw = await this.app.vault.cachedRead(file);
|
const raw = await this.app.vault.cachedRead(file);
|
||||||
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
||||||
const vec = await this.embed(text);
|
const vec = await this.embedWithTimeout(text, this.pipe ? 13e3 : 12e4);
|
||||||
this.cache.set(file.path, { mtime, vec });
|
this.cache.set(file.path, { mtime, vec });
|
||||||
this.vecs.set(file.path, { vec, file });
|
this.vecs.set(file.path, { vec, file });
|
||||||
changed.push(file.path);
|
changed.push(file.path);
|
||||||
windowEmbedded++;
|
windowEmbedded++;
|
||||||
|
if (changed.length === 1 || changed.length % 50 === 0)
|
||||||
|
console.log(`[Memex] Eingebettet: ${changed.length}/${total}`);
|
||||||
|
if (changed.length % 100 === 0)
|
||||||
|
await this.flushBatch(changed.slice(-100));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!this.pipe && !pipelineError) {
|
if (!this.pipe && !pipelineError) {
|
||||||
pipelineError = e;
|
pipelineError = e;
|
||||||
console.error("[Memex] Pipeline-Ladefehler:", e);
|
console.error("[Memex] Pipeline-Ladefehler:", e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
console.warn("[Memex] Datei \xFCbersprungen:", file.path, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
done++;
|
done++;
|
||||||
@@ -32425,17 +32451,77 @@ var EmbedSearch = class {
|
|||||||
this.onProgress(done, total, speed);
|
this.onProgress(done, total, speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log("[Memex] Loop fertig, changed:", changed.length, "pipelineError:", !!pipelineError);
|
||||||
if (pipelineError)
|
if (pipelineError)
|
||||||
throw pipelineError;
|
throw pipelineError;
|
||||||
const allPaths = new Set(files.map((f) => f.path));
|
const allPaths = new Set(files.map((f) => f.path));
|
||||||
await this.saveCache(changed, allPaths);
|
const remainder = changed.length % 100;
|
||||||
|
await this.saveCache(remainder > 0 ? changed.slice(-remainder) : [], allPaths);
|
||||||
this.indexed = true;
|
this.indexed = true;
|
||||||
if (this.onProgress)
|
if (this.onProgress)
|
||||||
this.onProgress(total, total, speed);
|
this.onProgress(total, total, speed);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Memex] buildIndex Fehler:", e);
|
||||||
} finally {
|
} finally {
|
||||||
this.indexing = false;
|
this.indexing = false;
|
||||||
|
console.log("[Memex] buildIndex END, indexed:", this.indexed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Debounced re-embed for a single file (called on vault modify events).
|
||||||
|
* Waits 2 s after the last write before embedding.
|
||||||
|
*/
|
||||||
|
reembedFile(file) {
|
||||||
|
if (!this.indexed || this.indexing)
|
||||||
|
return;
|
||||||
|
const existing = this.reembedTimers.get(file.path);
|
||||||
|
if (existing)
|
||||||
|
clearTimeout(existing);
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
this.reembedTimers.delete(file.path);
|
||||||
|
try {
|
||||||
|
const raw = await this.app.vault.cachedRead(file);
|
||||||
|
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
||||||
|
const vec = await this.embedWithTimeout(text);
|
||||||
|
const mtime = file.stat.mtime;
|
||||||
|
this.cache.set(file.path, { mtime, vec });
|
||||||
|
this.vecs.set(file.path, { vec, file });
|
||||||
|
await this.saveCache([file.path], new Set(this.vecs.keys()));
|
||||||
|
console.log("[Memex] Re-embedded:", file.path);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Memex] Re-embed fehlgeschlagen:", file.path, e);
|
||||||
|
}
|
||||||
|
}, 2e3);
|
||||||
|
this.reembedTimers.set(file.path, timer);
|
||||||
|
}
|
||||||
|
/** Find notes similar to a given file using its cached vector (no re-embedding). */
|
||||||
|
async searchSimilarToFile(file, topK = 10) {
|
||||||
|
if (!this.indexed)
|
||||||
|
return [];
|
||||||
|
let qvec = this.vecs.get(file.path)?.vec;
|
||||||
|
if (!qvec) {
|
||||||
|
try {
|
||||||
|
const raw = await this.app.vault.cachedRead(file);
|
||||||
|
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
||||||
|
qvec = await this.embedWithTimeout(text);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scores = [];
|
||||||
|
for (const [path3, { vec }] of this.vecs) {
|
||||||
|
if (path3 === file.path)
|
||||||
|
continue;
|
||||||
|
const s = this.cosine(qvec, vec);
|
||||||
|
if (s > 0.2)
|
||||||
|
scores.push([path3, s]);
|
||||||
|
}
|
||||||
|
scores.sort((a, b) => b[1] - a[1]);
|
||||||
|
return scores.slice(0, topK).map(([path3, score]) => {
|
||||||
|
const { file: f } = this.vecs.get(path3);
|
||||||
|
return { file: f, score, excerpt: "", title: f.basename };
|
||||||
|
});
|
||||||
|
}
|
||||||
async search(query, topK = 8) {
|
async search(query, topK = 8) {
|
||||||
if (!this.indexed)
|
if (!this.indexed)
|
||||||
await this.buildIndex();
|
await this.buildIndex();
|
||||||
@@ -32494,16 +32580,12 @@ var EmbedSearch = class {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/** Write .ajson files for a batch of vault paths (no pruning). Called incrementally. */
|
||||||
* Write .ajson for each newly embedded note; delete .ajson for removed notes;
|
async flushBatch(vaultPaths) {
|
||||||
* write/update the manifest.
|
|
||||||
*/
|
|
||||||
async saveCache(changed, allVaultPaths) {
|
|
||||||
try {
|
try {
|
||||||
await import_fs3.promises.mkdir(this.embedDir, { recursive: true });
|
|
||||||
const manifest = { model: this.modelId, version: 1 };
|
const manifest = { model: this.modelId, version: 1 };
|
||||||
await import_fs3.promises.writeFile(this.manifestPath, JSON.stringify(manifest), "utf8");
|
await import_fs3.promises.writeFile(this.manifestPath, JSON.stringify(manifest), "utf8");
|
||||||
for (const vaultPath of changed) {
|
for (const vaultPath of vaultPaths) {
|
||||||
const entry = this.cache.get(vaultPath);
|
const entry = this.cache.get(vaultPath);
|
||||||
if (!entry)
|
if (!entry)
|
||||||
continue;
|
continue;
|
||||||
@@ -32511,11 +32593,18 @@ var EmbedSearch = class {
|
|||||||
await import_fs3.promises.mkdir((0, import_path3.dirname)(filePath), { recursive: true });
|
await import_fs3.promises.mkdir((0, import_path3.dirname)(filePath), { recursive: true });
|
||||||
await import_fs3.promises.writeFile(filePath, JSON.stringify({ mtime: entry.mtime, vec: entry.vec }), "utf8");
|
await import_fs3.promises.writeFile(filePath, JSON.stringify({ mtime: entry.mtime, vec: entry.vec }), "utf8");
|
||||||
}
|
}
|
||||||
await this.pruneStale(this.embedDir, allVaultPaths);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Memex] Embedding-Cache konnte nicht gespeichert werden:", e);
|
console.error("[Memex] flushBatch Fehler:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Final save: flush any remaining changed notes, then prune stale .ajson files.
|
||||||
|
*/
|
||||||
|
async saveCache(changed, allVaultPaths) {
|
||||||
|
if (changed.length > 0)
|
||||||
|
await this.flushBatch(changed);
|
||||||
|
await this.pruneStale(this.embedDir, allVaultPaths);
|
||||||
|
}
|
||||||
async pruneStale(dir, allVaultPaths) {
|
async pruneStale(dir, allVaultPaths) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
@@ -32640,6 +32729,7 @@ Wenn du Fragen beantwortest:
|
|||||||
systemContextFile: "",
|
systemContextFile: "",
|
||||||
useEmbeddings: false,
|
useEmbeddings: false,
|
||||||
embeddingModel: "TaylorAI/bge-micro-v2",
|
embeddingModel: "TaylorAI/bge-micro-v2",
|
||||||
|
embedExcludeFolders: [],
|
||||||
promptButtons: [
|
promptButtons: [
|
||||||
{
|
{
|
||||||
label: "Draft Check",
|
label: "Draft Check",
|
||||||
@@ -32667,6 +32757,41 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
|||||||
display() {
|
display() {
|
||||||
const { containerEl } = this;
|
const { containerEl } = this;
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
const allFolders = this.app.vault.getAllFolders().map((f) => f.path).filter((p) => p !== "/").sort();
|
||||||
|
const attachFolderDropdown = (wrap, input, getExcluded, onPick) => {
|
||||||
|
const dropdown = wrap.createDiv("vc-folder-dropdown");
|
||||||
|
dropdown.style.display = "none";
|
||||||
|
const refresh = () => {
|
||||||
|
const q = input.value.toLowerCase();
|
||||||
|
const excluded = getExcluded();
|
||||||
|
const matches = allFolders.filter((f) => f.toLowerCase().includes(q) && !excluded.includes(f)).slice(0, 12);
|
||||||
|
dropdown.empty();
|
||||||
|
if (!matches.length) {
|
||||||
|
dropdown.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of matches) {
|
||||||
|
const item = dropdown.createDiv("vc-folder-item");
|
||||||
|
item.textContent = f;
|
||||||
|
item.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPick(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dropdown.style.display = "block";
|
||||||
|
};
|
||||||
|
input.addEventListener("input", refresh);
|
||||||
|
input.addEventListener("focus", refresh);
|
||||||
|
input.addEventListener("blur", () => setTimeout(() => {
|
||||||
|
dropdown.style.display = "none";
|
||||||
|
}, 150));
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
dropdown.style.display = "none";
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
|
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
|
||||||
containerEl.createEl("h3", { text: "Claude API" });
|
containerEl.createEl("h3", { text: "Claude API" });
|
||||||
new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("Dein Anthropic API Key (sk-ant-...)").addText(
|
new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("Dein Anthropic API Key (sk-ant-...)").addText(
|
||||||
@@ -32716,6 +32841,50 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
|||||||
await this.plugin.initEmbedSearch();
|
await this.plugin.initEmbedSearch();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const exclSetting = new import_obsidian3.Setting(containerEl).setName("Ordner ausschlie\xDFen").setDesc("Diese Ordner werden beim Embedding \xFCbersprungen. Nach \xC4nderung Index neu aufbauen.");
|
||||||
|
exclSetting.settingEl.style.flexWrap = "wrap";
|
||||||
|
exclSetting.settingEl.style.alignItems = "flex-start";
|
||||||
|
const exclTagContainer = exclSetting.controlEl.createDiv("vc-prop-tags");
|
||||||
|
const renderExclTags = () => {
|
||||||
|
exclTagContainer.empty();
|
||||||
|
for (const folder of this.plugin.settings.embedExcludeFolders) {
|
||||||
|
const tag = exclTagContainer.createEl("span", { cls: "vc-prop-tag" });
|
||||||
|
tag.createEl("span", { text: folder });
|
||||||
|
const x = tag.createEl("button", { cls: "vc-prop-tag-remove", text: "\xD7" });
|
||||||
|
x.onclick = async () => {
|
||||||
|
this.plugin.settings.embedExcludeFolders = this.plugin.settings.embedExcludeFolders.filter((f) => f !== folder);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
renderExclTags();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderExclTags();
|
||||||
|
const exclWrap = exclSetting.controlEl.createDiv("vc-folder-search-wrap");
|
||||||
|
const exclInput = exclWrap.createEl("input", {
|
||||||
|
cls: "vc-prop-input",
|
||||||
|
attr: { type: "text", placeholder: "Ordner suchen\u2026" }
|
||||||
|
});
|
||||||
|
const addExclFolder = async (folder) => {
|
||||||
|
folder = folder.trim().replace(/\/$/, "");
|
||||||
|
if (!folder || this.plugin.settings.embedExcludeFolders.includes(folder))
|
||||||
|
return;
|
||||||
|
this.plugin.settings.embedExcludeFolders = [...this.plugin.settings.embedExcludeFolders, folder];
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
exclInput.value = "";
|
||||||
|
renderExclTags();
|
||||||
|
};
|
||||||
|
attachFolderDropdown(
|
||||||
|
exclWrap,
|
||||||
|
exclInput,
|
||||||
|
() => this.plugin.settings.embedExcludeFolders,
|
||||||
|
(f) => addExclFolder(f)
|
||||||
|
);
|
||||||
|
exclInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addExclFolder(exclInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
|
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
|
||||||
new import_obsidian3.Setting(containerEl).setName("Max. Kontext-Notizen").setDesc("Wie viele Notizen werden automatisch als Kontext hinzugef\xFCgt? (1\u201315)").addSlider(
|
new import_obsidian3.Setting(containerEl).setName("Max. Kontext-Notizen").setDesc("Wie viele Notizen werden automatisch als Kontext hinzugef\xFCgt? (1\u201315)").addSlider(
|
||||||
(slider) => slider.setLimits(1, 15, 1).setValue(this.plugin.settings.maxContextNotes).setDynamicTooltip().onChange(async (value) => {
|
(slider) => slider.setLimits(1, 15, 1).setValue(this.plugin.settings.maxContextNotes).setDynamicTooltip().onChange(async (value) => {
|
||||||
@@ -32842,26 +33011,27 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
|||||||
renderFolders();
|
renderFolders();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const folderInput = folderSection.createEl("input", {
|
const folderWrap = folderSection.createDiv("vc-folder-search-wrap");
|
||||||
|
folderWrap.style.width = "200px";
|
||||||
|
const folderInput = folderWrap.createEl("input", {
|
||||||
cls: "vc-pbtn-input",
|
cls: "vc-pbtn-input",
|
||||||
attr: { type: "text", placeholder: "Ordner hinzuf\xFCgen\u2026", style: "width:180px" }
|
attr: { type: "text", placeholder: "Ordner suchen\u2026" }
|
||||||
});
|
});
|
||||||
const doAddFolder = async () => {
|
const doAddFolder = async (val) => {
|
||||||
const val = folderInput.value.trim().replace(/\/$/, "");
|
val = val.trim().replace(/\/$/, "");
|
||||||
if (!val)
|
if (!val || (pb.searchFolders ?? []).includes(val))
|
||||||
return;
|
return;
|
||||||
pb.searchFolders = [...pb.searchFolders ?? [], val];
|
pb.searchFolders = [...pb.searchFolders ?? [], val];
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
renderFolders();
|
renderFolders();
|
||||||
};
|
};
|
||||||
|
attachFolderDropdown(folderWrap, folderInput, () => pb.searchFolders ?? [], (f) => doAddFolder(f));
|
||||||
folderInput.addEventListener("keydown", (e) => {
|
folderInput.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
doAddFolder();
|
doAddFolder(folderInput.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const addFolderBtn = folderSection.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
|
||||||
addFolderBtn.onclick = doAddFolder;
|
|
||||||
};
|
};
|
||||||
renderFolders();
|
renderFolders();
|
||||||
checkbox.addEventListener("change", async () => {
|
checkbox.addEventListener("change", async () => {
|
||||||
@@ -32916,12 +33086,22 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
|||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
new import_obsidian3.Setting(containerEl).setName("Threads-Ordner").setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden").addText(
|
const threadsFolderSetting = new import_obsidian3.Setting(containerEl).setName("Threads-Ordner").setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden");
|
||||||
(text) => text.setPlaceholder("Calendar/Chat").setValue(this.plugin.settings.threadsFolder).onChange(async (value) => {
|
const tfWrap = threadsFolderSetting.controlEl.createDiv("vc-folder-search-wrap");
|
||||||
this.plugin.settings.threadsFolder = value;
|
const tfInput = tfWrap.createEl("input", {
|
||||||
|
cls: "vc-prop-input",
|
||||||
|
attr: { type: "text", placeholder: "Calendar/Chat" }
|
||||||
|
});
|
||||||
|
tfInput.value = this.plugin.settings.threadsFolder;
|
||||||
|
tfInput.addEventListener("input", async () => {
|
||||||
|
this.plugin.settings.threadsFolder = tfInput.value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
});
|
||||||
);
|
attachFolderDropdown(tfWrap, tfInput, () => [], async (f) => {
|
||||||
|
tfInput.value = f;
|
||||||
|
this.plugin.settings.threadsFolder = f;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
containerEl.createEl("h3", { text: "System Prompt" });
|
containerEl.createEl("h3", { text: "System Prompt" });
|
||||||
new import_obsidian3.Setting(containerEl).setName("System Prompt").setDesc("Instruktionen f\xFCr Claude (wie soll er sich verhalten?)").addTextArea((textarea) => {
|
new import_obsidian3.Setting(containerEl).setName("System Prompt").setDesc("Instruktionen f\xFCr Claude (wie soll er sich verhalten?)").addTextArea((textarea) => {
|
||||||
textarea.setValue(this.plugin.settings.systemPrompt).onChange(async (value) => {
|
textarea.setValue(this.plugin.settings.systemPrompt).onChange(async (value) => {
|
||||||
@@ -32955,8 +33135,89 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// src/RelatedNotesView.ts
|
||||||
|
var import_obsidian4 = require("obsidian");
|
||||||
|
var VIEW_TYPE_RELATED = "memex-related-notes";
|
||||||
|
var RelatedNotesView = class extends import_obsidian4.ItemView {
|
||||||
|
constructor(leaf, plugin) {
|
||||||
|
super(leaf);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
getViewType() {
|
||||||
|
return VIEW_TYPE_RELATED;
|
||||||
|
}
|
||||||
|
getDisplayText() {
|
||||||
|
return "Verwandte Notizen";
|
||||||
|
}
|
||||||
|
getIcon() {
|
||||||
|
return "sparkles";
|
||||||
|
}
|
||||||
|
async onOpen() {
|
||||||
|
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh()));
|
||||||
|
this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh()));
|
||||||
|
this.render([]);
|
||||||
|
this.scheduleRefresh();
|
||||||
|
}
|
||||||
|
scheduleRefresh(delay = 400) {
|
||||||
|
if (this.refreshTimer)
|
||||||
|
clearTimeout(this.refreshTimer);
|
||||||
|
this.refreshTimer = setTimeout(() => this.refresh(), delay);
|
||||||
|
}
|
||||||
|
/** Called by the plugin when the embedding index finishes building. */
|
||||||
|
onIndexReady() {
|
||||||
|
this.scheduleRefresh(0);
|
||||||
|
}
|
||||||
|
async refresh() {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (!file || file.extension !== "md")
|
||||||
|
return;
|
||||||
|
const es = this.plugin.embedSearch;
|
||||||
|
if (!es || !es.isIndexed()) {
|
||||||
|
this.renderStatus("Embedding-Index wird aufgebaut\u2026");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.renderStatus("Suche verwandte Notizen\u2026");
|
||||||
|
const results = await es.searchSimilarToFile(file);
|
||||||
|
this.render(results, file.basename);
|
||||||
|
}
|
||||||
|
renderStatus(msg) {
|
||||||
|
this.contentEl.empty();
|
||||||
|
this.contentEl.createDiv({ cls: "vc-related-status", text: msg });
|
||||||
|
}
|
||||||
|
render(results, forNote) {
|
||||||
|
this.contentEl.empty();
|
||||||
|
const header = this.contentEl.createDiv("vc-related-header");
|
||||||
|
header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" });
|
||||||
|
if (forNote)
|
||||||
|
header.createDiv({ cls: "vc-related-subtitle", text: forNote });
|
||||||
|
if (!results.length) {
|
||||||
|
this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = this.contentEl.createDiv("vc-related-list");
|
||||||
|
for (const r of results) {
|
||||||
|
const item = list.createDiv("vc-related-item");
|
||||||
|
const info = item.createDiv("vc-related-info");
|
||||||
|
info.createDiv({ cls: "vc-related-name", text: r.title });
|
||||||
|
const folder = r.file.parent?.path;
|
||||||
|
if (folder && folder !== "/") {
|
||||||
|
info.createDiv({ cls: "vc-related-folder", text: folder });
|
||||||
|
}
|
||||||
|
const scoreWrap = item.createDiv("vc-related-score-wrap");
|
||||||
|
const pct = Math.round(r.score * 100);
|
||||||
|
const bar = scoreWrap.createDiv("vc-related-bar");
|
||||||
|
bar.createDiv({ cls: "vc-related-bar-fill" }).style.width = `${pct}%`;
|
||||||
|
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` });
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
this.app.workspace.openLinkText(r.file.path, r.file.path, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// src/main.ts
|
// src/main.ts
|
||||||
var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
var MemexChatPlugin = class extends import_obsidian5.Plugin {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
this.embedSearch = null;
|
this.embedSearch = null;
|
||||||
@@ -32982,14 +33243,23 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
|||||||
this.search = new VaultSearch(this.app);
|
this.search = new VaultSearch(this.app);
|
||||||
this.claude = new ClaudeClient();
|
this.claude = new ClaudeClient();
|
||||||
this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this));
|
this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this));
|
||||||
|
this.registerView(VIEW_TYPE_RELATED, (leaf) => new RelatedNotesView(leaf, this));
|
||||||
this.addRibbonIcon("message-circle", "Memex Chat \xF6ffnen", () => {
|
this.addRibbonIcon("message-circle", "Memex Chat \xF6ffnen", () => {
|
||||||
this.activateView();
|
this.activateView();
|
||||||
});
|
});
|
||||||
|
this.addRibbonIcon("sparkles", "Verwandte Notizen", () => {
|
||||||
|
this.activateRelatedView();
|
||||||
|
});
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "open-memex-chat",
|
id: "open-memex-chat",
|
||||||
name: "Memex Chat \xF6ffnen",
|
name: "Memex Chat \xF6ffnen",
|
||||||
callback: () => this.activateView()
|
callback: () => this.activateView()
|
||||||
});
|
});
|
||||||
|
this.addCommand({
|
||||||
|
id: "memex-related-notes",
|
||||||
|
name: "Verwandte Notizen anzeigen",
|
||||||
|
callback: () => this.activateRelatedView()
|
||||||
|
});
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "memex-chat-rebuild-index",
|
id: "memex-chat-rebuild-index",
|
||||||
name: "Memex Chat: Index neu aufbauen",
|
name: "Memex Chat: Index neu aufbauen",
|
||||||
@@ -33039,6 +33309,23 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
|||||||
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
|
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
|
||||||
this.app.workspace.revealLeaf(leaf);
|
this.app.workspace.revealLeaf(leaf);
|
||||||
}
|
}
|
||||||
|
async activateRelatedView() {
|
||||||
|
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
this.app.workspace.revealLeaf(existing[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leaf = this.app.workspace.getRightLeaf(false);
|
||||||
|
if (!leaf)
|
||||||
|
return;
|
||||||
|
await leaf.setViewState({ type: VIEW_TYPE_RELATED, active: true });
|
||||||
|
this.app.workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
notifyRelatedView() {
|
||||||
|
this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED).forEach((l) => {
|
||||||
|
l.view.onIndexReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
/** Create or recreate the EmbedSearch instance (called when settings change) */
|
/** Create or recreate the EmbedSearch instance (called when settings change) */
|
||||||
async initEmbedSearch() {
|
async initEmbedSearch() {
|
||||||
if (!this.settings.useEmbeddings) {
|
if (!this.settings.useEmbeddings) {
|
||||||
@@ -33046,7 +33333,37 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
|
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
|
||||||
this.embedSearch.buildIndex().catch(console.error);
|
this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? [];
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.vault.on("modify", (file) => {
|
||||||
|
if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md")
|
||||||
|
this.embedSearch.reembedFile(file);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const notice = new import_obsidian5.Notice("Memex: Embedding wird vorbereitet\u2026", 0);
|
||||||
|
this.embedSearch.onModelStatus = (status) => {
|
||||||
|
notice.setMessage(`Memex: ${status}`);
|
||||||
|
};
|
||||||
|
this.embedSearch.onProgress = (done, total, speed) => {
|
||||||
|
const speedStr = speed > 0 ? ` \u2022 ${speed.toFixed(1)} N/s` : "";
|
||||||
|
const remaining = speed > 0 && done < total ? (total - done) / speed : 0;
|
||||||
|
const eta = remaining > 0 ? ` \u2022 ~${remaining < 60 ? Math.ceil(remaining) + "s" : Math.ceil(remaining / 60) + "min"}` : "";
|
||||||
|
notice.setMessage(`Memex Embedding: ${done}/${total}${speedStr}${eta}`);
|
||||||
|
};
|
||||||
|
this.waitForSyncIdle(notice).then(() => this.embedSearch?.buildIndex()).then(() => {
|
||||||
|
notice.setMessage(`\u2713 Memex: ${this.app.vault.getMarkdownFiles().length} Notizen eingebettet`);
|
||||||
|
setTimeout(() => notice.hide(), 4e3);
|
||||||
|
this.notifyRelatedView();
|
||||||
|
}).catch((e) => {
|
||||||
|
notice.setMessage(`\u2717 Memex Embedding: ${e.message}`);
|
||||||
|
setTimeout(() => notice.hide(), 6e3);
|
||||||
|
console.error(e);
|
||||||
|
}).finally(() => {
|
||||||
|
if (this.embedSearch) {
|
||||||
|
this.embedSearch.onProgress = void 0;
|
||||||
|
this.embedSearch.onModelStatus = void 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async rebuildIndex() {
|
async rebuildIndex() {
|
||||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
|
||||||
@@ -33081,6 +33398,46 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
|||||||
setTimeout(() => view.setStatus(""), 3e3);
|
setTimeout(() => view.setStatus(""), 3e3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Waits until Obsidian Sync is idle.
|
||||||
|
* Strategy: watch for vault changes; if activity stops for 15 s, sync is done.
|
||||||
|
* If no activity within the first 5 s, sync isn't running — return immediately.
|
||||||
|
* Falls back after 5 minutes regardless.
|
||||||
|
*/
|
||||||
|
async waitForSyncIdle(notice) {
|
||||||
|
const syncPlugin = this.app.internalPlugins?.plugins?.["sync"]?.instance;
|
||||||
|
if (!syncPlugin)
|
||||||
|
return;
|
||||||
|
const PROBE_MS = 5e3;
|
||||||
|
const QUIET_MS = 15e3;
|
||||||
|
const MAX_MS = 5 * 6e4;
|
||||||
|
let lastChange = 0;
|
||||||
|
let activitySeen = false;
|
||||||
|
const tick = () => {
|
||||||
|
lastChange = Date.now();
|
||||||
|
activitySeen = true;
|
||||||
|
};
|
||||||
|
this.app.vault.on("create", tick);
|
||||||
|
this.app.vault.on("modify", tick);
|
||||||
|
this.app.vault.on("delete", tick);
|
||||||
|
try {
|
||||||
|
notice.setMessage("Memex: Pr\xFCfe Sync-Status\u2026");
|
||||||
|
await new Promise((r) => setTimeout(r, PROBE_MS));
|
||||||
|
if (!activitySeen)
|
||||||
|
return;
|
||||||
|
notice.setMessage("Memex: Warte auf Obsidian Sync\u2026");
|
||||||
|
const deadline = Date.now() + MAX_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2e3));
|
||||||
|
if (Date.now() - lastChange >= QUIET_MS)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.app.vault.off("create", tick);
|
||||||
|
this.app.vault.off("modify", tick);
|
||||||
|
this.app.vault.off("delete", tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
this.data.settings = this.settings;
|
this.data.settings = this.settings;
|
||||||
await this.saveData(this.data);
|
await this.saveData(this.data);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "memex-chat",
|
"id": "memex-chat",
|
||||||
"name": "Memex Chat",
|
"name": "Memex Chat",
|
||||||
"version": "0.3.0",
|
"version": "1.0.0",
|
||||||
"minAppVersion": "1.4.0",
|
"minAppVersion": "1.4.0",
|
||||||
"description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.",
|
"description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.",
|
||||||
"author": "Sven",
|
"author": "Sven",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "memex-chat",
|
"name": "memex-chat",
|
||||||
"version": "0.3.0",
|
"version": "1.0.0",
|
||||||
"description": "Obsidian plugin: Chat with your vault using Claude AI",
|
"description": "Obsidian plugin: Chat with your vault using Claude AI",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+103
-18
@@ -26,6 +26,7 @@ interface Manifest { model: string; version: number }
|
|||||||
export class EmbedSearch {
|
export class EmbedSearch {
|
||||||
private app: App;
|
private app: App;
|
||||||
private modelId: string;
|
private modelId: string;
|
||||||
|
excludeFolders: string[] = []; // vault folder prefixes to skip
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private pipe: ((text: string, opts: object) => Promise<{ data: Float32Array }>) | null = null;
|
private pipe: ((text: string, opts: object) => Promise<{ data: Float32Array }>) | null = null;
|
||||||
private cache: Map<string, EmbedCacheEntry> = new Map(); // vaultPath → entry
|
private cache: Map<string, EmbedCacheEntry> = new Map(); // vaultPath → entry
|
||||||
@@ -112,11 +113,24 @@ export class EmbedSearch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async embed(text: string): Promise<number[]> {
|
private async embed(text: string): Promise<number[]> {
|
||||||
|
console.log("[Memex] embed: loadPipeline…");
|
||||||
await this.loadPipeline();
|
await this.loadPipeline();
|
||||||
|
console.log("[Memex] embed: pipe call…");
|
||||||
const result = await this.pipe!(text.slice(0, 512), { pooling: "mean", normalize: true });
|
const result = await this.pipe!(text.slice(0, 512), { pooling: "mean", normalize: true });
|
||||||
|
console.log("[Memex] embed: done, dims:", result.data.length);
|
||||||
return Array.from(result.data);
|
return Array.from(result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** embed() with a hard timeout; rejects with "embed timeout" if exceeded. */
|
||||||
|
private embedWithTimeout(text: string, ms = 13000): Promise<number[]> {
|
||||||
|
return Promise.race([
|
||||||
|
this.embed(text),
|
||||||
|
new Promise<number[]>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("embed timeout")), ms)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private cosine(a: number[], b: number[]): number {
|
private cosine(a: number[], b: number[]): number {
|
||||||
let dot = 0;
|
let dot = 0;
|
||||||
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
||||||
@@ -126,6 +140,7 @@ export class EmbedSearch {
|
|||||||
// ─── Index ────────────────────────────────────────────────────────────────
|
// ─── Index ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async buildIndex(): Promise<void> {
|
async buildIndex(): Promise<void> {
|
||||||
|
console.log("[Memex] buildIndex START, indexing:", this.indexing);
|
||||||
if (this.indexing) return;
|
if (this.indexing) return;
|
||||||
this.indexing = true;
|
this.indexing = true;
|
||||||
this.indexed = false;
|
this.indexed = false;
|
||||||
@@ -138,15 +153,21 @@ export class EmbedSearch {
|
|||||||
try {
|
try {
|
||||||
await fsp.mkdir(this.modelsDir, { recursive: true });
|
await fsp.mkdir(this.modelsDir, { recursive: true });
|
||||||
await fsp.mkdir(this.embedDir, { recursive: true });
|
await fsp.mkdir(this.embedDir, { recursive: true });
|
||||||
|
console.log("[Memex] Verzeichnisse OK:", this.embedDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Memex] Verzeichnisse konnten nicht angelegt werden:", e);
|
console.error("[Memex] Verzeichnisse konnten nicht angelegt werden:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.loadCache();
|
await this.loadCache();
|
||||||
|
console.log("[Memex] Cache geladen, Einträge:", this.cache.size);
|
||||||
|
|
||||||
const files = this.app.vault.getMarkdownFiles();
|
const allFiles = this.app.vault.getMarkdownFiles();
|
||||||
|
const files = this.excludeFolders.length
|
||||||
|
? allFiles.filter((f) => !this.excludeFolders.some((ex) => f.path.startsWith(ex + "/")))
|
||||||
|
: allFiles;
|
||||||
const total = files.length;
|
const total = files.length;
|
||||||
|
console.log("[Memex] Dateien gesamt:", total, "(ausgeschlossen:", allFiles.length - total, ")");
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let windowStart = Date.now();
|
let windowStart = Date.now();
|
||||||
let windowEmbedded = 0;
|
let windowEmbedded = 0;
|
||||||
@@ -165,11 +186,16 @@ export class EmbedSearch {
|
|||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
const raw = await this.app.vault.cachedRead(file);
|
const raw = await this.app.vault.cachedRead(file);
|
||||||
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
||||||
const vec = await this.embed(text);
|
// First call initialises WASM + loads model — allow extra time
|
||||||
|
const vec = await this.embedWithTimeout(text, this.pipe ? 13000 : 120000);
|
||||||
this.cache.set(file.path, { mtime, vec });
|
this.cache.set(file.path, { mtime, vec });
|
||||||
this.vecs.set(file.path, { vec, file });
|
this.vecs.set(file.path, { vec, file });
|
||||||
changed.push(file.path);
|
changed.push(file.path);
|
||||||
windowEmbedded++;
|
windowEmbedded++;
|
||||||
|
if (changed.length === 1 || changed.length % 50 === 0)
|
||||||
|
console.log(`[Memex] Eingebettet: ${changed.length}/${total}`);
|
||||||
|
// Flush newly embedded notes to disk every 100 to preserve progress
|
||||||
|
if (changed.length % 100 === 0) await this.flushBatch(changed.slice(-100));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!this.pipe && !pipelineError) {
|
if (!this.pipe && !pipelineError) {
|
||||||
// Pipeline failed to load — log once and abort embedding loop
|
// Pipeline failed to load — log once and abort embedding loop
|
||||||
@@ -177,6 +203,7 @@ export class EmbedSearch {
|
|||||||
console.error("[Memex] Pipeline-Ladefehler:", e);
|
console.error("[Memex] Pipeline-Ladefehler:", e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
console.warn("[Memex] Datei übersprungen:", file.path, e);
|
||||||
// skip individual file
|
// skip individual file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,17 +219,78 @@ export class EmbedSearch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[Memex] Loop fertig, changed:", changed.length, "pipelineError:", !!pipelineError);
|
||||||
if (pipelineError) throw pipelineError;
|
if (pipelineError) throw pipelineError;
|
||||||
|
|
||||||
const allPaths = new Set(files.map((f) => f.path));
|
const allPaths = new Set(files.map((f) => f.path));
|
||||||
await this.saveCache(changed, allPaths);
|
// Flush remainder (notes not yet flushed by the every-100 batches)
|
||||||
|
const remainder = changed.length % 100;
|
||||||
|
await this.saveCache(remainder > 0 ? changed.slice(-remainder) : [], allPaths);
|
||||||
this.indexed = true;
|
this.indexed = true;
|
||||||
if (this.onProgress) this.onProgress(total, total, speed);
|
if (this.onProgress) this.onProgress(total, total, speed);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Memex] buildIndex Fehler:", e);
|
||||||
} finally {
|
} finally {
|
||||||
this.indexing = false;
|
this.indexing = false;
|
||||||
|
console.log("[Memex] buildIndex END, indexed:", this.indexed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Incremental re-embed on file change ─────────────────────────────────
|
||||||
|
|
||||||
|
private reembedTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced re-embed for a single file (called on vault modify events).
|
||||||
|
* Waits 2 s after the last write before embedding.
|
||||||
|
*/
|
||||||
|
reembedFile(file: TFile): void {
|
||||||
|
if (!this.indexed || this.indexing) return;
|
||||||
|
const existing = this.reembedTimers.get(file.path);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
this.reembedTimers.delete(file.path);
|
||||||
|
try {
|
||||||
|
const raw = await this.app.vault.cachedRead(file);
|
||||||
|
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
||||||
|
const vec = await this.embedWithTimeout(text);
|
||||||
|
const mtime = file.stat.mtime;
|
||||||
|
this.cache.set(file.path, { mtime, vec });
|
||||||
|
this.vecs.set(file.path, { vec, file });
|
||||||
|
await this.saveCache([file.path], new Set(this.vecs.keys()));
|
||||||
|
console.log("[Memex] Re-embedded:", file.path);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Memex] Re-embed fehlgeschlagen:", file.path, e);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
this.reembedTimers.set(file.path, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find notes similar to a given file using its cached vector (no re-embedding). */
|
||||||
|
async searchSimilarToFile(file: TFile, topK = 10): Promise<SearchResult[]> {
|
||||||
|
if (!this.indexed) return [];
|
||||||
|
let qvec = this.vecs.get(file.path)?.vec;
|
||||||
|
if (!qvec) {
|
||||||
|
// File not yet indexed — embed on the fly
|
||||||
|
try {
|
||||||
|
const raw = await this.app.vault.cachedRead(file);
|
||||||
|
const text = this.preprocess(raw).slice(0, 800) + " " + file.basename;
|
||||||
|
qvec = await this.embedWithTimeout(text);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
const scores: Array<[string, number]> = [];
|
||||||
|
for (const [path, { vec }] of this.vecs) {
|
||||||
|
if (path === file.path) continue;
|
||||||
|
const s = this.cosine(qvec, vec);
|
||||||
|
if (s > 0.2) scores.push([path, s]);
|
||||||
|
}
|
||||||
|
scores.sort((a, b) => b[1] - a[1]);
|
||||||
|
return scores.slice(0, topK).map(([path, score]) => {
|
||||||
|
const { file: f } = this.vecs.get(path)!;
|
||||||
|
return { file: f, score, excerpt: "", title: f.basename };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async search(query: string, topK = 8): Promise<SearchResult[]> {
|
async search(query: string, topK = 8): Promise<SearchResult[]> {
|
||||||
if (!this.indexed) await this.buildIndex();
|
if (!this.indexed) await this.buildIndex();
|
||||||
|
|
||||||
@@ -265,34 +353,31 @@ export class EmbedSearch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Write .ajson files for a batch of vault paths (no pruning). Called incrementally. */
|
||||||
* Write .ajson for each newly embedded note; delete .ajson for removed notes;
|
private async flushBatch(vaultPaths: string[]): Promise<void> {
|
||||||
* write/update the manifest.
|
|
||||||
*/
|
|
||||||
private async saveCache(changed: string[], allVaultPaths: Set<string>): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await fsp.mkdir(this.embedDir, { recursive: true });
|
|
||||||
|
|
||||||
// Manifest
|
|
||||||
const manifest: Manifest = { model: this.modelId, version: 1 };
|
const manifest: Manifest = { model: this.modelId, version: 1 };
|
||||||
await fsp.writeFile(this.manifestPath, JSON.stringify(manifest), "utf8");
|
await fsp.writeFile(this.manifestPath, JSON.stringify(manifest), "utf8");
|
||||||
|
for (const vaultPath of vaultPaths) {
|
||||||
// Write only the newly embedded notes
|
|
||||||
for (const vaultPath of changed) {
|
|
||||||
const entry = this.cache.get(vaultPath);
|
const entry = this.cache.get(vaultPath);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
const filePath = this.noteEmbedPath(vaultPath);
|
const filePath = this.noteEmbedPath(vaultPath);
|
||||||
await fsp.mkdir(dirname(filePath), { recursive: true });
|
await fsp.mkdir(dirname(filePath), { recursive: true });
|
||||||
await fsp.writeFile(filePath, JSON.stringify({ mtime: entry.mtime, vec: entry.vec }), "utf8");
|
await fsp.writeFile(filePath, JSON.stringify({ mtime: entry.mtime, vec: entry.vec }), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune .ajson files whose notes no longer exist
|
|
||||||
await this.pruneStale(this.embedDir, allVaultPaths);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Memex] Embedding-Cache konnte nicht gespeichert werden:", e);
|
console.error("[Memex] flushBatch Fehler:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final save: flush any remaining changed notes, then prune stale .ajson files.
|
||||||
|
*/
|
||||||
|
private async saveCache(changed: string[], allVaultPaths: Set<string>): Promise<void> {
|
||||||
|
if (changed.length > 0) await this.flushBatch(changed);
|
||||||
|
await this.pruneStale(this.embedDir, allVaultPaths);
|
||||||
|
}
|
||||||
|
|
||||||
private async pruneStale(dir: string, allVaultPaths: Set<string>): Promise<void> {
|
private async pruneStale(dir: string, allVaultPaths: Set<string>): Promise<void> {
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
|
||||||
|
import type MemexChatPlugin from "./main";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_RELATED = "memex-related-notes";
|
||||||
|
|
||||||
|
export class RelatedNotesView extends ItemView {
|
||||||
|
private plugin: MemexChatPlugin;
|
||||||
|
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) {
|
||||||
|
super(leaf);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType() { return VIEW_TYPE_RELATED; }
|
||||||
|
getDisplayText() { return "Verwandte Notizen"; }
|
||||||
|
getIcon() { return "sparkles"; }
|
||||||
|
|
||||||
|
async onOpen(): Promise<void> {
|
||||||
|
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh()));
|
||||||
|
this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh()));
|
||||||
|
this.render([]);
|
||||||
|
this.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRefresh(delay = 400) {
|
||||||
|
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||||
|
this.refreshTimer = setTimeout(() => this.refresh(), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the plugin when the embedding index finishes building. */
|
||||||
|
onIndexReady() { this.scheduleRefresh(0); }
|
||||||
|
|
||||||
|
private async refresh() {
|
||||||
|
const file = this.app.workspace.getActiveFile();
|
||||||
|
if (!file || file.extension !== "md") return;
|
||||||
|
|
||||||
|
const es = this.plugin.embedSearch;
|
||||||
|
if (!es || !es.isIndexed()) {
|
||||||
|
this.renderStatus("Embedding-Index wird aufgebaut…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderStatus("Suche verwandte Notizen…");
|
||||||
|
const results = await es.searchSimilarToFile(file);
|
||||||
|
this.render(results, file.basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatus(msg: string) {
|
||||||
|
this.contentEl.empty();
|
||||||
|
this.contentEl.createDiv({ cls: "vc-related-status", text: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(results: Array<{ file: TFile; score: number; title: string }>, forNote?: string) {
|
||||||
|
this.contentEl.empty();
|
||||||
|
|
||||||
|
const header = this.contentEl.createDiv("vc-related-header");
|
||||||
|
header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" });
|
||||||
|
if (forNote) header.createDiv({ cls: "vc-related-subtitle", text: forNote });
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = this.contentEl.createDiv("vc-related-list");
|
||||||
|
for (const r of results) {
|
||||||
|
const item = list.createDiv("vc-related-item");
|
||||||
|
|
||||||
|
const info = item.createDiv("vc-related-info");
|
||||||
|
info.createDiv({ cls: "vc-related-name", text: r.title });
|
||||||
|
|
||||||
|
// Folder path (dimmed)
|
||||||
|
const folder = r.file.parent?.path;
|
||||||
|
if (folder && folder !== "/") {
|
||||||
|
info.createDiv({ cls: "vc-related-folder", text: folder });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarity bar + percentage
|
||||||
|
const scoreWrap = item.createDiv("vc-related-score-wrap");
|
||||||
|
const pct = Math.round(r.score * 100);
|
||||||
|
const bar = scoreWrap.createDiv("vc-related-bar");
|
||||||
|
bar.createDiv({ cls: "vc-related-bar-fill" }).style.width = `${pct}%`;
|
||||||
|
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` });
|
||||||
|
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
this.app.workspace.openLinkText(r.file.path, r.file.path, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-18
@@ -27,6 +27,7 @@ export interface MemexChatSettings {
|
|||||||
systemContextFile: string; // optional vault path for extended system context
|
systemContextFile: string; // optional vault path for extended system context
|
||||||
useEmbeddings: boolean; // use local embedding model instead of TF-IDF
|
useEmbeddings: boolean; // use local embedding model instead of TF-IDF
|
||||||
embeddingModel: string; // HuggingFace model ID
|
embeddingModel: string; // HuggingFace model ID
|
||||||
|
embedExcludeFolders: string[]; // vault folders to skip during embedding
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: MemexChatSettings = {
|
export const DEFAULT_SETTINGS: MemexChatSettings = {
|
||||||
@@ -52,6 +53,7 @@ Wenn du Fragen beantwortest:
|
|||||||
systemContextFile: "",
|
systemContextFile: "",
|
||||||
useEmbeddings: false,
|
useEmbeddings: false,
|
||||||
embeddingModel: "TaylorAI/bge-micro-v2",
|
embeddingModel: "TaylorAI/bge-micro-v2",
|
||||||
|
embedExcludeFolders: [],
|
||||||
promptButtons: [
|
promptButtons: [
|
||||||
{
|
{
|
||||||
label: "Draft Check",
|
label: "Draft Check",
|
||||||
@@ -85,6 +87,44 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
|||||||
const { containerEl } = this;
|
const { containerEl } = this;
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
|
||||||
|
// Sorted vault folder list — used by all folder autocompletes in this settings page
|
||||||
|
const allFolders = this.app.vault.getAllFolders()
|
||||||
|
.map((f) => f.path)
|
||||||
|
.filter((p) => p !== "/")
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
/** Attaches a folder-search dropdown to a wrapper element. onPick is called with the selected folder. */
|
||||||
|
const attachFolderDropdown = (
|
||||||
|
wrap: HTMLElement,
|
||||||
|
input: HTMLInputElement,
|
||||||
|
getExcluded: () => string[],
|
||||||
|
onPick: (folder: string) => void,
|
||||||
|
) => {
|
||||||
|
const dropdown = wrap.createDiv("vc-folder-dropdown");
|
||||||
|
dropdown.style.display = "none";
|
||||||
|
const refresh = () => {
|
||||||
|
const q = input.value.toLowerCase();
|
||||||
|
const excluded = getExcluded();
|
||||||
|
const matches = allFolders
|
||||||
|
.filter((f) => f.toLowerCase().includes(q) && !excluded.includes(f))
|
||||||
|
.slice(0, 12);
|
||||||
|
dropdown.empty();
|
||||||
|
if (!matches.length) { dropdown.style.display = "none"; return; }
|
||||||
|
for (const f of matches) {
|
||||||
|
const item = dropdown.createDiv("vc-folder-item");
|
||||||
|
item.textContent = f;
|
||||||
|
item.addEventListener("mousedown", (e) => { e.preventDefault(); onPick(f); });
|
||||||
|
}
|
||||||
|
dropdown.style.display = "block";
|
||||||
|
};
|
||||||
|
input.addEventListener("input", refresh);
|
||||||
|
input.addEventListener("focus", refresh);
|
||||||
|
input.addEventListener("blur", () => setTimeout(() => { dropdown.style.display = "none"; }, 150));
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") { dropdown.style.display = "none"; input.blur(); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
|
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
|
||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
@@ -168,6 +208,52 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Exclude folders from embedding
|
||||||
|
const exclSetting = new Setting(containerEl)
|
||||||
|
.setName("Ordner ausschließen")
|
||||||
|
.setDesc("Diese Ordner werden beim Embedding übersprungen. Nach Änderung Index neu aufbauen.");
|
||||||
|
exclSetting.settingEl.style.flexWrap = "wrap";
|
||||||
|
exclSetting.settingEl.style.alignItems = "flex-start";
|
||||||
|
|
||||||
|
const exclTagContainer = exclSetting.controlEl.createDiv("vc-prop-tags");
|
||||||
|
const renderExclTags = () => {
|
||||||
|
exclTagContainer.empty();
|
||||||
|
for (const folder of this.plugin.settings.embedExcludeFolders) {
|
||||||
|
const tag = exclTagContainer.createEl("span", { cls: "vc-prop-tag" });
|
||||||
|
tag.createEl("span", { text: folder });
|
||||||
|
const x = tag.createEl("button", { cls: "vc-prop-tag-remove", text: "×" });
|
||||||
|
x.onclick = async () => {
|
||||||
|
this.plugin.settings.embedExcludeFolders =
|
||||||
|
this.plugin.settings.embedExcludeFolders.filter((f) => f !== folder);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
renderExclTags();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderExclTags();
|
||||||
|
|
||||||
|
const exclWrap = exclSetting.controlEl.createDiv("vc-folder-search-wrap");
|
||||||
|
const exclInput = exclWrap.createEl("input", {
|
||||||
|
cls: "vc-prop-input",
|
||||||
|
attr: { type: "text", placeholder: "Ordner suchen…" },
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
|
||||||
|
const addExclFolder = async (folder: string) => {
|
||||||
|
folder = folder.trim().replace(/\/$/, "");
|
||||||
|
if (!folder || this.plugin.settings.embedExcludeFolders.includes(folder)) return;
|
||||||
|
this.plugin.settings.embedExcludeFolders = [...this.plugin.settings.embedExcludeFolders, folder];
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
exclInput.value = "";
|
||||||
|
renderExclTags();
|
||||||
|
};
|
||||||
|
attachFolderDropdown(exclWrap, exclInput,
|
||||||
|
() => this.plugin.settings.embedExcludeFolders,
|
||||||
|
(f) => addExclFolder(f),
|
||||||
|
);
|
||||||
|
exclInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); addExclFolder(exclInput.value); }
|
||||||
|
});
|
||||||
|
|
||||||
// --- Context ---
|
// --- Context ---
|
||||||
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
|
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
|
||||||
|
|
||||||
@@ -334,20 +420,23 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
|||||||
renderFolders();
|
renderFolders();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const folderInput = folderSection.createEl("input", {
|
const folderWrap = folderSection.createDiv("vc-folder-search-wrap");
|
||||||
|
folderWrap.style.width = "200px";
|
||||||
|
const folderInput = folderWrap.createEl("input", {
|
||||||
cls: "vc-pbtn-input",
|
cls: "vc-pbtn-input",
|
||||||
attr: { type: "text", placeholder: "Ordner hinzufügen…", style: "width:180px" },
|
attr: { type: "text", placeholder: "Ordner suchen…" },
|
||||||
}) as HTMLInputElement;
|
}) as HTMLInputElement;
|
||||||
const doAddFolder = async () => {
|
const doAddFolder = async (val: string) => {
|
||||||
const val = folderInput.value.trim().replace(/\/$/, "");
|
val = val.trim().replace(/\/$/, "");
|
||||||
if (!val) return;
|
if (!val || (pb.searchFolders ?? []).includes(val)) return;
|
||||||
pb.searchFolders = [...(pb.searchFolders ?? []), val];
|
pb.searchFolders = [...(pb.searchFolders ?? []), val];
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
renderFolders();
|
renderFolders();
|
||||||
};
|
};
|
||||||
folderInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doAddFolder(); } });
|
attachFolderDropdown(folderWrap, folderInput, () => pb.searchFolders ?? [], (f) => doAddFolder(f));
|
||||||
const addFolderBtn = folderSection.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
folderInput.addEventListener("keydown", (e) => {
|
||||||
addFolderBtn.onclick = doAddFolder;
|
if (e.key === "Enter") { e.preventDefault(); doAddFolder(folderInput.value); }
|
||||||
|
});
|
||||||
};
|
};
|
||||||
renderFolders();
|
renderFolders();
|
||||||
|
|
||||||
@@ -413,18 +502,24 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
const threadsFolderSetting = new Setting(containerEl)
|
||||||
.setName("Threads-Ordner")
|
.setName("Threads-Ordner")
|
||||||
.setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden")
|
.setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden");
|
||||||
.addText((text) =>
|
const tfWrap = threadsFolderSetting.controlEl.createDiv("vc-folder-search-wrap");
|
||||||
text
|
const tfInput = tfWrap.createEl("input", {
|
||||||
.setPlaceholder("Calendar/Chat")
|
cls: "vc-prop-input",
|
||||||
.setValue(this.plugin.settings.threadsFolder)
|
attr: { type: "text", placeholder: "Calendar/Chat" },
|
||||||
.onChange(async (value) => {
|
}) as HTMLInputElement;
|
||||||
this.plugin.settings.threadsFolder = value;
|
tfInput.value = this.plugin.settings.threadsFolder;
|
||||||
|
tfInput.addEventListener("input", async () => {
|
||||||
|
this.plugin.settings.threadsFolder = tfInput.value;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
});
|
||||||
);
|
attachFolderDropdown(tfWrap, tfInput, () => [], async (f) => {
|
||||||
|
tfInput.value = f;
|
||||||
|
this.plugin.settings.threadsFolder = f;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
// --- System Prompt ---
|
// --- System Prompt ---
|
||||||
containerEl.createEl("h3", { text: "System Prompt" });
|
containerEl.createEl("h3", { text: "System Prompt" });
|
||||||
|
|||||||
+116
-9
@@ -1,9 +1,10 @@
|
|||||||
import { Plugin, WorkspaceLeaf } from "obsidian";
|
import { Notice, Plugin, TFile, WorkspaceLeaf } from "obsidian";
|
||||||
import { ChatView, VIEW_TYPE_MEMEX_CHAT } from "./ChatView";
|
import { ChatView, VIEW_TYPE_MEMEX_CHAT } from "./ChatView";
|
||||||
import { VaultSearch } from "./VaultSearch";
|
import { VaultSearch } from "./VaultSearch";
|
||||||
import { EmbedSearch } from "./EmbedSearch";
|
import { EmbedSearch } from "./EmbedSearch";
|
||||||
import { ClaudeClient } from "./ClaudeClient";
|
import { ClaudeClient } from "./ClaudeClient";
|
||||||
import { MemexChatSettingsTab, MemexChatSettings, DEFAULT_SETTINGS } from "./SettingsTab";
|
import { MemexChatSettingsTab, MemexChatSettings, DEFAULT_SETTINGS } from "./SettingsTab";
|
||||||
|
import { RelatedNotesView, VIEW_TYPE_RELATED } from "./RelatedNotesView";
|
||||||
|
|
||||||
interface PluginData {
|
interface PluginData {
|
||||||
settings: MemexChatSettings;
|
settings: MemexChatSettings;
|
||||||
@@ -43,13 +44,17 @@ export default class MemexChatPlugin extends Plugin {
|
|||||||
this.search = new VaultSearch(this.app);
|
this.search = new VaultSearch(this.app);
|
||||||
this.claude = new ClaudeClient();
|
this.claude = new ClaudeClient();
|
||||||
|
|
||||||
// Register view
|
// Register views
|
||||||
this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this));
|
this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this));
|
||||||
|
this.registerView(VIEW_TYPE_RELATED, (leaf) => new RelatedNotesView(leaf, this));
|
||||||
|
|
||||||
// Ribbon icon
|
// Ribbon icons
|
||||||
this.addRibbonIcon("message-circle", "Memex Chat öffnen", () => {
|
this.addRibbonIcon("message-circle", "Memex Chat öffnen", () => {
|
||||||
this.activateView();
|
this.activateView();
|
||||||
});
|
});
|
||||||
|
this.addRibbonIcon("sparkles", "Verwandte Notizen", () => {
|
||||||
|
this.activateRelatedView();
|
||||||
|
});
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
@@ -57,6 +62,11 @@ export default class MemexChatPlugin extends Plugin {
|
|||||||
name: "Memex Chat öffnen",
|
name: "Memex Chat öffnen",
|
||||||
callback: () => this.activateView(),
|
callback: () => this.activateView(),
|
||||||
});
|
});
|
||||||
|
this.addCommand({
|
||||||
|
id: "memex-related-notes",
|
||||||
|
name: "Verwandte Notizen anzeigen",
|
||||||
|
callback: () => this.activateRelatedView(),
|
||||||
|
});
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "memex-chat-rebuild-index",
|
id: "memex-chat-rebuild-index",
|
||||||
@@ -106,17 +116,28 @@ export default class MemexChatPlugin extends Plugin {
|
|||||||
|
|
||||||
async activateView(): Promise<void> {
|
async activateView(): Promise<void> {
|
||||||
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
|
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) { this.app.workspace.revealLeaf(existing[0]); return; }
|
||||||
this.app.workspace.revealLeaf(existing[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leaf = this.app.workspace.getLeaf("tab");
|
const leaf = this.app.workspace.getLeaf("tab");
|
||||||
if (!leaf) return;
|
if (!leaf) return;
|
||||||
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
|
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
|
||||||
this.app.workspace.revealLeaf(leaf);
|
this.app.workspace.revealLeaf(leaf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async activateRelatedView(): Promise<void> {
|
||||||
|
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED);
|
||||||
|
if (existing.length > 0) { this.app.workspace.revealLeaf(existing[0]); return; }
|
||||||
|
const leaf = this.app.workspace.getRightLeaf(false);
|
||||||
|
if (!leaf) return;
|
||||||
|
await leaf.setViewState({ type: VIEW_TYPE_RELATED, active: true });
|
||||||
|
this.app.workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyRelatedView() {
|
||||||
|
this.app.workspace.getLeavesOfType(VIEW_TYPE_RELATED).forEach((l) => {
|
||||||
|
(l.view as RelatedNotesView).onIndexReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Create or recreate the EmbedSearch instance (called when settings change) */
|
/** Create or recreate the EmbedSearch instance (called when settings change) */
|
||||||
async initEmbedSearch(): Promise<void> {
|
async initEmbedSearch(): Promise<void> {
|
||||||
if (!this.settings.useEmbeddings) {
|
if (!this.settings.useEmbeddings) {
|
||||||
@@ -124,7 +145,50 @@ export default class MemexChatPlugin extends Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
|
this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel);
|
||||||
// Don't build immediately — build on first search or explicit rebuild
|
this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? [];
|
||||||
|
|
||||||
|
// Re-embed modified notes as they change
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.vault.on("modify", (file) => {
|
||||||
|
if (this.embedSearch && file instanceof TFile && file.extension === "md")
|
||||||
|
this.embedSearch.reembedFile(file);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persistent notice updated during background indexing
|
||||||
|
const notice = new Notice("Memex: Embedding wird vorbereitet…", 0);
|
||||||
|
|
||||||
|
this.embedSearch.onModelStatus = (status) => {
|
||||||
|
notice.setMessage(`Memex: ${status}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.embedSearch.onProgress = (done, total, speed) => {
|
||||||
|
const speedStr = speed > 0 ? ` • ${speed.toFixed(1)} N/s` : "";
|
||||||
|
const remaining = speed > 0 && done < total ? (total - done) / speed : 0;
|
||||||
|
const eta = remaining > 0
|
||||||
|
? ` • ~${remaining < 60 ? Math.ceil(remaining) + "s" : Math.ceil(remaining / 60) + "min"}`
|
||||||
|
: "";
|
||||||
|
notice.setMessage(`Memex Embedding: ${done}/${total}${speedStr}${eta}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for Obsidian Sync to finish before starting (avoids embedding stale/partial files)
|
||||||
|
this.waitForSyncIdle(notice).then(() => this.embedSearch?.buildIndex())
|
||||||
|
.then(() => {
|
||||||
|
notice.setMessage(`✓ Memex: ${this.app.vault.getMarkdownFiles().length} Notizen eingebettet`);
|
||||||
|
setTimeout(() => notice.hide(), 4000);
|
||||||
|
this.notifyRelatedView();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
notice.setMessage(`✗ Memex Embedding: ${(e as Error).message}`);
|
||||||
|
setTimeout(() => notice.hide(), 6000);
|
||||||
|
console.error(e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.embedSearch) {
|
||||||
|
this.embedSearch.onProgress = undefined;
|
||||||
|
this.embedSearch.onModelStatus = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async rebuildIndex(): Promise<void> {
|
async rebuildIndex(): Promise<void> {
|
||||||
@@ -166,6 +230,49 @@ export default class MemexChatPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until Obsidian Sync is idle.
|
||||||
|
* Strategy: watch for vault changes; if activity stops for 15 s, sync is done.
|
||||||
|
* If no activity within the first 5 s, sync isn't running — return immediately.
|
||||||
|
* Falls back after 5 minutes regardless.
|
||||||
|
*/
|
||||||
|
private async waitForSyncIdle(notice: Notice): Promise<void> {
|
||||||
|
// Only wait if the Sync plugin is installed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const syncPlugin = (this.app as any).internalPlugins?.plugins?.["sync"]?.instance;
|
||||||
|
if (!syncPlugin) return;
|
||||||
|
|
||||||
|
const PROBE_MS = 5_000; // time to detect if sync is active
|
||||||
|
const QUIET_MS = 15_000; // idle period that signals sync completion
|
||||||
|
const MAX_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
let lastChange = 0;
|
||||||
|
let activitySeen = false;
|
||||||
|
const tick = () => { lastChange = Date.now(); activitySeen = true; };
|
||||||
|
|
||||||
|
this.app.vault.on("create", tick);
|
||||||
|
this.app.vault.on("modify", tick);
|
||||||
|
this.app.vault.on("delete", tick);
|
||||||
|
|
||||||
|
try {
|
||||||
|
notice.setMessage("Memex: Prüfe Sync-Status…");
|
||||||
|
await new Promise((r) => setTimeout(r, PROBE_MS));
|
||||||
|
if (!activitySeen) return; // no sync activity → proceed immediately
|
||||||
|
|
||||||
|
notice.setMessage("Memex: Warte auf Obsidian Sync…");
|
||||||
|
const deadline = Date.now() + MAX_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2_000));
|
||||||
|
if (Date.now() - lastChange >= QUIET_MS) return; // 15 s quiet → done
|
||||||
|
}
|
||||||
|
// Max wait reached — proceed anyway
|
||||||
|
} finally {
|
||||||
|
this.app.vault.off("create", tick);
|
||||||
|
this.app.vault.off("modify", tick);
|
||||||
|
this.app.vault.off("delete", tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async saveSettings(): Promise<void> {
|
async saveSettings(): Promise<void> {
|
||||||
this.data.settings = this.settings;
|
this.data.settings = this.settings;
|
||||||
await this.saveData(this.data);
|
await this.saveData(this.data);
|
||||||
|
|||||||
+130
@@ -1,5 +1,100 @@
|
|||||||
/* ─── Memex Chat Plugin Styles ───────────────────────────────────────── */
|
/* ─── Memex Chat Plugin Styles ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ─── Related Notes Panel ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.vc-related-header {
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-status {
|
||||||
|
padding: 16px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-item:hover {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-normal);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-folder {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-score-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-bar {
|
||||||
|
width: 48px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--background-modifier-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-related-pct {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.vc-root {
|
.vc-root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -797,6 +892,41 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder autocomplete */
|
||||||
|
.vc-folder-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-folder-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-folder-item {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-normal);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-folder-item:hover {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* Prompt button settings list */
|
/* Prompt button settings list */
|
||||||
.vc-pbtn-list {
|
.vc-pbtn-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user