v0.2.0: History, rename threads, prompt buttons, Draft Check, @mention syntax
- Thread sidebar: inline rename (double-click) + collapsible Verlauf section that loads saved vault chat files and imports them as active threads - Prompt extension buttons (Draft Check, Monthly Check) in chat header with configurable system prompt file, date-search mode, folder filter, and helpText info panel shown above input when button is active - Draft Check: skip auto context search, use only @mention + system prompt; three-phase helpText (DRAFT / PRE-PUBLISH / DIAGNOSTIC) displayed in input area - Monthly Check: date-based file search with German month names + relative refs - TF-IDF: frontmatter priority properties (collection, related, up, tags) boosted 5×, configurable in settings - @mention syntax changed from @[[Notizname]] to @Notizname - Unresolved [[links]] in assistant responses styled as is-unresolved with similar-note suggestions (findSimilarByName) - Chat opens as new tab; all note links open in new tab - saveThreadToVault embeds thread ID in frontmatter for dedup on re-import - Settings merge fix: per-entry promptButton defaults preserved on load Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Memex Chat — CLAUDE.md
|
||||
|
||||
Obsidian plugin: Chat with your vault using Claude AI. Semantic TF-IDF context retrieval, `[[Note]]` mentions, thread history, streaming responses.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # production build → main.js
|
||||
npm run dev # watch mode with inline sourcemaps
|
||||
```
|
||||
|
||||
Entry: `src/main.ts` → bundled to `main.js` via esbuild (CJS, ES2018 target).
|
||||
`obsidian` and all `@codemirror/*` / `@lezer/*` packages are external (provided by Obsidian).
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/main.ts` | Plugin entry — `MemexChatPlugin extends Plugin`. Registers view, commands, settings tab. |
|
||||
| `src/ChatView.ts` | Main UI — `ChatView extends ItemView`. Thread management, context preview, streaming render. View type: `memex-chat-view`. |
|
||||
| `src/VaultSearch.ts` | TF-IDF search engine. Builds in-memory index over all vault markdown files. No external API. |
|
||||
| `src/ClaudeClient.ts` | Anthropic API client. `streamChat()` yields `ClaudeStreamChunk` via async generator. Uses `fetch` directly (no SDK). |
|
||||
| `src/SettingsTab.ts` | `MemexChatSettingsTab` + `MemexChatSettings` interface + `DEFAULT_SETTINGS`. |
|
||||
| `styles.css` | All plugin styles. CSS classes prefixed `vc-` (e.g. `vc-root`, `vc-msg--assistant`). |
|
||||
| `manifest.json` | Obsidian plugin manifest. ID: `memex-chat`. |
|
||||
| `main.js` | Compiled output — do not edit manually, always rebuild. |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Data persistence**: `this.saveData(this.data)` / `this.loadData()` — single object `{ settings, threads }`.
|
||||
- **Streaming**: `ClaudeClient.streamChat()` is an async generator; `ChatView` iterates it and calls `updateLastMessage()` per chunk.
|
||||
- **Context flow**: Query → `VaultSearch.search()` → context preview → user confirms → `sendMessage()` injects note content into the Claude prompt.
|
||||
- **Thread storage**: Optionally saved as Markdown to vault folder (default `Calendar/Chat/`).
|
||||
- **CSS prefix**: `vc-` for all plugin DOM classes. Do not use Obsidian internal class names.
|
||||
- **TypeScript**: `strictNullChecks` on, `moduleResolution: bundler`. No tests currently.
|
||||
|
||||
## Deployment (Manual)
|
||||
|
||||
Copy `main.js`, `manifest.json`, `styles.css` into `.obsidian/plugins/memex-chat/` in the target vault.
|
||||
|
||||
## Models (SettingsTab.ts)
|
||||
|
||||
Default: `claude-opus-4-5-20251101`. Update `MODELS` array and `DEFAULT_SETTINGS.model` when adding new model IDs.
|
||||
@@ -38,6 +38,11 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
// Mention autocomplete state
|
||||
this.mentionSelectedIdx = 0;
|
||||
this.mentionMatches = [];
|
||||
// Active prompt extension buttons (file paths)
|
||||
this.activeExtensions = /* @__PURE__ */ new Set();
|
||||
// ─── Sidebar History ──────────────────────────────────────────────────────
|
||||
this.historyExpanded = false;
|
||||
this.historyThreads = [];
|
||||
this.plugin = plugin;
|
||||
this.renderComponent = new import_obsidian.Component();
|
||||
}
|
||||
@@ -71,6 +76,20 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
root.addClass("vc-root");
|
||||
const header = root.createDiv("vc-header");
|
||||
header.createEl("span", { text: "Memex Chat", cls: "vc-header-title" });
|
||||
const modeBtns = header.createDiv("vc-header-modes");
|
||||
for (const pb of this.plugin.settings.promptButtons) {
|
||||
const modeBtn = modeBtns.createEl("button", { text: pb.label, cls: "vc-mode-btn" });
|
||||
modeBtn.onclick = () => {
|
||||
if (this.activeExtensions.has(pb.filePath)) {
|
||||
this.activeExtensions.delete(pb.filePath);
|
||||
modeBtn.removeClass("vc-mode-btn--active");
|
||||
} else {
|
||||
this.activeExtensions.add(pb.filePath);
|
||||
modeBtn.addClass("vc-mode-btn--active");
|
||||
}
|
||||
this.updateModeHint();
|
||||
};
|
||||
}
|
||||
const headerActions = header.createDiv("vc-header-actions");
|
||||
const newThreadBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Neuer Thread" });
|
||||
newThreadBtn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none"><path d="M12 5v14M5 12h14" stroke-width="2" stroke-linecap="round"/></svg>`;
|
||||
@@ -95,12 +114,14 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
this.contextPreviewEl = chatArea.createDiv("vc-context-preview");
|
||||
this.contextPreviewEl.style.display = "none";
|
||||
const inputArea = chatArea.createDiv("vc-input-area");
|
||||
this.modeHintEl = inputArea.createDiv("vc-mode-hint");
|
||||
this.modeHintEl.style.display = "none";
|
||||
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
|
||||
this.mentionDropdownEl = root.createDiv("vc-mention-dropdown");
|
||||
this.mentionDropdownEl.style.display = "none";
|
||||
this.inputEl = inputWrapper.createEl("textarea", {
|
||||
cls: "vc-input",
|
||||
attr: { placeholder: "Frage stellen\u2026 (@ f\xFCr Notiz einf\xFCgen)" }
|
||||
attr: { placeholder: "Frage stellen\u2026 (@ f\xFCr Notiz)" }
|
||||
});
|
||||
this.inputEl.rows = 3;
|
||||
const inputActions = inputArea.createDiv("vc-input-actions");
|
||||
@@ -133,14 +154,15 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
const isCmdEnter = e.metaKey || e.ctrlKey;
|
||||
const sendOnEnter = this.plugin.settings.sendOnEnter;
|
||||
if (sendOnEnter && e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this.handleSend();
|
||||
} else if (!sendOnEnter && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
if (isCmdEnter || sendOnEnter && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.handleSend();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.inputEl.addEventListener("input", () => this.handleInputChange());
|
||||
this.renderThreadList();
|
||||
@@ -181,6 +203,7 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
}
|
||||
// ─── Send & Context ──────────────────────────────────────────────────────
|
||||
async handleSend() {
|
||||
var _a;
|
||||
const query = this.inputEl.value.trim();
|
||||
if (!query || this.isLoading)
|
||||
return;
|
||||
@@ -188,22 +211,36 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
this.setStatus("\u26A0 Bitte API Key in den Einstellungen eingeben");
|
||||
return;
|
||||
}
|
||||
const mentionPattern = /\[\[([^\]]+)\]\]/g;
|
||||
const mentionPattern = /@([\w\däöüÄÖÜß][^@\n]{1,}?)(?=\s|$)/g;
|
||||
const mentions = [];
|
||||
let match;
|
||||
while ((match = mentionPattern.exec(query)) !== null) {
|
||||
const name = match[1];
|
||||
const name = match[1].trim();
|
||||
const file = this.app.metadataCache.getFirstLinkpathDest(name, "");
|
||||
if (file)
|
||||
mentions.push(file);
|
||||
}
|
||||
if (this.activeExtensions.size === 0) {
|
||||
if (this.plugin.settings.autoRetrieveContext && this.plugin.settings.showContextPreview) {
|
||||
if (this.pendingContext.length === 0 && this.explicitContext.length === 0) {
|
||||
await this.fetchAndShowContext(query, mentions);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.sendMessage(query, mentions);
|
||||
} else {
|
||||
this.pendingContext = [];
|
||||
this.explicitContext = [];
|
||||
}
|
||||
const dateFiles = [];
|
||||
for (const pb of this.plugin.settings.promptButtons) {
|
||||
if (pb.searchMode === "date" && this.activeExtensions.has(pb.filePath)) {
|
||||
const { start, end, label } = this.parseDateRange(query);
|
||||
const found = this.findFilesByDate(start, end, (_a = pb.searchFolders) != null ? _a : []);
|
||||
dateFiles.push(...found);
|
||||
this.setStatus(`${found.length} Texte aus ${label} gefunden`);
|
||||
}
|
||||
}
|
||||
await this.sendMessage(query, [...mentions, ...dateFiles]);
|
||||
}
|
||||
async fetchAndShowContext(query, mentions) {
|
||||
this.setStatus("Suche relevante Notizen\u2026");
|
||||
@@ -223,6 +260,7 @@ var ChatView = class extends import_obsidian.ItemView {
|
||||
this.isLoading = false;
|
||||
}
|
||||
async sendMessage(query, additionalFiles = []) {
|
||||
var _a, _b;
|
||||
this.isLoading = true;
|
||||
this.sendBtn.disabled = true;
|
||||
const thread = this.activeThread;
|
||||
@@ -280,11 +318,19 @@ ${content}`;
|
||||
content: query + contextText
|
||||
});
|
||||
this.setStatus("Claude denkt\u2026");
|
||||
let systemPrompt = this.plugin.settings.systemPrompt;
|
||||
for (const filePath of this.activeExtensions) {
|
||||
const file = (_b = (_a = this.app.metadataCache.getFirstLinkpathDest(filePath, "")) != null ? _a : this.app.vault.getAbstractFileByPath(filePath + ".md")) != null ? _b : this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof import_obsidian.TFile) {
|
||||
const ext = await this.app.vault.cachedRead(file);
|
||||
systemPrompt += "\n\n---\n" + ext;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const stream = this.plugin.claude.streamChat(claudeMessages, {
|
||||
apiKey: this.plugin.settings.apiKey,
|
||||
model: this.plugin.settings.model,
|
||||
systemPrompt: this.plugin.settings.systemPrompt
|
||||
systemPrompt
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === "text" && chunk.text) {
|
||||
@@ -336,7 +382,7 @@ ${content}`;
|
||||
const score = Math.round(result.score * 100);
|
||||
const itemHeader = item.createDiv("vc-ctx-item-header");
|
||||
const titleEl = itemHeader.createEl("span", { text: result.title, cls: "vc-ctx-item-title" });
|
||||
titleEl.onclick = () => this.app.workspace.openLinkText(result.file.path, "", false);
|
||||
titleEl.onclick = () => this.app.workspace.openLinkText(result.file.path, "", "tab");
|
||||
itemHeader.createEl("span", { text: `${score}%`, cls: "vc-ctx-score" });
|
||||
const removeBtn = itemHeader.createEl("button", { cls: "vc-icon-btn vc-ctx-remove", title: "Entfernen" });
|
||||
removeBtn.innerHTML = `\u2715`;
|
||||
@@ -355,6 +401,26 @@ ${content}`;
|
||||
this.explicitContext = [];
|
||||
this.setStatus("");
|
||||
}
|
||||
updateModeHint() {
|
||||
const hints = [];
|
||||
for (const pb of this.plugin.settings.promptButtons) {
|
||||
if (this.activeExtensions.has(pb.filePath) && pb.helpText) {
|
||||
hints.push(pb.helpText);
|
||||
}
|
||||
}
|
||||
if (hints.length > 0) {
|
||||
this.modeHintEl.empty();
|
||||
for (const hint of hints) {
|
||||
const div = this.modeHintEl.createDiv("vc-mode-hint-text");
|
||||
div.textContent = hint;
|
||||
}
|
||||
this.modeHintEl.style.display = "block";
|
||||
this.inputEl.placeholder = "";
|
||||
} else {
|
||||
this.modeHintEl.style.display = "none";
|
||||
this.inputEl.placeholder = "Frage stellen\u2026 (@ f\xFCr Notiz)";
|
||||
}
|
||||
}
|
||||
async openContextPicker() {
|
||||
var _a, _b, _c, _d;
|
||||
const lastUserMsg = (_d = (_c = [...(_b = (_a = this.activeThread) == null ? void 0 : _a.messages) != null ? _b : []].reverse().find((m) => m.role === "user")) == null ? void 0 : _c.content) != null ? _d : "";
|
||||
@@ -378,6 +444,10 @@ ${content}`;
|
||||
const item = this.threadListEl.createDiv("vc-thread-item" + (thread.id === this.activeThreadId ? " vc-thread-item--active" : ""));
|
||||
const titleEl = item.createEl("span", { text: thread.title, cls: "vc-thread-title" });
|
||||
titleEl.onclick = () => this.switchThread(thread.id);
|
||||
titleEl.ondblclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.startRenameThread(thread, titleEl);
|
||||
};
|
||||
const del = item.createEl("button", { cls: "vc-icon-btn vc-thread-del", title: "L\xF6schen" });
|
||||
del.innerHTML = "\u2715";
|
||||
del.onclick = (e) => {
|
||||
@@ -385,6 +455,32 @@ ${content}`;
|
||||
this.deleteThread(thread.id);
|
||||
};
|
||||
}
|
||||
this.renderHistorySection();
|
||||
}
|
||||
startRenameThread(thread, titleEl) {
|
||||
const input = document.createElement("input");
|
||||
input.className = "vc-thread-rename";
|
||||
input.value = thread.title;
|
||||
titleEl.replaceWith(input);
|
||||
input.select();
|
||||
const finish = () => {
|
||||
const newTitle = input.value.trim() || thread.title;
|
||||
thread.title = newTitle;
|
||||
this.saveThreads();
|
||||
this.renderThreadList();
|
||||
};
|
||||
input.addEventListener("blur", finish);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
input.value = thread.title;
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
input.focus();
|
||||
}
|
||||
renderMessages() {
|
||||
this.messagesEl.empty();
|
||||
@@ -393,7 +489,7 @@ ${content}`;
|
||||
const empty = this.messagesEl.createDiv("vc-empty");
|
||||
empty.createEl("div", { text: "\u{1F4AC}", cls: "vc-empty-icon" });
|
||||
empty.createEl("div", { text: "Stell eine Frage \u2014 ich suche passende Notizen aus deinem Vault.", cls: "vc-empty-text" });
|
||||
empty.createEl("div", { text: "Tipp: Nutze @[[Notizname]] um eine Notiz direkt einzubinden.", cls: "vc-empty-hint" });
|
||||
empty.createEl("div", { text: "Tipp: Nutze @Notizname um eine Notiz direkt einzubinden.", cls: "vc-empty-hint" });
|
||||
return;
|
||||
}
|
||||
for (const msg of thread.messages) {
|
||||
@@ -414,6 +510,33 @@ ${content}`;
|
||||
mdEl.createEl("span", { cls: "vc-cursor", text: "\u2588" });
|
||||
} else {
|
||||
import_obsidian.MarkdownRenderer.render(this.app, msg.content, mdEl, "", this.renderComponent);
|
||||
mdEl.querySelectorAll("a.internal-link").forEach((a) => {
|
||||
var _a2, _b;
|
||||
const href = (_b = (_a2 = a.getAttribute("href")) != null ? _a2 : a.textContent) != null ? _b : "";
|
||||
const exists = !!this.app.metadataCache.getFirstLinkpathDest(href, "");
|
||||
if (!exists) {
|
||||
a.classList.add("is-unresolved");
|
||||
const similar = this.plugin.search.findSimilarByName(href, 2, 0.45);
|
||||
if (similar.length > 0) {
|
||||
const hint = a.parentElement.createEl("span", { cls: "vc-link-hint" });
|
||||
hint.createEl("span", { text: " \u2192 \xC4hnliche Notiz: ", cls: "vc-link-hint-label" });
|
||||
similar.forEach((r, i) => {
|
||||
if (i > 0)
|
||||
hint.appendText(", ");
|
||||
const link = hint.createEl("a", { text: r.title, cls: "internal-link vc-link-hint-target" });
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.app.workspace.openLinkText(r.file.path, "", false);
|
||||
});
|
||||
});
|
||||
a.insertAdjacentElement("afterend", hint);
|
||||
}
|
||||
}
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.app.workspace.openLinkText(href, "", "tab");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!msg.isStreaming && msg.contextNotes && msg.contextNotes.length > 0) {
|
||||
@@ -423,7 +546,7 @@ ${content}`;
|
||||
const file = this.app.vault.getAbstractFileByPath(notePath);
|
||||
const name = file instanceof import_obsidian.TFile ? file.basename : (_a = notePath.split("/").pop()) != null ? _a : notePath;
|
||||
const link = sources.createEl("span", { text: `[[${name}]]`, cls: "vc-source-link" });
|
||||
link.onclick = () => this.app.workspace.openLinkText(notePath, "", false);
|
||||
link.onclick = () => this.app.workspace.openLinkText(notePath, "", "tab");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,11 +629,11 @@ ${content}`;
|
||||
const cursor = (_a = this.inputEl.selectionStart) != null ? _a : 0;
|
||||
const text = this.inputEl.value;
|
||||
const textBefore = text.slice(0, cursor);
|
||||
const match = textBefore.match(/@([^@\n[\]]{2,})$/);
|
||||
const match = textBefore.match(/@([^@\n]{2,})$/);
|
||||
if (!match)
|
||||
return;
|
||||
const start = cursor - match[0].length;
|
||||
const replacement = `[[${basename}]]`;
|
||||
const replacement = `@${basename}`;
|
||||
this.inputEl.value = text.slice(0, start) + replacement + text.slice(cursor);
|
||||
const newCursor = start + replacement.length;
|
||||
this.inputEl.setSelectionRange(newCursor, newCursor);
|
||||
@@ -522,6 +645,68 @@ ${content}`;
|
||||
this.mentionMatches = [];
|
||||
this.mentionSelectedIdx = 0;
|
||||
}
|
||||
// ─── Date-based context search ────────────────────────────────────────────
|
||||
parseDateRange(query) {
|
||||
const now = /* @__PURE__ */ new Date();
|
||||
const lower = query.toLowerCase();
|
||||
if (/letzt[eaem]n?\s+monat|vorig[eaem]n?\s+monat/.test(lower)) {
|
||||
const start2 = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const end2 = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
|
||||
return { start: start2, end: end2, label: start2.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) };
|
||||
}
|
||||
const MONTHS = {
|
||||
januar: 0,
|
||||
februar: 1,
|
||||
"m\xE4rz": 2,
|
||||
april: 3,
|
||||
mai: 4,
|
||||
juni: 5,
|
||||
juli: 6,
|
||||
august: 7,
|
||||
september: 8,
|
||||
oktober: 9,
|
||||
november: 10,
|
||||
dezember: 11
|
||||
};
|
||||
for (const [name, idx] of Object.entries(MONTHS)) {
|
||||
if (lower.includes(name)) {
|
||||
const yearMatch = lower.match(/\b(20\d{2})\b/);
|
||||
const year = yearMatch ? parseInt(yearMatch[1]) : now.getFullYear();
|
||||
const start2 = new Date(year, idx, 1);
|
||||
const end2 = new Date(year, idx + 1, 0, 23, 59, 59);
|
||||
return { start: start2, end: end2, label: start2.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) };
|
||||
}
|
||||
}
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||||
return { start, end, label: start.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) };
|
||||
}
|
||||
getFileDate(file) {
|
||||
var _a, _b, _c;
|
||||
const m = file.basename.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m)
|
||||
return new Date(+m[1], +m[2] - 1, +m[3]);
|
||||
const fm = (_a = this.app.metadataCache.getFileCache(file)) == null ? void 0 : _a.frontmatter;
|
||||
if (fm) {
|
||||
const raw = (_c = (_b = fm["created"]) != null ? _b : fm["date"]) != null ? _c : fm["datum"];
|
||||
if (raw) {
|
||||
const d = new Date(raw);
|
||||
if (!isNaN(d.getTime()))
|
||||
return d;
|
||||
}
|
||||
}
|
||||
return new Date(file.stat.ctime);
|
||||
}
|
||||
findFilesByDate(start, end, folders) {
|
||||
const s = start.getTime();
|
||||
const e = end.getTime();
|
||||
return this.app.vault.getMarkdownFiles().filter((file) => {
|
||||
if (folders.length > 0 && !folders.some((f) => file.path.startsWith(f.endsWith("/") ? f : f + "/")))
|
||||
return false;
|
||||
const t = this.getFileDate(file).getTime();
|
||||
return t >= s && t <= e;
|
||||
});
|
||||
}
|
||||
// ─── Persistence ─────────────────────────────────────────────────────────
|
||||
loadThreads() {
|
||||
var _a;
|
||||
@@ -541,6 +726,7 @@ ${content}`;
|
||||
const fileName = `${folder}/${date} ${safeName}.md`;
|
||||
let content = `---
|
||||
created: ${date}
|
||||
id: ${thread.id}
|
||||
tags: [chat]
|
||||
---
|
||||
|
||||
@@ -571,10 +757,132 @@ tags: [chat]
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
/** Parse a vault chat file back into a Thread object */
|
||||
async parseThreadFromVault(file) {
|
||||
try {
|
||||
const raw = await this.app.vault.cachedRead(file);
|
||||
let id;
|
||||
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
const idMatch = fmMatch[1].match(/^id:\s*(.+)$/m);
|
||||
if (idMatch)
|
||||
id = idMatch[1].trim();
|
||||
}
|
||||
const titleMatch = raw.match(/^# (.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : file.basename;
|
||||
const messages = [];
|
||||
const body = fmMatch ? raw.slice(fmMatch[0].length) : raw;
|
||||
const lines = body.split("\n");
|
||||
let currentRole = null;
|
||||
let currentContent = [];
|
||||
const flush = () => {
|
||||
if (currentRole && currentContent.length > 0) {
|
||||
messages.push({
|
||||
role: currentRole,
|
||||
content: currentContent.join("\n").trim(),
|
||||
timestamp: file.stat.ctime
|
||||
});
|
||||
}
|
||||
currentContent = [];
|
||||
currentRole = null;
|
||||
};
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("**Du**: ")) {
|
||||
flush();
|
||||
currentRole = "user";
|
||||
currentContent.push(line.slice("**Du**: ".length));
|
||||
} else if (line.startsWith("**Claude**: ")) {
|
||||
flush();
|
||||
currentRole = "assistant";
|
||||
currentContent.push(line.slice("**Claude**: ".length));
|
||||
} else if (currentRole) {
|
||||
if (line.startsWith("> Kontext:"))
|
||||
continue;
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return {
|
||||
id: id != null ? id : file.stat.ctime.toString(),
|
||||
title,
|
||||
messages,
|
||||
created: file.stat.ctime,
|
||||
updated: file.stat.mtime
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Load saved vault files not already in this.threads */
|
||||
async loadHistoryFromVault() {
|
||||
const folder = this.plugin.settings.threadsFolder;
|
||||
const loadedIds = new Set(this.threads.map((t) => t.id));
|
||||
const results = [];
|
||||
const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder + "/")).sort((a, b) => b.stat.mtime - a.stat.mtime);
|
||||
for (const file of files) {
|
||||
const thread = await this.parseThreadFromVault(file);
|
||||
if (thread && !loadedIds.has(thread.id)) {
|
||||
results.push(thread);
|
||||
loadedIds.add(thread.id);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
async renderHistorySection() {
|
||||
var _a;
|
||||
const existing = (_a = this.threadListEl.parentElement) == null ? void 0 : _a.querySelector(".vc-history-section");
|
||||
if (existing)
|
||||
existing.remove();
|
||||
if (!this.plugin.settings.saveThreadsToVault)
|
||||
return;
|
||||
const sidebar = this.threadListEl.parentElement;
|
||||
const section = sidebar.createDiv("vc-history-section");
|
||||
const toggle = section.createDiv("vc-history-toggle");
|
||||
toggle.createEl("span", { text: this.historyExpanded ? "\u25BE" : "\u25B8", cls: "vc-history-arrow" });
|
||||
toggle.createEl("span", { text: "Verlauf", cls: "vc-history-label" });
|
||||
const listEl = section.createDiv("vc-history-list");
|
||||
listEl.style.display = this.historyExpanded ? "block" : "none";
|
||||
toggle.onclick = async () => {
|
||||
this.historyExpanded = !this.historyExpanded;
|
||||
toggle.empty();
|
||||
toggle.createEl("span", { text: this.historyExpanded ? "\u25BE" : "\u25B8", cls: "vc-history-arrow" });
|
||||
toggle.createEl("span", { text: "Verlauf", cls: "vc-history-label" });
|
||||
listEl.style.display = this.historyExpanded ? "block" : "none";
|
||||
if (this.historyExpanded && this.historyThreads.length === 0) {
|
||||
listEl.setText("Lade\u2026");
|
||||
this.historyThreads = await this.loadHistoryFromVault();
|
||||
this.renderHistoryList(listEl);
|
||||
}
|
||||
};
|
||||
if (this.historyExpanded) {
|
||||
if (this.historyThreads.length === 0) {
|
||||
this.historyThreads = await this.loadHistoryFromVault();
|
||||
}
|
||||
this.renderHistoryList(listEl);
|
||||
}
|
||||
}
|
||||
renderHistoryList(listEl) {
|
||||
listEl.empty();
|
||||
if (this.historyThreads.length === 0) {
|
||||
listEl.createEl("div", { text: "Keine gespeicherten Chats", cls: "vc-history-empty" });
|
||||
return;
|
||||
}
|
||||
for (const thread of this.historyThreads) {
|
||||
const item = listEl.createDiv("vc-history-item");
|
||||
item.createEl("span", { text: thread.title, cls: "vc-thread-title" });
|
||||
item.onclick = () => {
|
||||
this.threads.unshift(thread);
|
||||
this.historyThreads = this.historyThreads.filter((t) => t.id !== thread.id);
|
||||
this.switchThread(thread.id);
|
||||
this.renderHistoryList(listEl);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/VaultSearch.ts
|
||||
var VaultSearch = class {
|
||||
// tokens from priority properties count 5x
|
||||
constructor(app) {
|
||||
this.docVectors = /* @__PURE__ */ new Map();
|
||||
// path -> term -> tfidf
|
||||
@@ -582,6 +890,9 @@ var VaultSearch = class {
|
||||
this.docContents = /* @__PURE__ */ new Map();
|
||||
this.indexed = false;
|
||||
this.indexing = false;
|
||||
/** Frontmatter properties whose values are boosted during indexing */
|
||||
this.priorityProperties = ["collection", "related", "up", "tags"];
|
||||
this.propertyBoost = 5;
|
||||
this.app = app;
|
||||
}
|
||||
/** Tokenize text: lowercase, split on non-word chars, keep umlauts */
|
||||
@@ -605,7 +916,7 @@ var VaultSearch = class {
|
||||
}
|
||||
/** Build or rebuild the TF-IDF index */
|
||||
async buildIndex() {
|
||||
var _a, _b, _c;
|
||||
var _a, _b, _c, _d, _e, _f;
|
||||
if (this.indexing)
|
||||
return;
|
||||
this.indexing = true;
|
||||
@@ -631,6 +942,16 @@ var VaultSearch = class {
|
||||
for (const t of tokens) {
|
||||
tf.set(t, ((_a = tf.get(t)) != null ? _a : 0) + 1);
|
||||
}
|
||||
const fm = (_c = (_b = this.app.metadataCache.getFileCache(file)) == null ? void 0 : _b.frontmatter) != null ? _c : {};
|
||||
for (const prop of this.priorityProperties) {
|
||||
const val = fm[prop];
|
||||
if (!val)
|
||||
continue;
|
||||
const text = Array.isArray(val) ? val.join(" ") : String(val);
|
||||
for (const t of this.tokenize(text)) {
|
||||
tf.set(t, ((_d = tf.get(t)) != null ? _d : 0) + this.propertyBoost);
|
||||
}
|
||||
}
|
||||
const maxTf = Math.max(...tf.values(), 1);
|
||||
const normalizedTf = /* @__PURE__ */ new Map();
|
||||
for (const [t, count] of tf) {
|
||||
@@ -638,7 +959,7 @@ var VaultSearch = class {
|
||||
}
|
||||
tfs.set(file.path, normalizedTf);
|
||||
for (const t of tf.keys()) {
|
||||
df.set(t, ((_b = df.get(t)) != null ? _b : 0) + 1);
|
||||
df.set(t, ((_e = df.get(t)) != null ? _e : 0) + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
@@ -651,7 +972,7 @@ var VaultSearch = class {
|
||||
const vec = /* @__PURE__ */ new Map();
|
||||
let norm = 0;
|
||||
for (const [term, tfVal] of tf) {
|
||||
const idfVal = (_c = this.idf.get(term)) != null ? _c : 0;
|
||||
const idfVal = (_f = this.idf.get(term)) != null ? _f : 0;
|
||||
const tfidf = tfVal * idfVal;
|
||||
vec.set(term, tfidf);
|
||||
norm += tfidf * tfidf;
|
||||
@@ -674,6 +995,34 @@ var VaultSearch = class {
|
||||
isIndexed() {
|
||||
return this.indexed;
|
||||
}
|
||||
/** Find notes with similar names (no index required). Uses substring + word-overlap scoring. */
|
||||
findSimilarByName(query, topK = 2, minScore = 0.45) {
|
||||
const normalize = (s) => s.toLowerCase().replace(/[^\wäöüß\s]/gi, " ").trim();
|
||||
const words = (s) => new Set(s.split(/\s+/).filter((w) => w.length > 1));
|
||||
const q = normalize(query);
|
||||
const qWords = words(q);
|
||||
const scored = [];
|
||||
for (const file of this.app.vault.getMarkdownFiles()) {
|
||||
const name = normalize(file.basename);
|
||||
const nameWords = words(name);
|
||||
let score = 0;
|
||||
if (name.includes(q) || q.includes(name))
|
||||
score = 0.9;
|
||||
const intersection = [...qWords].filter((w) => nameWords.has(w)).length;
|
||||
const union = (/* @__PURE__ */ new Set([...qWords, ...nameWords])).size;
|
||||
if (union > 0)
|
||||
score = Math.max(score, intersection / union);
|
||||
if (score >= minScore)
|
||||
scored.push([file, score]);
|
||||
}
|
||||
scored.sort((a, b) => b[1] - a[1]);
|
||||
return scored.slice(0, topK).map(([file, score]) => ({
|
||||
file,
|
||||
score,
|
||||
excerpt: "",
|
||||
title: file.basename
|
||||
}));
|
||||
}
|
||||
/** Search for the top-K most similar notes to the query */
|
||||
async search(query, topK = 8) {
|
||||
var _a, _b, _c;
|
||||
@@ -831,7 +1180,21 @@ Wenn du Fragen beantwortest:
|
||||
showContextPreview: true,
|
||||
saveThreadsToVault: true,
|
||||
threadsFolder: "Calendar/Chat",
|
||||
sendOnEnter: false
|
||||
sendOnEnter: false,
|
||||
contextProperties: ["collection", "related", "up", "tags"],
|
||||
promptButtons: [
|
||||
{
|
||||
label: "Draft Check",
|
||||
filePath: "Schreibdenken/ferals/Code/Prompts/COHERENCE CHECK",
|
||||
helpText: "\u{1F4DD} DRAFT \u2014 Fr\xFChphase: Kernbotschaft, Koh\xE4renz, grobe Struktur\n\u2702\uFE0F PRE-PUBLISH \u2014 Fast fertig: Feinschliff, Sprache, Logik\n\u{1F50D} DIAGNOSTIC \u2014 Gezielte Analyse: ein spezifisches Problem benennen\n\nGib die Phase an und f\xFCge deinen Text mit @[[Notiz]] ein."
|
||||
},
|
||||
{
|
||||
label: "Monthly Check",
|
||||
filePath: "Schreibdenken/ferals/Code/Prompts/MONTHLY COHERENCE AUDIT",
|
||||
searchMode: "date",
|
||||
searchFolders: ["Schreibdenken/ferals/Content/Artikel"]
|
||||
}
|
||||
]
|
||||
};
|
||||
var MODELS = [
|
||||
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (St\xE4rkst)" },
|
||||
@@ -894,6 +1257,172 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h3", { text: "Priorit\xE4ts-Properties" });
|
||||
containerEl.createEl("p", {
|
||||
text: "Frontmatter-Properties, deren Werte bei der Kontextsuche st\xE4rker gewichtet werden (z.B. related, collection, up, tags). Nach \xC4nderung den Index neu aufbauen.",
|
||||
cls: "setting-item-description"
|
||||
});
|
||||
const propSetting = new import_obsidian3.Setting(containerEl).setName("Properties");
|
||||
propSetting.settingEl.style.flexWrap = "wrap";
|
||||
propSetting.settingEl.style.alignItems = "flex-start";
|
||||
const tagContainer = propSetting.controlEl.createDiv("vc-prop-tags");
|
||||
const renderTags = () => {
|
||||
tagContainer.empty();
|
||||
for (const prop of this.plugin.settings.contextProperties) {
|
||||
const tag = tagContainer.createEl("span", { cls: "vc-prop-tag" });
|
||||
tag.createEl("span", { text: prop });
|
||||
const removeBtn = tag.createEl("button", { cls: "vc-prop-tag-remove", text: "\xD7" });
|
||||
removeBtn.onclick = async () => {
|
||||
this.plugin.settings.contextProperties = this.plugin.settings.contextProperties.filter(
|
||||
(p) => p !== prop
|
||||
);
|
||||
await this.plugin.saveSettings();
|
||||
renderTags();
|
||||
};
|
||||
}
|
||||
};
|
||||
renderTags();
|
||||
const addRow = propSetting.controlEl.createDiv("vc-prop-add-row");
|
||||
const addInput = addRow.createEl("input", {
|
||||
cls: "vc-prop-input",
|
||||
attr: { type: "text", placeholder: "Property hinzuf\xFCgen\u2026" }
|
||||
});
|
||||
const addBtn = addRow.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
||||
const doAdd = async () => {
|
||||
const val = addInput.value.trim().toLowerCase();
|
||||
if (!val || this.plugin.settings.contextProperties.includes(val))
|
||||
return;
|
||||
this.plugin.settings.contextProperties = [...this.plugin.settings.contextProperties, val];
|
||||
await this.plugin.saveSettings();
|
||||
addInput.value = "";
|
||||
renderTags();
|
||||
};
|
||||
addBtn.onclick = doAdd;
|
||||
addInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
doAdd();
|
||||
}
|
||||
});
|
||||
containerEl.createEl("h3", { text: "Prompt-Buttons" });
|
||||
containerEl.createEl("p", {
|
||||
text: "Buttons in der Chat-Leiste, die den System-Prompt um den Inhalt einer Vault-Notiz erweitern.",
|
||||
cls: "setting-item-description"
|
||||
});
|
||||
const btnListEl = containerEl.createDiv("vc-pbtn-list");
|
||||
const renderBtnList = () => {
|
||||
var _a;
|
||||
btnListEl.empty();
|
||||
for (const [idx, pb] of this.plugin.settings.promptButtons.entries()) {
|
||||
const card = btnListEl.createDiv("vc-pbtn-card");
|
||||
const row1 = card.createDiv("vc-pbtn-row");
|
||||
const labelInput = row1.createEl("input", {
|
||||
cls: "vc-pbtn-input",
|
||||
attr: { type: "text", placeholder: "Label", value: pb.label }
|
||||
});
|
||||
labelInput.addEventListener("change", async () => {
|
||||
this.plugin.settings.promptButtons[idx].label = labelInput.value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
const pathInput = row1.createEl("input", {
|
||||
cls: "vc-pbtn-input vc-pbtn-path",
|
||||
attr: { type: "text", placeholder: "Pfad im Vault (ohne .md)", value: pb.filePath }
|
||||
});
|
||||
pathInput.addEventListener("change", async () => {
|
||||
this.plugin.settings.promptButtons[idx].filePath = pathInput.value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
const removeBtn = row1.createEl("button", { cls: "vc-prop-tag-remove", text: "\xD7" });
|
||||
removeBtn.style.fontSize = "16px";
|
||||
removeBtn.onclick = async () => {
|
||||
this.plugin.settings.promptButtons.splice(idx, 1);
|
||||
await this.plugin.saveSettings();
|
||||
renderBtnList();
|
||||
};
|
||||
const row2 = card.createDiv("vc-pbtn-row2");
|
||||
const toggleWrap = row2.createEl("label", { cls: "vc-pbtn-toggle-wrap" });
|
||||
const checkbox = toggleWrap.createEl("input", { attr: { type: "checkbox" } });
|
||||
checkbox.checked = pb.searchMode === "date";
|
||||
toggleWrap.appendText(" Datumsbasierte Suche");
|
||||
const folderSection = row2.createDiv("vc-pbtn-folders");
|
||||
folderSection.style.display = pb.searchMode === "date" ? "flex" : "none";
|
||||
const renderFolders = () => {
|
||||
var _a2;
|
||||
folderSection.empty();
|
||||
folderSection.createEl("span", { text: "Ordner: ", cls: "vc-pbtn-folder-label" });
|
||||
for (const folder of (_a2 = pb.searchFolders) != null ? _a2 : []) {
|
||||
const chip = folderSection.createEl("span", { cls: "vc-prop-tag" });
|
||||
chip.createEl("span", { text: folder });
|
||||
const x = chip.createEl("button", { cls: "vc-prop-tag-remove", text: "\xD7" });
|
||||
x.onclick = async () => {
|
||||
var _a3;
|
||||
pb.searchFolders = ((_a3 = pb.searchFolders) != null ? _a3 : []).filter((f) => f !== folder);
|
||||
await this.plugin.saveSettings();
|
||||
renderFolders();
|
||||
};
|
||||
}
|
||||
const folderInput = folderSection.createEl("input", {
|
||||
cls: "vc-pbtn-input",
|
||||
attr: { type: "text", placeholder: "Ordner hinzuf\xFCgen\u2026", style: "width:180px" }
|
||||
});
|
||||
const doAddFolder = async () => {
|
||||
var _a3;
|
||||
const val = folderInput.value.trim().replace(/\/$/, "");
|
||||
if (!val)
|
||||
return;
|
||||
pb.searchFolders = [...(_a3 = pb.searchFolders) != null ? _a3 : [], val];
|
||||
await this.plugin.saveSettings();
|
||||
renderFolders();
|
||||
};
|
||||
folderInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
doAddFolder();
|
||||
}
|
||||
});
|
||||
const addFolderBtn = folderSection.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
||||
addFolderBtn.onclick = doAddFolder;
|
||||
};
|
||||
renderFolders();
|
||||
checkbox.addEventListener("change", async () => {
|
||||
pb.searchMode = checkbox.checked ? "date" : void 0;
|
||||
if (!checkbox.checked)
|
||||
pb.searchFolders = [];
|
||||
folderSection.style.display = checkbox.checked ? "flex" : "none";
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
const helpLabel = card.createEl("label", { cls: "vc-pbtn-folder-label", text: "Hilfetext (optional, erscheint im Chat wenn Button aktiv):" });
|
||||
const helpTextArea = card.createEl("textarea", {
|
||||
cls: "vc-pbtn-help-textarea",
|
||||
attr: { rows: "3", placeholder: "z.B. DRAFT \u2014 Fr\xFChphase\u2026\nPRE-PUBLISH \u2014 Fast fertig\u2026" }
|
||||
});
|
||||
helpTextArea.value = (_a = pb.helpText) != null ? _a : "";
|
||||
helpTextArea.addEventListener("change", async () => {
|
||||
pb.helpText = helpTextArea.value.trim() || void 0;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
}
|
||||
const addRow2 = btnListEl.createDiv("vc-pbtn-add-row");
|
||||
const newLabel = addRow2.createEl("input", {
|
||||
cls: "vc-pbtn-input",
|
||||
attr: { type: "text", placeholder: "Label (z.B. Draft Check)" }
|
||||
});
|
||||
const newPath = addRow2.createEl("input", {
|
||||
cls: "vc-pbtn-input vc-pbtn-path",
|
||||
attr: { type: "text", placeholder: "Pfad/zur/Prompt-Notiz" }
|
||||
});
|
||||
const addBtn2 = addRow2.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
||||
addBtn2.onclick = async () => {
|
||||
const label = newLabel.value.trim();
|
||||
const filePath = newPath.value.trim();
|
||||
if (!label || !filePath)
|
||||
return;
|
||||
this.plugin.settings.promptButtons.push({ label, filePath });
|
||||
await this.plugin.saveSettings();
|
||||
renderBtnList();
|
||||
};
|
||||
};
|
||||
renderBtnList();
|
||||
containerEl.createEl("h3", { text: "Thread-History" });
|
||||
new import_obsidian3.Setting(containerEl).setName("Threads im Vault speichern").setDesc("Chat-Threads als Markdown-Notizen im Vault ablegen").addToggle(
|
||||
(toggle) => toggle.setValue(this.plugin.settings.saveThreadsToVault).onChange(async (value) => {
|
||||
@@ -937,11 +1466,21 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
||||
// src/main.ts
|
||||
var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
||||
async onload() {
|
||||
var _a, _b;
|
||||
var _a, _b, _c;
|
||||
const loaded = await this.loadData();
|
||||
const mergedSettings = { ...DEFAULT_SETTINGS, ...(_a = loaded == null ? void 0 : loaded.settings) != null ? _a : {} };
|
||||
if ((_b = loaded == null ? void 0 : loaded.settings) == null ? void 0 : _b.promptButtons) {
|
||||
mergedSettings.promptButtons = loaded.settings.promptButtons.map((saved, i) => {
|
||||
var _a2;
|
||||
return {
|
||||
...(_a2 = DEFAULT_SETTINGS.promptButtons[i]) != null ? _a2 : {},
|
||||
...saved
|
||||
};
|
||||
});
|
||||
}
|
||||
this.data = {
|
||||
settings: { ...DEFAULT_SETTINGS, ...(_a = loaded == null ? void 0 : loaded.settings) != null ? _a : {} },
|
||||
threads: (_b = loaded == null ? void 0 : loaded.threads) != null ? _b : []
|
||||
settings: mergedSettings,
|
||||
threads: (_c = loaded == null ? void 0 : loaded.threads) != null ? _c : []
|
||||
};
|
||||
this.settings = this.data.settings;
|
||||
this.search = new VaultSearch(this.app);
|
||||
@@ -980,6 +1519,7 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
||||
this.addSettingTab(new MemexChatSettingsTab(this.app, this));
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
if (!this.search.isIndexed()) {
|
||||
this.search.priorityProperties = this.settings.contextProperties;
|
||||
this.search.buildIndex().catch(console.error);
|
||||
}
|
||||
});
|
||||
@@ -994,7 +1534,7 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
||||
this.app.workspace.revealLeaf(existing[0]);
|
||||
return;
|
||||
}
|
||||
const leaf = this.app.workspace.getRightLeaf(false);
|
||||
const leaf = this.app.workspace.getLeaf("tab");
|
||||
if (!leaf)
|
||||
return;
|
||||
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
|
||||
@@ -1004,6 +1544,7 @@ var MemexChatPlugin = class extends import_obsidian4.Plugin {
|
||||
var _a;
|
||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
|
||||
const view = (_a = leaves[0]) == null ? void 0 : _a.view;
|
||||
this.search.priorityProperties = this.settings.contextProperties;
|
||||
this.search.onProgress = (done, total) => {
|
||||
if (view && done % 200 === 0) {
|
||||
view.setStatus(`Indiziere\u2026 ${done}/${total}`);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "memex-chat",
|
||||
"name": "Memex Chat",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.",
|
||||
"author": "Sven",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "memex-chat",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Obsidian plugin: Chat with your vault using Claude AI",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
+343
-17
@@ -35,6 +35,7 @@ export class ChatView extends ItemView {
|
||||
private messagesEl!: HTMLElement;
|
||||
private inputEl!: HTMLTextAreaElement;
|
||||
private contextPreviewEl!: HTMLElement;
|
||||
private modeHintEl!: HTMLElement;
|
||||
private sendBtn!: HTMLButtonElement;
|
||||
private statusEl!: HTMLElement;
|
||||
private mentionDropdownEl!: HTMLElement;
|
||||
@@ -43,6 +44,9 @@ export class ChatView extends ItemView {
|
||||
private mentionSelectedIdx = 0;
|
||||
private mentionMatches: string[] = [];
|
||||
|
||||
// Active prompt extension buttons (file paths)
|
||||
private activeExtensions: Set<string> = new Set();
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
@@ -87,6 +91,23 @@ export class ChatView extends ItemView {
|
||||
// Header
|
||||
const header = root.createDiv("vc-header");
|
||||
header.createEl("span", { text: "Memex Chat", cls: "vc-header-title" });
|
||||
|
||||
// Prompt-extension mode buttons (centre)
|
||||
const modeBtns = header.createDiv("vc-header-modes");
|
||||
for (const pb of this.plugin.settings.promptButtons) {
|
||||
const modeBtn = modeBtns.createEl("button", { text: pb.label, cls: "vc-mode-btn" });
|
||||
modeBtn.onclick = () => {
|
||||
if (this.activeExtensions.has(pb.filePath)) {
|
||||
this.activeExtensions.delete(pb.filePath);
|
||||
modeBtn.removeClass("vc-mode-btn--active");
|
||||
} else {
|
||||
this.activeExtensions.add(pb.filePath);
|
||||
modeBtn.addClass("vc-mode-btn--active");
|
||||
}
|
||||
this.updateModeHint();
|
||||
};
|
||||
}
|
||||
|
||||
const headerActions = header.createDiv("vc-header-actions");
|
||||
|
||||
const newThreadBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Neuer Thread" });
|
||||
@@ -127,13 +148,17 @@ export class ChatView extends ItemView {
|
||||
// Input area
|
||||
const inputArea = chatArea.createDiv("vc-input-area");
|
||||
|
||||
// Mode hint panel (inside input area, above textarea)
|
||||
this.modeHintEl = inputArea.createDiv("vc-mode-hint");
|
||||
this.modeHintEl.style.display = "none";
|
||||
|
||||
const inputWrapper = inputArea.createDiv("vc-input-wrapper");
|
||||
// Dropdown appended to root to escape overflow:hidden ancestors
|
||||
this.mentionDropdownEl = root.createDiv("vc-mention-dropdown");
|
||||
this.mentionDropdownEl.style.display = "none";
|
||||
this.inputEl = inputWrapper.createEl("textarea", {
|
||||
cls: "vc-input",
|
||||
attr: { placeholder: "Frage stellen… (@ für Notiz einfügen)" },
|
||||
attr: { placeholder: "Frage stellen… (@ für Notiz)" },
|
||||
});
|
||||
this.inputEl.rows = 3;
|
||||
|
||||
@@ -171,14 +196,15 @@ export class ChatView extends ItemView {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
const isCmdEnter = e.metaKey || e.ctrlKey;
|
||||
const sendOnEnter = this.plugin.settings.sendOnEnter;
|
||||
if (sendOnEnter && e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this.handleSend();
|
||||
} else if (!sendOnEnter && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
if (isCmdEnter || (sendOnEnter && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.handleSend();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.inputEl.addEventListener("input", () => this.handleInputChange());
|
||||
@@ -234,25 +260,41 @@ export class ChatView extends ItemView {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse @[[mentions]] from input
|
||||
const mentionPattern = /\[\[([^\]]+)\]\]/g;
|
||||
// Parse @Notizname mentions from input
|
||||
const mentionPattern = /@([\w\däöüÄÖÜß][^@\n]{1,}?)(?=\s|$)/g;
|
||||
const mentions: TFile[] = [];
|
||||
let match;
|
||||
while ((match = mentionPattern.exec(query)) !== null) {
|
||||
const name = match[1];
|
||||
const name = match[1].trim();
|
||||
const file = this.app.metadataCache.getFirstLinkpathDest(name, "");
|
||||
if (file) mentions.push(file);
|
||||
}
|
||||
|
||||
// If context preview is enabled and auto-retrieve is on, fetch context first
|
||||
// With active prompt extensions, skip auto-retrieve and clear any leftover context
|
||||
if (this.activeExtensions.size === 0) {
|
||||
if (this.plugin.settings.autoRetrieveContext && this.plugin.settings.showContextPreview) {
|
||||
if (this.pendingContext.length === 0 && this.explicitContext.length === 0) {
|
||||
await this.fetchAndShowContext(query, mentions);
|
||||
return; // wait for user to confirm/modify context
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.pendingContext = [];
|
||||
this.explicitContext = [];
|
||||
}
|
||||
|
||||
await this.sendMessage(query, mentions);
|
||||
// Date-based context for active date-search buttons
|
||||
const dateFiles: TFile[] = [];
|
||||
for (const pb of this.plugin.settings.promptButtons) {
|
||||
if (pb.searchMode === "date" && this.activeExtensions.has(pb.filePath)) {
|
||||
const { start, end, label } = this.parseDateRange(query);
|
||||
const found = this.findFilesByDate(start, end, pb.searchFolders ?? []);
|
||||
dateFiles.push(...found);
|
||||
this.setStatus(`${found.length} Texte aus ${label} gefunden`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.sendMessage(query, [...mentions, ...dateFiles]);
|
||||
}
|
||||
|
||||
private async fetchAndShowContext(query: string, mentions: TFile[]): Promise<void> {
|
||||
@@ -349,11 +391,24 @@ export class ChatView extends ItemView {
|
||||
|
||||
this.setStatus("Claude denkt…");
|
||||
|
||||
// Build effective system prompt (base + any active extensions)
|
||||
let systemPrompt = this.plugin.settings.systemPrompt;
|
||||
for (const filePath of this.activeExtensions) {
|
||||
const file =
|
||||
this.app.metadataCache.getFirstLinkpathDest(filePath, "") ??
|
||||
(this.app.vault.getAbstractFileByPath(filePath + ".md") as TFile | null) ??
|
||||
(this.app.vault.getAbstractFileByPath(filePath) as TFile | null);
|
||||
if (file instanceof TFile) {
|
||||
const ext = await this.app.vault.cachedRead(file);
|
||||
systemPrompt += "\n\n---\n" + ext;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = this.plugin.claude.streamChat(claudeMessages, {
|
||||
apiKey: this.plugin.settings.apiKey,
|
||||
model: this.plugin.settings.model,
|
||||
systemPrompt: this.plugin.settings.systemPrompt,
|
||||
systemPrompt,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -415,7 +470,7 @@ export class ChatView extends ItemView {
|
||||
|
||||
const itemHeader = item.createDiv("vc-ctx-item-header");
|
||||
const titleEl = itemHeader.createEl("span", { text: result.title, cls: "vc-ctx-item-title" });
|
||||
titleEl.onclick = () => this.app.workspace.openLinkText(result.file.path, "", false);
|
||||
titleEl.onclick = () => this.app.workspace.openLinkText(result.file.path, "", "tab");
|
||||
|
||||
itemHeader.createEl("span", { text: `${score}%`, cls: "vc-ctx-score" });
|
||||
|
||||
@@ -439,6 +494,28 @@ export class ChatView extends ItemView {
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
private updateModeHint(): void {
|
||||
// Collect helpTexts from all active extensions that have one
|
||||
const hints: string[] = [];
|
||||
for (const pb of this.plugin.settings.promptButtons) {
|
||||
if (this.activeExtensions.has(pb.filePath) && pb.helpText) {
|
||||
hints.push(pb.helpText);
|
||||
}
|
||||
}
|
||||
if (hints.length > 0) {
|
||||
this.modeHintEl.empty();
|
||||
for (const hint of hints) {
|
||||
const div = this.modeHintEl.createDiv("vc-mode-hint-text");
|
||||
div.textContent = hint;
|
||||
}
|
||||
this.modeHintEl.style.display = "block";
|
||||
this.inputEl.placeholder = "";
|
||||
} else {
|
||||
this.modeHintEl.style.display = "none";
|
||||
this.inputEl.placeholder = "Frage stellen… (@ für Notiz)";
|
||||
}
|
||||
}
|
||||
|
||||
private async openContextPicker(): Promise<void> {
|
||||
const lastUserMsg = [...(this.activeThread?.messages ?? [])]
|
||||
.reverse()
|
||||
@@ -464,6 +541,10 @@ export class ChatView extends ItemView {
|
||||
const item = this.threadListEl.createDiv("vc-thread-item" + (thread.id === this.activeThreadId ? " vc-thread-item--active" : ""));
|
||||
const titleEl = item.createEl("span", { text: thread.title, cls: "vc-thread-title" });
|
||||
titleEl.onclick = () => this.switchThread(thread.id);
|
||||
titleEl.ondblclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.startRenameThread(thread, titleEl);
|
||||
};
|
||||
|
||||
const del = item.createEl("button", { cls: "vc-icon-btn vc-thread-del", title: "Löschen" });
|
||||
del.innerHTML = "✕";
|
||||
@@ -472,6 +553,27 @@ export class ChatView extends ItemView {
|
||||
this.deleteThread(thread.id);
|
||||
};
|
||||
}
|
||||
this.renderHistorySection();
|
||||
}
|
||||
|
||||
private startRenameThread(thread: Thread, titleEl: HTMLElement): void {
|
||||
const input = document.createElement("input");
|
||||
input.className = "vc-thread-rename";
|
||||
input.value = thread.title;
|
||||
titleEl.replaceWith(input);
|
||||
input.select();
|
||||
const finish = () => {
|
||||
const newTitle = input.value.trim() || thread.title;
|
||||
thread.title = newTitle;
|
||||
this.saveThreads();
|
||||
this.renderThreadList();
|
||||
};
|
||||
input.addEventListener("blur", finish);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
|
||||
if (e.key === "Escape") { input.value = thread.title; input.blur(); }
|
||||
});
|
||||
input.focus();
|
||||
}
|
||||
|
||||
private renderMessages(): void {
|
||||
@@ -481,7 +583,7 @@ export class ChatView extends ItemView {
|
||||
const empty = this.messagesEl.createDiv("vc-empty");
|
||||
empty.createEl("div", { text: "💬", cls: "vc-empty-icon" });
|
||||
empty.createEl("div", { text: "Stell eine Frage — ich suche passende Notizen aus deinem Vault.", cls: "vc-empty-text" });
|
||||
empty.createEl("div", { text: "Tipp: Nutze @[[Notizname]] um eine Notiz direkt einzubinden.", cls: "vc-empty-hint" });
|
||||
empty.createEl("div", { text: "Tipp: Nutze @Notizname um eine Notiz direkt einzubinden.", cls: "vc-empty-hint" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -506,6 +608,33 @@ export class ChatView extends ItemView {
|
||||
mdEl.createEl("span", { cls: "vc-cursor", text: "█" });
|
||||
} else {
|
||||
MarkdownRenderer.render(this.app, msg.content, mdEl, "", this.renderComponent);
|
||||
// Wire up internal [[links]] to open / create notes
|
||||
mdEl.querySelectorAll("a.internal-link").forEach((a) => {
|
||||
const href = (a as HTMLAnchorElement).getAttribute("href") ?? a.textContent ?? "";
|
||||
const exists = !!this.app.metadataCache.getFirstLinkpathDest(href, "");
|
||||
if (!exists) {
|
||||
a.classList.add("is-unresolved");
|
||||
// Suggest similar existing notes
|
||||
const similar = this.plugin.search.findSimilarByName(href, 2, 0.45);
|
||||
if (similar.length > 0) {
|
||||
const hint = (a.parentElement as HTMLElement).createEl("span", { cls: "vc-link-hint" });
|
||||
hint.createEl("span", { text: " → Ähnliche Notiz: ", cls: "vc-link-hint-label" });
|
||||
similar.forEach((r, i) => {
|
||||
if (i > 0) hint.appendText(", ");
|
||||
const link = hint.createEl("a", { text: r.title, cls: "internal-link vc-link-hint-target" });
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.app.workspace.openLinkText(r.file.path, "", false);
|
||||
});
|
||||
});
|
||||
a.insertAdjacentElement("afterend", hint);
|
||||
}
|
||||
}
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.app.workspace.openLinkText(href, "", "tab");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +646,7 @@ export class ChatView extends ItemView {
|
||||
const file = this.app.vault.getAbstractFileByPath(notePath);
|
||||
const name = file instanceof TFile ? file.basename : notePath.split("/").pop() ?? notePath;
|
||||
const link = sources.createEl("span", { text: `[[${name}]]`, cls: "vc-source-link" });
|
||||
link.onclick = () => this.app.workspace.openLinkText(notePath, "", false);
|
||||
link.onclick = () => this.app.workspace.openLinkText(notePath, "", "tab");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -616,10 +745,10 @@ export class ChatView extends ItemView {
|
||||
const cursor = this.inputEl.selectionStart ?? 0;
|
||||
const text = this.inputEl.value;
|
||||
const textBefore = text.slice(0, cursor);
|
||||
const match = textBefore.match(/@([^@\n[\]]{2,})$/);
|
||||
const match = textBefore.match(/@([^@\n]{2,})$/);
|
||||
if (!match) return;
|
||||
const start = cursor - match[0].length;
|
||||
const replacement = `[[${basename}]]`;
|
||||
const replacement = `@${basename}`;
|
||||
this.inputEl.value = text.slice(0, start) + replacement + text.slice(cursor);
|
||||
const newCursor = start + replacement.length;
|
||||
this.inputEl.setSelectionRange(newCursor, newCursor);
|
||||
@@ -633,6 +762,65 @@ export class ChatView extends ItemView {
|
||||
this.mentionSelectedIdx = 0;
|
||||
}
|
||||
|
||||
// ─── Date-based context search ────────────────────────────────────────────
|
||||
|
||||
private parseDateRange(query: string): { start: Date; end: Date; label: string } {
|
||||
const now = new Date();
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
// Relative: letzter / voriger Monat
|
||||
if (/letzt[eaem]n?\s+monat|vorig[eaem]n?\s+monat/.test(lower)) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
|
||||
return { start, end, label: start.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) };
|
||||
}
|
||||
|
||||
// German month names (+ optional year)
|
||||
const MONTHS: Record<string, number> = {
|
||||
januar: 0, februar: 1, "märz": 2, april: 3, mai: 4, juni: 5,
|
||||
juli: 6, august: 7, september: 8, oktober: 9, november: 10, dezember: 11,
|
||||
};
|
||||
for (const [name, idx] of Object.entries(MONTHS)) {
|
||||
if (lower.includes(name)) {
|
||||
const yearMatch = lower.match(/\b(20\d{2})\b/);
|
||||
const year = yearMatch ? parseInt(yearMatch[1]) : now.getFullYear();
|
||||
const start = new Date(year, idx, 1);
|
||||
const end = new Date(year, idx + 1, 0, 23, 59, 59);
|
||||
return { start, end, label: start.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) };
|
||||
}
|
||||
}
|
||||
|
||||
// Default: current month
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||||
return { start, end, label: start.toLocaleDateString("de-DE", { month: "long", year: "numeric" }) };
|
||||
}
|
||||
|
||||
private getFileDate(file: TFile): Date {
|
||||
// 1. Filename starts with YYYY-MM-DD
|
||||
const m = file.basename.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
|
||||
// 2. Frontmatter created / date / datum
|
||||
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
if (fm) {
|
||||
const raw = fm["created"] ?? fm["date"] ?? fm["datum"];
|
||||
if (raw) { const d = new Date(raw); if (!isNaN(d.getTime())) return d; }
|
||||
}
|
||||
// 3. Filesystem ctime
|
||||
return new Date(file.stat.ctime);
|
||||
}
|
||||
|
||||
private findFilesByDate(start: Date, end: Date, folders: string[]): TFile[] {
|
||||
const s = start.getTime();
|
||||
const e = end.getTime();
|
||||
return this.app.vault.getMarkdownFiles().filter((file) => {
|
||||
if (folders.length > 0 && !folders.some((f) => file.path.startsWith(f.endsWith("/") ? f : f + "/")))
|
||||
return false;
|
||||
const t = this.getFileDate(file).getTime();
|
||||
return t >= s && t <= e;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Persistence ─────────────────────────────────────────────────────────
|
||||
|
||||
private loadThreads(): void {
|
||||
@@ -653,7 +841,7 @@ export class ChatView extends ItemView {
|
||||
const safeName = thread.title.replace(/[\\/:*?"<>|]/g, " ").slice(0, 60);
|
||||
const fileName = `${folder}/${date} ${safeName}.md`;
|
||||
|
||||
let content = `---\ncreated: ${date}\ntags: [chat]\n---\n\n# ${thread.title}\n\n`;
|
||||
let content = `---\ncreated: ${date}\nid: ${thread.id}\ntags: [chat]\n---\n\n# ${thread.title}\n\n`;
|
||||
for (const msg of thread.messages) {
|
||||
const role = msg.role === "user" ? "**Du**" : "**Claude**";
|
||||
content += `${role}: ${msg.content}\n\n`;
|
||||
@@ -673,4 +861,142 @@ export class ChatView extends ItemView {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a vault chat file back into a Thread object */
|
||||
private async parseThreadFromVault(file: TFile): Promise<Thread | null> {
|
||||
try {
|
||||
const raw = await this.app.vault.cachedRead(file);
|
||||
// Extract frontmatter id if present
|
||||
let id: string | undefined;
|
||||
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
const idMatch = fmMatch[1].match(/^id:\s*(.+)$/m);
|
||||
if (idMatch) id = idMatch[1].trim();
|
||||
}
|
||||
// Extract title from first h1
|
||||
const titleMatch = raw.match(/^# (.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : file.basename;
|
||||
// Extract messages
|
||||
const messages: ChatMessage[] = [];
|
||||
const body = fmMatch ? raw.slice(fmMatch[0].length) : raw;
|
||||
const lines = body.split("\n");
|
||||
let currentRole: "user" | "assistant" | null = null;
|
||||
let currentContent: string[] = [];
|
||||
const flush = () => {
|
||||
if (currentRole && currentContent.length > 0) {
|
||||
messages.push({
|
||||
role: currentRole,
|
||||
content: currentContent.join("\n").trim(),
|
||||
timestamp: file.stat.ctime,
|
||||
});
|
||||
}
|
||||
currentContent = [];
|
||||
currentRole = null;
|
||||
};
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("**Du**: ")) {
|
||||
flush();
|
||||
currentRole = "user";
|
||||
currentContent.push(line.slice("**Du**: ".length));
|
||||
} else if (line.startsWith("**Claude**: ")) {
|
||||
flush();
|
||||
currentRole = "assistant";
|
||||
currentContent.push(line.slice("**Claude**: ".length));
|
||||
} else if (currentRole) {
|
||||
if (line.startsWith("> Kontext:")) continue; // skip context lines
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return {
|
||||
id: id ?? file.stat.ctime.toString(),
|
||||
title,
|
||||
messages,
|
||||
created: file.stat.ctime,
|
||||
updated: file.stat.mtime,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load saved vault files not already in this.threads */
|
||||
private async loadHistoryFromVault(): Promise<Thread[]> {
|
||||
const folder = this.plugin.settings.threadsFolder;
|
||||
const loadedIds = new Set(this.threads.map((t) => t.id));
|
||||
const results: Thread[] = [];
|
||||
const files = this.app.vault.getMarkdownFiles()
|
||||
.filter((f) => f.path.startsWith(folder + "/"))
|
||||
.sort((a, b) => b.stat.mtime - a.stat.mtime);
|
||||
for (const file of files) {
|
||||
const thread = await this.parseThreadFromVault(file);
|
||||
if (thread && !loadedIds.has(thread.id)) {
|
||||
results.push(thread);
|
||||
loadedIds.add(thread.id);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Sidebar History ──────────────────────────────────────────────────────
|
||||
|
||||
private historyExpanded = false;
|
||||
private historyThreads: Thread[] = [];
|
||||
|
||||
async renderHistorySection(): Promise<void> {
|
||||
// Remove existing history section if any
|
||||
const existing = this.threadListEl.parentElement?.querySelector(".vc-history-section");
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!this.plugin.settings.saveThreadsToVault) return;
|
||||
|
||||
const sidebar = this.threadListEl.parentElement as HTMLElement;
|
||||
const section = sidebar.createDiv("vc-history-section");
|
||||
|
||||
const toggle = section.createDiv("vc-history-toggle");
|
||||
toggle.createEl("span", { text: this.historyExpanded ? "▾" : "▸", cls: "vc-history-arrow" });
|
||||
toggle.createEl("span", { text: "Verlauf", cls: "vc-history-label" });
|
||||
|
||||
const listEl = section.createDiv("vc-history-list");
|
||||
listEl.style.display = this.historyExpanded ? "block" : "none";
|
||||
|
||||
toggle.onclick = async () => {
|
||||
this.historyExpanded = !this.historyExpanded;
|
||||
toggle.empty();
|
||||
toggle.createEl("span", { text: this.historyExpanded ? "▾" : "▸", cls: "vc-history-arrow" });
|
||||
toggle.createEl("span", { text: "Verlauf", cls: "vc-history-label" });
|
||||
listEl.style.display = this.historyExpanded ? "block" : "none";
|
||||
if (this.historyExpanded && this.historyThreads.length === 0) {
|
||||
listEl.setText("Lade…");
|
||||
this.historyThreads = await this.loadHistoryFromVault();
|
||||
this.renderHistoryList(listEl);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.historyExpanded) {
|
||||
if (this.historyThreads.length === 0) {
|
||||
this.historyThreads = await this.loadHistoryFromVault();
|
||||
}
|
||||
this.renderHistoryList(listEl);
|
||||
}
|
||||
}
|
||||
|
||||
private renderHistoryList(listEl: HTMLElement): void {
|
||||
listEl.empty();
|
||||
if (this.historyThreads.length === 0) {
|
||||
listEl.createEl("div", { text: "Keine gespeicherten Chats", cls: "vc-history-empty" });
|
||||
return;
|
||||
}
|
||||
for (const thread of this.historyThreads) {
|
||||
const item = listEl.createDiv("vc-history-item");
|
||||
item.createEl("span", { text: thread.title, cls: "vc-thread-title" });
|
||||
item.onclick = () => {
|
||||
// Import into active threads and switch
|
||||
this.threads.unshift(thread);
|
||||
this.historyThreads = this.historyThreads.filter((t) => t.id !== thread.id);
|
||||
this.switchThread(thread.id);
|
||||
this.renderHistoryList(listEl);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import type MemexChatPlugin from "./main";
|
||||
|
||||
export interface PromptButton {
|
||||
label: string;
|
||||
filePath: string; // vault-relative path to the system-prompt note (without .md)
|
||||
searchMode?: "date"; // if set: load notes by date range instead of TF-IDF
|
||||
searchFolders?: string[]; // folders to restrict date search (empty = all vault)
|
||||
helpText?: string; // shown as info panel + changes placeholder when button is active
|
||||
}
|
||||
|
||||
export interface MemexChatSettings {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
@@ -12,6 +20,8 @@ export interface MemexChatSettings {
|
||||
saveThreadsToVault: boolean;
|
||||
threadsFolder: string;
|
||||
sendOnEnter: boolean;
|
||||
contextProperties: string[];
|
||||
promptButtons: PromptButton[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MemexChatSettings = {
|
||||
@@ -32,6 +42,20 @@ Wenn du Fragen beantwortest:
|
||||
saveThreadsToVault: true,
|
||||
threadsFolder: "Calendar/Chat",
|
||||
sendOnEnter: false,
|
||||
contextProperties: ["collection", "related", "up", "tags"],
|
||||
promptButtons: [
|
||||
{
|
||||
label: "Draft Check",
|
||||
filePath: "Schreibdenken/ferals/Code/Prompts/COHERENCE CHECK",
|
||||
helpText: "📝 DRAFT — Frühphase: Kernbotschaft, Kohärenz, grobe Struktur\n✂️ PRE-PUBLISH — Fast fertig: Feinschliff, Sprache, Logik\n🔍 DIAGNOSTIC — Gezielte Analyse: ein spezifisches Problem benennen\n\nGib die Phase an und füge deinen Text mit @[[Notiz]] ein.",
|
||||
},
|
||||
{
|
||||
label: "Monthly Check",
|
||||
filePath: "Schreibdenken/ferals/Code/Prompts/MONTHLY COHERENCE AUDIT",
|
||||
searchMode: "date",
|
||||
searchFolders: ["Schreibdenken/ferals/Content/Artikel"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MODELS = [
|
||||
@@ -143,6 +167,180 @@ export class MemexChatSettingsTab extends PluginSettingTab {
|
||||
})
|
||||
);
|
||||
|
||||
// --- Priority Properties ---
|
||||
containerEl.createEl("h3", { text: "Prioritäts-Properties" });
|
||||
containerEl.createEl("p", {
|
||||
text: "Frontmatter-Properties, deren Werte bei der Kontextsuche stärker gewichtet werden (z.B. related, collection, up, tags). Nach Änderung den Index neu aufbauen.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
|
||||
const propSetting = new Setting(containerEl).setName("Properties");
|
||||
propSetting.settingEl.style.flexWrap = "wrap";
|
||||
propSetting.settingEl.style.alignItems = "flex-start";
|
||||
|
||||
// Tag container
|
||||
const tagContainer = propSetting.controlEl.createDiv("vc-prop-tags");
|
||||
const renderTags = () => {
|
||||
tagContainer.empty();
|
||||
for (const prop of this.plugin.settings.contextProperties) {
|
||||
const tag = tagContainer.createEl("span", { cls: "vc-prop-tag" });
|
||||
tag.createEl("span", { text: prop });
|
||||
const removeBtn = tag.createEl("button", { cls: "vc-prop-tag-remove", text: "×" });
|
||||
removeBtn.onclick = async () => {
|
||||
this.plugin.settings.contextProperties = this.plugin.settings.contextProperties.filter(
|
||||
(p) => p !== prop
|
||||
);
|
||||
await this.plugin.saveSettings();
|
||||
renderTags();
|
||||
};
|
||||
}
|
||||
};
|
||||
renderTags();
|
||||
|
||||
// Add input row
|
||||
const addRow = propSetting.controlEl.createDiv("vc-prop-add-row");
|
||||
const addInput = addRow.createEl("input", {
|
||||
cls: "vc-prop-input",
|
||||
attr: { type: "text", placeholder: "Property hinzufügen…" },
|
||||
}) as HTMLInputElement;
|
||||
const addBtn = addRow.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
||||
|
||||
const doAdd = async () => {
|
||||
const val = addInput.value.trim().toLowerCase();
|
||||
if (!val || this.plugin.settings.contextProperties.includes(val)) return;
|
||||
this.plugin.settings.contextProperties = [...this.plugin.settings.contextProperties, val];
|
||||
await this.plugin.saveSettings();
|
||||
addInput.value = "";
|
||||
renderTags();
|
||||
};
|
||||
addBtn.onclick = doAdd;
|
||||
addInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); doAdd(); }
|
||||
});
|
||||
|
||||
// --- Prompt Buttons ---
|
||||
containerEl.createEl("h3", { text: "Prompt-Buttons" });
|
||||
containerEl.createEl("p", {
|
||||
text: "Buttons in der Chat-Leiste, die den System-Prompt um den Inhalt einer Vault-Notiz erweitern.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
|
||||
const btnListEl = containerEl.createDiv("vc-pbtn-list");
|
||||
const renderBtnList = () => {
|
||||
btnListEl.empty();
|
||||
for (const [idx, pb] of this.plugin.settings.promptButtons.entries()) {
|
||||
const card = btnListEl.createDiv("vc-pbtn-card");
|
||||
|
||||
// ── Row 1: label / path / remove ──
|
||||
const row1 = card.createDiv("vc-pbtn-row");
|
||||
const labelInput = row1.createEl("input", {
|
||||
cls: "vc-pbtn-input",
|
||||
attr: { type: "text", placeholder: "Label", value: pb.label },
|
||||
}) as HTMLInputElement;
|
||||
labelInput.addEventListener("change", async () => {
|
||||
this.plugin.settings.promptButtons[idx].label = labelInput.value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
const pathInput = row1.createEl("input", {
|
||||
cls: "vc-pbtn-input vc-pbtn-path",
|
||||
attr: { type: "text", placeholder: "Pfad im Vault (ohne .md)", value: pb.filePath },
|
||||
}) as HTMLInputElement;
|
||||
pathInput.addEventListener("change", async () => {
|
||||
this.plugin.settings.promptButtons[idx].filePath = pathInput.value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
const removeBtn = row1.createEl("button", { cls: "vc-prop-tag-remove", text: "×" });
|
||||
removeBtn.style.fontSize = "16px";
|
||||
removeBtn.onclick = async () => {
|
||||
this.plugin.settings.promptButtons.splice(idx, 1);
|
||||
await this.plugin.saveSettings();
|
||||
renderBtnList();
|
||||
};
|
||||
|
||||
// ── Row 2: date-search toggle + folders ──
|
||||
const row2 = card.createDiv("vc-pbtn-row2");
|
||||
const toggleWrap = row2.createEl("label", { cls: "vc-pbtn-toggle-wrap" });
|
||||
const checkbox = toggleWrap.createEl("input", { attr: { type: "checkbox" } }) as HTMLInputElement;
|
||||
checkbox.checked = pb.searchMode === "date";
|
||||
toggleWrap.appendText(" Datumsbasierte Suche");
|
||||
|
||||
const folderSection = row2.createDiv("vc-pbtn-folders");
|
||||
folderSection.style.display = pb.searchMode === "date" ? "flex" : "none";
|
||||
|
||||
const renderFolders = () => {
|
||||
folderSection.empty();
|
||||
folderSection.createEl("span", { text: "Ordner: ", cls: "vc-pbtn-folder-label" });
|
||||
for (const folder of (pb.searchFolders ?? [])) {
|
||||
const chip = folderSection.createEl("span", { cls: "vc-prop-tag" });
|
||||
chip.createEl("span", { text: folder });
|
||||
const x = chip.createEl("button", { cls: "vc-prop-tag-remove", text: "×" });
|
||||
x.onclick = async () => {
|
||||
pb.searchFolders = (pb.searchFolders ?? []).filter((f) => f !== folder);
|
||||
await this.plugin.saveSettings();
|
||||
renderFolders();
|
||||
};
|
||||
}
|
||||
const folderInput = folderSection.createEl("input", {
|
||||
cls: "vc-pbtn-input",
|
||||
attr: { type: "text", placeholder: "Ordner hinzufügen…", style: "width:180px" },
|
||||
}) as HTMLInputElement;
|
||||
const doAddFolder = async () => {
|
||||
const val = folderInput.value.trim().replace(/\/$/, "");
|
||||
if (!val) return;
|
||||
pb.searchFolders = [...(pb.searchFolders ?? []), val];
|
||||
await this.plugin.saveSettings();
|
||||
renderFolders();
|
||||
};
|
||||
folderInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doAddFolder(); } });
|
||||
const addFolderBtn = folderSection.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
||||
addFolderBtn.onclick = doAddFolder;
|
||||
};
|
||||
renderFolders();
|
||||
|
||||
checkbox.addEventListener("change", async () => {
|
||||
pb.searchMode = checkbox.checked ? "date" : undefined;
|
||||
if (!checkbox.checked) pb.searchFolders = [];
|
||||
folderSection.style.display = checkbox.checked ? "flex" : "none";
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
// ── Row 3: help text ──
|
||||
const helpLabel = card.createEl("label", { cls: "vc-pbtn-folder-label", text: "Hilfetext (optional, erscheint im Chat wenn Button aktiv):" });
|
||||
const helpTextArea = card.createEl("textarea", {
|
||||
cls: "vc-pbtn-help-textarea",
|
||||
attr: { rows: "3", placeholder: "z.B. DRAFT — Frühphase…\nPRE-PUBLISH — Fast fertig…" },
|
||||
}) as HTMLTextAreaElement;
|
||||
helpTextArea.value = pb.helpText ?? "";
|
||||
helpTextArea.addEventListener("change", async () => {
|
||||
pb.helpText = helpTextArea.value.trim() || undefined;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Add row ──
|
||||
const addRow = btnListEl.createDiv("vc-pbtn-add-row");
|
||||
const newLabel = addRow.createEl("input", {
|
||||
cls: "vc-pbtn-input",
|
||||
attr: { type: "text", placeholder: "Label (z.B. Draft Check)" },
|
||||
}) as HTMLInputElement;
|
||||
const newPath = addRow.createEl("input", {
|
||||
cls: "vc-pbtn-input vc-pbtn-path",
|
||||
attr: { type: "text", placeholder: "Pfad/zur/Prompt-Notiz" },
|
||||
}) as HTMLInputElement;
|
||||
const addBtn = addRow.createEl("button", { cls: "vc-prop-add-btn", text: "+" });
|
||||
addBtn.onclick = async () => {
|
||||
const label = newLabel.value.trim();
|
||||
const filePath = newPath.value.trim();
|
||||
if (!label || !filePath) return;
|
||||
this.plugin.settings.promptButtons.push({ label, filePath });
|
||||
await this.plugin.saveSettings();
|
||||
renderBtnList();
|
||||
};
|
||||
};
|
||||
renderBtnList();
|
||||
|
||||
// --- Threads ---
|
||||
containerEl.createEl("h3", { text: "Thread-History" });
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ export class VaultSearch {
|
||||
private indexing = false;
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
|
||||
/** Frontmatter properties whose values are boosted during indexing */
|
||||
priorityProperties: string[] = ["collection", "related", "up", "tags"];
|
||||
private readonly propertyBoost = 5; // tokens from priority properties count 5x
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
@@ -79,6 +83,16 @@ export class VaultSearch {
|
||||
for (const t of tokens) {
|
||||
tf.set(t, (tf.get(t) ?? 0) + 1);
|
||||
}
|
||||
// Boost tokens from priority frontmatter properties
|
||||
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter ?? {};
|
||||
for (const prop of this.priorityProperties) {
|
||||
const val = fm[prop];
|
||||
if (!val) continue;
|
||||
const text = Array.isArray(val) ? val.join(" ") : String(val);
|
||||
for (const t of this.tokenize(text)) {
|
||||
tf.set(t, (tf.get(t) ?? 0) + this.propertyBoost);
|
||||
}
|
||||
}
|
||||
// Normalize TF
|
||||
const maxTf = Math.max(...tf.values(), 1);
|
||||
const normalizedTf: Map<string, number> = new Map();
|
||||
@@ -133,6 +147,40 @@ export class VaultSearch {
|
||||
return this.indexed;
|
||||
}
|
||||
|
||||
/** Find notes with similar names (no index required). Uses substring + word-overlap scoring. */
|
||||
findSimilarByName(query: string, topK = 2, minScore = 0.45): SearchResult[] {
|
||||
const normalize = (s: string) =>
|
||||
s.toLowerCase().replace(/[^\wäöüß\s]/gi, " ").trim();
|
||||
const words = (s: string) => new Set(s.split(/\s+/).filter((w) => w.length > 1));
|
||||
|
||||
const q = normalize(query);
|
||||
const qWords = words(q);
|
||||
|
||||
const scored: Array<[TFile, number]> = [];
|
||||
for (const file of this.app.vault.getMarkdownFiles()) {
|
||||
const name = normalize(file.basename);
|
||||
const nameWords = words(name);
|
||||
|
||||
let score = 0;
|
||||
// Substring containment
|
||||
if (name.includes(q) || q.includes(name)) score = 0.9;
|
||||
// Jaccard word overlap
|
||||
const intersection = [...qWords].filter((w) => nameWords.has(w)).length;
|
||||
const union = new Set([...qWords, ...nameWords]).size;
|
||||
if (union > 0) score = Math.max(score, intersection / union);
|
||||
|
||||
if (score >= minScore) scored.push([file, score]);
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b[1] - a[1]);
|
||||
return scored.slice(0, topK).map(([file, score]) => ({
|
||||
file,
|
||||
score,
|
||||
excerpt: "",
|
||||
title: file.basename,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Search for the top-K most similar notes to the query */
|
||||
async search(query: string, topK = 8): Promise<SearchResult[]> {
|
||||
if (!this.indexed) await this.buildIndex();
|
||||
|
||||
+12
-2
@@ -18,8 +18,16 @@ export default class MemexChatPlugin extends Plugin {
|
||||
async onload(): Promise<void> {
|
||||
// Load data
|
||||
const loaded = (await this.loadData()) as PluginData | null;
|
||||
const mergedSettings: MemexChatSettings = { ...DEFAULT_SETTINGS, ...(loaded?.settings ?? {}) };
|
||||
// Merge promptButtons per-entry so new fields (e.g. helpText) from defaults aren't lost
|
||||
if (loaded?.settings?.promptButtons) {
|
||||
mergedSettings.promptButtons = loaded.settings.promptButtons.map((saved, i) => ({
|
||||
...(DEFAULT_SETTINGS.promptButtons[i] ?? {}),
|
||||
...saved,
|
||||
}));
|
||||
}
|
||||
this.data = {
|
||||
settings: { ...DEFAULT_SETTINGS, ...(loaded?.settings ?? {}) },
|
||||
settings: mergedSettings,
|
||||
threads: loaded?.threads ?? [],
|
||||
};
|
||||
this.settings = this.data.settings;
|
||||
@@ -76,6 +84,7 @@ export default class MemexChatPlugin extends Plugin {
|
||||
// Build index once the workspace layout (and vault cache) is fully ready
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
if (!this.search.isIndexed()) {
|
||||
this.search.priorityProperties = this.settings.contextProperties;
|
||||
this.search.buildIndex().catch(console.error);
|
||||
}
|
||||
});
|
||||
@@ -94,7 +103,7 @@ export default class MemexChatPlugin extends Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaf = this.app.workspace.getRightLeaf(false);
|
||||
const leaf = this.app.workspace.getLeaf("tab");
|
||||
if (!leaf) return;
|
||||
await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true });
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
@@ -104,6 +113,7 @@ export default class MemexChatPlugin extends Plugin {
|
||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT);
|
||||
const view = leaves[0]?.view as ChatView | undefined;
|
||||
|
||||
this.search.priorityProperties = this.settings.contextProperties;
|
||||
this.search.onProgress = (done, total) => {
|
||||
if (view && done % 200 === 0) {
|
||||
// @ts-ignore
|
||||
|
||||
+305
@@ -24,6 +24,60 @@
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vc-header-modes {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vc-mode-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vc-mode-btn:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vc-mode-btn--active {
|
||||
background: var(--interactive-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.vc-mode-btn--active:hover {
|
||||
opacity: 0.9;
|
||||
color: var(--text-on-accent);
|
||||
background: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Mode hint panel */
|
||||
.vc-mode-hint {
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.vc-mode-hint-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.vc-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -119,6 +173,81 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Rename input */
|
||||
.vc-thread-rename {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--interactive-accent);
|
||||
border-radius: 3px;
|
||||
color: var(--text-normal);
|
||||
padding: 1px 4px;
|
||||
width: 0; /* flex will expand it */
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* History section */
|
||||
.vc-history-section {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.vc-history-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.vc-history-toggle:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vc-history-arrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vc-history-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vc-history-list {
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.vc-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vc-history-item:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vc-history-empty {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Chat area */
|
||||
.vc-chat-area {
|
||||
flex: 1;
|
||||
@@ -247,6 +376,7 @@
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.vc-md pre {
|
||||
position: relative;
|
||||
background: var(--background-primary-alt);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
@@ -255,6 +385,13 @@
|
||||
margin: 10px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hide Obsidian's injected copy-code button inside chat bubbles */
|
||||
.vc-md .copy-code-button,
|
||||
.vc-md pre > button,
|
||||
.vc-md .code-block-flair {
|
||||
display: none !important;
|
||||
}
|
||||
.vc-md pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
@@ -301,6 +438,26 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Unresolved link suggestion */
|
||||
.vc-link-hint {
|
||||
display: inline;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.vc-link-hint-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vc-link-hint-target {
|
||||
color: var(--text-accent);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vc-link-hint-target:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Streaming cursor */
|
||||
.vc-cursor {
|
||||
animation: blink 1s step-end infinite;
|
||||
@@ -534,3 +691,151 @@
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Priority properties tag editor (Settings) */
|
||||
.vc-prop-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vc-prop-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--background-modifier-hover);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.vc-prop-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 1px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.vc-prop-tag-remove:hover {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.vc-prop-add-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vc-prop-input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vc-prop-input:focus {
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.vc-prop-add-btn {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vc-prop-add-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Prompt button settings list */
|
||||
.vc-pbtn-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vc-pbtn-row,
|
||||
.vc-pbtn-add-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vc-pbtn-input {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
outline: none;
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vc-pbtn-input:focus {
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.vc-pbtn-path {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.vc-pbtn-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.vc-pbtn-row2 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vc-pbtn-toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vc-pbtn-folders {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vc-pbtn-folder-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user