fix: mempalace search in related

This commit is contained in:
svemagie
2026-04-13 11:37:57 +02:00
parent 12a1dec7d8
commit 71bc0127d5
4 changed files with 283 additions and 56 deletions
+24 -3
View File
@@ -1,6 +1,6 @@
# Memex Chat — CLAUDE.md # Memex Chat — CLAUDE.md
Obsidian plugin: Chat with your vault using Claude AI. Semantic TF-IDF + local embedding context retrieval, `@Notizname` mentions, thread history, prompt extension buttons, streaming responses, related notes sidebar. Obsidian plugin: Chat with your vault using Claude AI. Hybrid TF-IDF + embedding search (Reciprocal Rank Fusion), MemPalace external knowledge injection, `@Notizname` mentions, thread history, prompt extension buttons, streaming responses, related notes sidebar.
## Build ## Build
@@ -21,6 +21,7 @@ Entry: `src/main.ts` → bundled to `main.js` via esbuild (CJS, ES2018 target).
| `src/ChatView.ts` | Main UI — `ChatView extends ItemView`. Thread management, sidebar history, context preview, mode buttons, streaming render, Copy/Save actions. View type: `memex-chat-view`. | | `src/ChatView.ts` | Main UI — `ChatView extends ItemView`. Thread management, sidebar history, context preview, mode buttons, streaming render, Copy/Save actions. View type: `memex-chat-view`. |
| `src/VaultSearch.ts` | TF-IDF search engine. Builds in-memory index over all vault markdown files. Frontmatter property boost (5×). `findSimilarByName()` for unresolved link hints. Exports `SearchResult` interface (includes optional `linked` field). | | `src/VaultSearch.ts` | TF-IDF search engine. Builds in-memory index over all vault markdown files. Frontmatter property boost (5×). `findSimilarByName()` for unresolved link hints. Exports `SearchResult` interface (includes optional `linked` field). |
| `src/EmbedSearch.ts` | Local semantic search via `@xenova/transformers` (ONNX, WASM). Caches per-note `.ajson` vectors under `<vault>/.memex-chat/embeddings/`. `searchSimilarToFile()` boosts scores by frontmatter property links (+0.15) and shared tags (+0.05/tag). | | `src/EmbedSearch.ts` | Local semantic search via `@xenova/transformers` (ONNX, WASM). Caches per-note `.ajson` vectors under `<vault>/.memex-chat/embeddings/`. `searchSimilarToFile()` boosts scores by frontmatter property links (+0.15) and shared tags (+0.05/tag). |
| `src/HybridSearch.ts` | Combines TF-IDF and embedding search via Reciprocal Rank Fusion (RRF, k=60). Runs both engines in parallel; rank-merges results so neither score space needs normalization. TF-IDF excerpts are preserved in merged output. |
| `src/RelatedNotesView.ts` | Sidebar panel — `RelatedNotesView extends ItemView`. Shows semantically similar notes for the active file; refreshes on file-open. Displays similarity bar and "verknüpft" badge for property-linked notes. View type: `memex-related-notes`. | | `src/RelatedNotesView.ts` | Sidebar panel — `RelatedNotesView extends ItemView`. Shows semantically similar notes for the active file; refreshes on file-open. Displays similarity bar and "verknüpft" badge for property-linked notes. View type: `memex-related-notes`. |
| `src/ClaudeClient.ts` | Anthropic API client. `streamChat()` yields `ClaudeStreamChunk` via async generator using native `fetch` + SSE. `chat()` and `fetchModels()` use Obsidian `requestUrl` (no SDK). | | `src/ClaudeClient.ts` | Anthropic API client. `streamChat()` yields `ClaudeStreamChunk` via async generator using native `fetch` + SSE. `chat()` and `fetchModels()` use Obsidian `requestUrl` (no SDK). |
| `src/SettingsTab.ts` | `MemexChatSettingsTab` + `MemexChatSettings` interface + `DEFAULT_SETTINGS`. Exports `PromptButton` interface. Folder autocomplete via `attachFolderDropdown()` helper. | | `src/SettingsTab.ts` | `MemexChatSettingsTab` + `MemexChatSettings` interface + `DEFAULT_SETTINGS`. Exports `PromptButton` interface. Folder autocomplete via `attachFolderDropdown()` helper. |
@@ -33,8 +34,9 @@ Entry: `src/main.ts` → bundled to `main.js` via esbuild (CJS, ES2018 target).
- **Data persistence**: `this.saveData(this.data)` / `this.loadData()` — single object `{ settings, threads }`. Settings merge on load preserves new fields via per-entry spread for `promptButtons`. - **Data persistence**: `this.saveData(this.data)` / `this.loadData()` — single object `{ settings, threads }`. Settings merge on load preserves new fields via per-entry spread for `promptButtons`.
- **Streaming**: `ClaudeClient.streamChat()` is an async generator using native `fetch` with `stream: true` and SSE parsing (`content_block_delta` events). `ChatView` iterates it and calls `updateLastMessage()` per chunk. `chat()` and `fetchModels()` use `requestUrl` (buffered, fine for non-streaming calls). - **Streaming**: `ClaudeClient.streamChat()` is an async generator using native `fetch` with `stream: true` and SSE parsing (`content_block_delta` events). `ChatView` iterates it and calls `updateLastMessage()` per chunk. `chat()` and `fetchModels()` use `requestUrl` (buffered, fine for non-streaming calls).
- **Context flow**: Query → `VaultSearch.search()` or `EmbedSearch.search()` → context preview → user confirms → `sendMessage()` injects note content into the Claude prompt. Auto-retrieve skipped when prompt extension buttons are active. - **Context flow**: Query → `VaultSearch.search()` or `HybridSearch.search()` → context preview → user confirms → `sendMessage()` injects note content into the Claude prompt. If `useMempalace` is enabled, `queryMempalace()` runs first and its results are prepended (highest priority, closest to query). Auto-retrieve skipped when prompt extension buttons are active.
- **Active search engine**: `plugin.activeSearch` returns `EmbedSearch` when enabled, else `VaultSearch`. - **Active search engine**: `plugin.activeSearch` returns `HybridSearch` when embeddings are ready, else `VaultSearch`.
- **MemPalace context**: `ChatView.queryMempalace(query)` calls `/usr/local/bin/mempalace search <query> --results N` via `execFile` with a 10 s timeout. Returns `""` silently if the binary is absent, errors, or times out. Results are labeled `MemPalace (Wissens-Archiv):` and prepended before vault context.
- **System prompt layering**: base system prompt → optional `systemContextFile` → active `promptButtons` extension files (each appended with `\n\n---\n`). - **System prompt layering**: base system prompt → optional `systemContextFile` → active `promptButtons` extension files (each appended with `\n\n---\n`).
- **@mention syntax**: `@Notizname` — autocomplete triggers after 2 chars, inserts full basename. Parsing in `handleSend` matches vault filenames directly (handles spaces & special chars). - **@mention syntax**: `@Notizname` — autocomplete triggers after 2 chars, inserts full basename. Parsing in `handleSend` matches vault filenames directly (handles spaces & special chars).
- **Prompt buttons**: `activeExtensions: Set<string>` tracks active button file paths. Mode hint panel shows `helpText` above input; hidden after send. Date-search buttons parse month from query and filter files by `getFileDate()`. - **Prompt buttons**: `activeExtensions: Set<string>` tracks active button file paths. Mode hint panel shows `helpText` above input; hidden after send. Date-search buttons parse month from query and filter files by `getFileDate()`.
@@ -61,6 +63,23 @@ Entry: `src/main.ts` → bundled to `main.js` via esbuild (CJS, ES2018 target).
- Obsidian Sync wait: `waitForSyncIdle()` monitors vault events (5 s probe, 15 s quiet) before starting `buildIndex`. - Obsidian Sync wait: `waitForSyncIdle()` monitors vault events (5 s probe, 15 s quiet) before starting `buildIndex`.
- esbuild patches required: `stubNativeModules`, `forceOnnxWeb`, `forceOrtWebBrowserMode`. `import.meta.url` defined as a constant string. - esbuild patches required: `stubNativeModules`, `forceOnnxWeb`, `forceOrtWebBrowserMode`. `import.meta.url` defined as a constant string.
## HybridSearch
- Combines `VaultSearch` (TF-IDF) and `EmbedSearch` (ONNX embeddings) via Reciprocal Rank Fusion.
- `RRF_K = 60` — standard constant; score = `1/(K + rank_tfidf + 1) + 1/(K + rank_embed + 1)`.
- Runs both engines in parallel (`Promise.all`), fetches `topK * 3` candidates from each.
- TF-IDF excerpts preserved in merged output; embedding results fill in where TF-IDF has no match.
- `plugin.hybridSearch` is set after `embedSearch.buildIndex()` completes; `plugin.activeSearch` returns it over `VaultSearch`.
## MemPalace Integration
- Requires `/usr/local/bin/mempalace` CLI installed on the host machine.
- `ChatView.queryMempalace(query)`: calls `mempalace search <query> --results N` via Node `execFile`, 10 s timeout.
- Silent no-op if binary missing, process errors, or stdout empty — never throws.
- Output section header: `MemPalace (Wissens-Archiv):`, prepended before vault context so it sits closest to the query in the prompt.
- Controlled by `settings.useMempalace` (toggle) and `settings.mempalaceResults` (110, default 3).
- Status indicator "MemPalace wird abgefragt…" shown during the CLI call.
## RelatedNotesView ## RelatedNotesView
- Opens in right sidebar leaf via `plugin.activateRelatedView()` or sparkles ribbon icon. - Opens in right sidebar leaf via `plugin.activateRelatedView()` or sparkles ribbon icon.
@@ -89,6 +108,8 @@ Entry: `src/main.ts` → bundled to `main.js` via esbuild (CJS, ES2018 target).
| `useEmbeddings` | `false` | Enable local semantic embeddings | | `useEmbeddings` | `false` | Enable local semantic embeddings |
| `embeddingModel` | `TaylorAI/bge-micro-v2` | ONNX embedding model ID | | `embeddingModel` | `TaylorAI/bge-micro-v2` | ONNX embedding model ID |
| `embedExcludeFolders` | `[]` | Vault folders excluded from embedding | | `embedExcludeFolders` | `[]` | Vault folders excluded from embedding |
| `useMempalace` | `false` | Inject MemPalace CLI search results as additional context |
| `mempalaceResults` | `3` | Number of MemPalace results per query (110) |
| `promptButtons` | Draft Check, Monthly Check | Header mode buttons with system prompt extension | | `promptButtons` | Draft Check, Monthly Check | Header mode buttons with system prompt extension |
## Prompt Buttons (PromptButton interface) ## Prompt Buttons (PromptButton interface)
+104 -26
View File
@@ -33397,7 +33397,7 @@ var RelatedNotesView = class extends import_obsidian4.ItemView {
async onOpen() { async onOpen() {
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh())); this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh()));
this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh())); this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh()));
this.render([]); this.render([], []);
this.scheduleRefresh(); this.scheduleRefresh();
} }
scheduleRefresh(delay = 400) { scheduleRefresh(delay = 400) {
@@ -33405,7 +33405,6 @@ var RelatedNotesView = class extends import_obsidian4.ItemView {
clearTimeout(this.refreshTimer); clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => this.refresh(), delay); this.refreshTimer = setTimeout(() => this.refresh(), delay);
} }
/** Called by the plugin when the embedding index finishes building. */
onIndexReady() { onIndexReady() {
this.scheduleRefresh(0); this.scheduleRefresh(0);
} }
@@ -33414,48 +33413,127 @@ var RelatedNotesView = class extends import_obsidian4.ItemView {
if (!file || file.extension !== "md") if (!file || file.extension !== "md")
return; return;
const es = this.plugin.embedSearch; const es = this.plugin.embedSearch;
if (!es || !es.isIndexed()) { const useMempalace = this.plugin.settings.useMempalace;
const embedReady = es?.isIndexed() ?? false;
if (!useMempalace && !embedReady) {
this.renderStatus("Embedding-Index wird aufgebaut\u2026"); this.renderStatus("Embedding-Index wird aufgebaut\u2026");
return; return;
} }
this.renderStatus("Suche verwandte Notizen\u2026"); this.renderStatus("Suche verwandte Notizen\u2026");
const results = await es.searchSimilarToFile(file); const topK = this.plugin.settings.mempalaceResults ?? 5;
this.render(results, file.basename); const [mpResults, nativeResults] = await Promise.all([
useMempalace ? this.queryMempalace(file.basename, topK) : Promise.resolve([]),
embedReady ? es.searchSimilarToFile(file) : Promise.resolve([])
]);
this.render(mpResults, nativeResults, file.basename);
} }
// ─── MemPalace ────────────────────────────────────────────────────────────
async queryMempalace(query, topK) {
return new Promise((resolve) => {
try {
const { existsSync } = require("fs");
const { execFile } = require("child_process");
if (!existsSync("/usr/local/bin/mempalace")) {
resolve([]);
return;
}
execFile(
"/usr/local/bin/mempalace",
["search", query, "--results", String(topK)],
{ timeout: 8e3 },
(err, stdout) => {
if (err || !stdout) {
resolve([]);
return;
}
resolve(this.parseMempalace(stdout));
}
);
} catch {
resolve([]);
}
});
}
parseMempalace(output) {
const results = [];
const blocks = output.split(/─{10,}/);
for (const block of blocks) {
const locMatch = block.match(/\[\d+\]\s+(.+?)\n/);
const srcMatch = block.match(/Source:\s+(.+?)(?:\.md)?\s*\n/);
const scoreMatch = block.match(/Match:\s+([\d.]+)/);
if (!locMatch || !srcMatch || !scoreMatch)
continue;
const location = locMatch[1].trim();
const source = srcMatch[1].trim();
const score = parseFloat(scoreMatch[1]);
const afterScore = block.slice(block.indexOf(scoreMatch[0]) + scoreMatch[0].length).trimStart();
const excerpt = afterScore.replace(/\n{3,}/g, "\n\n").trim().slice(0, 240);
results.push({ source, location, score, excerpt });
}
return results;
}
// ─── Rendering ────────────────────────────────────────────────────────────
renderStatus(msg) { renderStatus(msg) {
this.contentEl.empty(); this.contentEl.empty();
this.contentEl.createDiv({ cls: "vc-related-status", text: msg }); this.contentEl.createDiv({ cls: "vc-related-status", text: msg });
} }
render(results, forNote) { render(mpResults, nativeResults, forNote) {
this.contentEl.empty(); this.contentEl.empty();
const header = this.contentEl.createDiv("vc-related-header"); const header = this.contentEl.createDiv("vc-related-header");
header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" }); header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" });
if (forNote) if (forNote)
header.createDiv({ cls: "vc-related-subtitle", text: forNote }); header.createDiv({ cls: "vc-related-subtitle", text: forNote });
if (!results.length) { if (!mpResults.length && !nativeResults.length) {
this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" }); this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" });
return; return;
} }
const list = this.contentEl.createDiv("vc-related-list"); if (mpResults.length) {
for (const r of results) { this.contentEl.createDiv({ cls: "vc-related-section-label", text: "MemPalace" });
const item = list.createDiv("vc-related-item"); const mpList = this.contentEl.createDiv("vc-related-list");
const info = item.createDiv("vc-related-info"); for (const r of mpResults) {
const nameRow = info.createDiv("vc-related-name-row"); const item = mpList.createDiv("vc-related-item vc-related-item--mp");
nameRow.createSpan({ cls: "vc-related-name", text: r.title }); const info = item.createDiv("vc-related-info");
if (r.linked) const nameRow = info.createDiv("vc-related-name-row");
nameRow.createSpan({ cls: "vc-related-linked", text: "verkn\xFCpft" }); nameRow.createSpan({ cls: "vc-related-name", text: r.source });
const folder = r.file.parent?.path; nameRow.createSpan({ cls: "vc-related-location", text: r.location });
if (folder && folder !== "/") { if (r.excerpt) {
info.createDiv({ cls: "vc-related-folder", text: folder }); info.createDiv({ cls: "vc-related-excerpt", text: r.excerpt });
}
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 vc-related-bar-fill--mp" }).style.width = `${pct}%`;
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` });
item.addEventListener("click", () => {
const vaultFile = this.app.vault.getMarkdownFiles().find((f) => f.basename === r.source);
if (vaultFile)
this.app.workspace.openLinkText(vaultFile.path, vaultFile.path, false);
});
}
}
if (nativeResults.length) {
this.contentEl.createDiv({ cls: "vc-related-section-label", text: "Vault" });
const list = this.contentEl.createDiv("vc-related-list");
for (const r of nativeResults) {
const item = list.createDiv("vc-related-item");
const info = item.createDiv("vc-related-info");
const nameRow = info.createDiv("vc-related-name-row");
nameRow.createSpan({ cls: "vc-related-name", text: r.title });
if (r.linked)
nameRow.createSpan({ cls: "vc-related-linked", text: "verkn\xFCpft" });
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);
});
} }
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);
});
} }
} }
}; };
+123 -27
View File
@@ -3,6 +3,13 @@ import type MemexChatPlugin from "./main";
export const VIEW_TYPE_RELATED = "memex-related-notes"; export const VIEW_TYPE_RELATED = "memex-related-notes";
interface MpResult {
source: string; // basename without .md
location: string; // "wing / room"
score: number;
excerpt: string;
}
export class RelatedNotesView extends ItemView { export class RelatedNotesView extends ItemView {
private plugin: MemexChatPlugin; private plugin: MemexChatPlugin;
private refreshTimer: ReturnType<typeof setTimeout> | null = null; private refreshTimer: ReturnType<typeof setTimeout> | null = null;
@@ -19,7 +26,7 @@ export class RelatedNotesView extends ItemView {
async onOpen(): Promise<void> { async onOpen(): Promise<void> {
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh())); this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.scheduleRefresh()));
this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh())); this.registerEvent(this.app.workspace.on("file-open", () => this.scheduleRefresh()));
this.render([]); this.render([], []);
this.scheduleRefresh(); this.scheduleRefresh();
} }
@@ -28,7 +35,6 @@ export class RelatedNotesView extends ItemView {
this.refreshTimer = setTimeout(() => this.refresh(), delay); this.refreshTimer = setTimeout(() => this.refresh(), delay);
} }
/** Called by the plugin when the embedding index finishes building. */
onIndexReady() { this.scheduleRefresh(0); } onIndexReady() { this.scheduleRefresh(0); }
private async refresh() { private async refresh() {
@@ -36,58 +42,148 @@ export class RelatedNotesView extends ItemView {
if (!file || file.extension !== "md") return; if (!file || file.extension !== "md") return;
const es = this.plugin.embedSearch; const es = this.plugin.embedSearch;
if (!es || !es.isIndexed()) { const useMempalace = this.plugin.settings.useMempalace;
const embedReady = es?.isIndexed() ?? false;
// Nothing can run yet
if (!useMempalace && !embedReady) {
this.renderStatus("Embedding-Index wird aufgebaut…"); this.renderStatus("Embedding-Index wird aufgebaut…");
return; return;
} }
this.renderStatus("Suche verwandte Notizen…"); this.renderStatus("Suche verwandte Notizen…");
const results = await es.searchSimilarToFile(file);
this.render(results, file.basename); const topK = this.plugin.settings.mempalaceResults ?? 5;
const [mpResults, nativeResults] = await Promise.all([
useMempalace ? this.queryMempalace(file.basename, topK) : Promise.resolve([] as MpResult[]),
embedReady ? es!.searchSimilarToFile(file) : Promise.resolve([]),
]);
this.render(mpResults, nativeResults, file.basename);
} }
// ─── MemPalace ────────────────────────────────────────────────────────────
private async queryMempalace(query: string, topK: number): Promise<MpResult[]> {
return new Promise((resolve) => {
try {
const { existsSync } = require("fs") as typeof import("fs");
const { execFile } = require("child_process") as typeof import("child_process");
if (!existsSync("/usr/local/bin/mempalace")) { resolve([]); return; }
execFile(
"/usr/local/bin/mempalace",
["search", query, "--results", String(topK)],
{ timeout: 8000 },
(err: Error | null, stdout: string) => {
if (err || !stdout) { resolve([]); return; }
resolve(this.parseMempalace(stdout));
}
);
} catch { resolve([]); }
});
}
private parseMempalace(output: string): MpResult[] {
const results: MpResult[] = [];
const blocks = output.split(/─{10,}/);
for (const block of blocks) {
const locMatch = block.match(/\[\d+\]\s+(.+?)\n/);
const srcMatch = block.match(/Source:\s+(.+?)(?:\.md)?\s*\n/);
const scoreMatch = block.match(/Match:\s+([\d.]+)/);
if (!locMatch || !srcMatch || !scoreMatch) continue;
const location = locMatch[1].trim();
const source = srcMatch[1].trim();
const score = parseFloat(scoreMatch[1]);
const afterScore = block.slice(block.indexOf(scoreMatch[0]) + scoreMatch[0].length).trimStart();
const excerpt = afterScore.replace(/\n{3,}/g, "\n\n").trim().slice(0, 240);
results.push({ source, location, score, excerpt });
}
return results;
}
// ─── Rendering ────────────────────────────────────────────────────────────
private renderStatus(msg: string) { private renderStatus(msg: string) {
this.contentEl.empty(); this.contentEl.empty();
this.contentEl.createDiv({ cls: "vc-related-status", text: msg }); this.contentEl.createDiv({ cls: "vc-related-status", text: msg });
} }
private render(results: Array<{ file: TFile; score: number; title: string }>, forNote?: string) { private render(
mpResults: MpResult[],
nativeResults: Array<{ file: TFile; score: number; title: string; linked?: boolean }>,
forNote?: string
) {
this.contentEl.empty(); this.contentEl.empty();
const header = this.contentEl.createDiv("vc-related-header"); const header = this.contentEl.createDiv("vc-related-header");
header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" }); header.createDiv({ cls: "vc-related-title", text: "Verwandte Notizen" });
if (forNote) header.createDiv({ cls: "vc-related-subtitle", text: forNote }); if (forNote) header.createDiv({ cls: "vc-related-subtitle", text: forNote });
if (!results.length) { if (!mpResults.length && !nativeResults.length) {
this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" }); this.contentEl.createDiv({ cls: "vc-related-status", text: forNote ? "Keine Treffer." : "" });
return; return;
} }
const list = this.contentEl.createDiv("vc-related-list"); // ── MemPalace section ──
for (const r of results) { if (mpResults.length) {
const item = list.createDiv("vc-related-item"); this.contentEl.createDiv({ cls: "vc-related-section-label", text: "MemPalace" });
const mpList = this.contentEl.createDiv("vc-related-list");
for (const r of mpResults) {
const item = mpList.createDiv("vc-related-item vc-related-item--mp");
const info = item.createDiv("vc-related-info"); const info = item.createDiv("vc-related-info");
const nameRow = info.createDiv("vc-related-name-row"); const nameRow = info.createDiv("vc-related-name-row");
nameRow.createSpan({ cls: "vc-related-name", text: r.title }); nameRow.createSpan({ cls: "vc-related-name", text: r.source });
if (r.linked) nameRow.createSpan({ cls: "vc-related-linked", text: "verknüpft" }); nameRow.createSpan({ cls: "vc-related-location", text: r.location });
// Folder path (dimmed) if (r.excerpt) {
const folder = r.file.parent?.path; info.createDiv({ cls: "vc-related-excerpt", text: r.excerpt });
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 vc-related-bar-fill--mp" }).style.width = `${pct}%`;
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` });
item.addEventListener("click", () => {
const vaultFile = this.app.vault.getMarkdownFiles().find((f) => f.basename === r.source);
if (vaultFile) this.app.workspace.openLinkText(vaultFile.path, vaultFile.path, false);
});
} }
}
// Similarity bar + percentage // ── Vault (native) section ──
const scoreWrap = item.createDiv("vc-related-score-wrap"); if (nativeResults.length) {
const pct = Math.round(r.score * 100); this.contentEl.createDiv({ cls: "vc-related-section-label", text: "Vault" });
const bar = scoreWrap.createDiv("vc-related-bar"); const list = this.contentEl.createDiv("vc-related-list");
bar.createDiv({ cls: "vc-related-bar-fill" }).style.width = `${pct}%`; for (const r of nativeResults) {
scoreWrap.createDiv({ cls: "vc-related-pct", text: `${pct}%` }); const item = list.createDiv("vc-related-item");
item.addEventListener("click", () => { const info = item.createDiv("vc-related-info");
this.app.workspace.openLinkText(r.file.path, r.file.path, false); const nameRow = info.createDiv("vc-related-name-row");
}); nameRow.createSpan({ cls: "vc-related-name", text: r.title });
if (r.linked) nameRow.createSpan({ cls: "vc-related-linked", text: "verknüpft" });
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);
});
}
} }
} }
} }
+32
View File
@@ -111,6 +111,38 @@
border-radius: 2px; border-radius: 2px;
} }
.vc-related-bar-fill--mp {
background: var(--color-purple, #8b5cf6);
}
.vc-related-section-label {
padding: 5px 12px 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.vc-related-location {
font-size: 10px;
color: var(--text-faint);
white-space: nowrap;
flex-shrink: 0;
}
.vc-related-excerpt {
font-size: 10px;
color: var(--text-muted);
margin-top: 3px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vc-related-pct { .vc-related-pct {
font-size: 10px; font-size: 10px;
color: var(--text-muted); color: var(--text-muted);