From 5905c458fca79887cb307a64a44a04acb6f63e66 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:50:16 +0100 Subject: [PATCH] v0.2.0: History, rename threads, prompt buttons, Draft Check, @mention syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 44 ++++ main.js | 599 ++++++++++++++++++++++++++++++++++++++++++--- manifest.json | 2 +- package.json | 2 +- src/ChatView.ts | 374 ++++++++++++++++++++++++++-- src/SettingsTab.ts | 198 +++++++++++++++ src/VaultSearch.ts | 48 ++++ src/main.ts | 14 +- styles.css | 305 +++++++++++++++++++++++ 9 files changed, 1529 insertions(+), 57 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..553f784 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/main.js b/main.js index 2390e07..10f22b8 100644 --- a/main.js +++ b/main.js @@ -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 = ``; @@ -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,13 +154,14 @@ var ChatView = class extends import_obsidian.ItemView { return; } } - 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)) { - e.preventDefault(); - this.handleSend(); + if (e.key === "Enter") { + const isCmdEnter = e.metaKey || e.ctrlKey; + const sendOnEnter = this.plugin.settings.sendOnEnter; + if (isCmdEnter || sendOnEnter && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + this.handleSend(); + } } }); this.inputEl.addEventListener("input", () => this.handleInputChange()); @@ -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.plugin.settings.autoRetrieveContext && this.plugin.settings.showContextPreview) { - if (this.pendingContext.length === 0 && this.explicitContext.length === 0) { - await this.fetchAndShowContext(query, mentions); - return; + 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; + } + } + } 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); + 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}`); diff --git a/manifest.json b/manifest.json index 698805a..06e443f 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package.json b/package.json index 79594c7..f3dba93 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/ChatView.ts b/src/ChatView.ts index bf1b94b..ba58147 100644 --- a/src/ChatView.ts +++ b/src/ChatView.ts @@ -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 = 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,13 +196,14 @@ export class ChatView extends ItemView { return; } } - 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)) { - e.preventDefault(); - this.handleSend(); + if (e.key === "Enter") { + const isCmdEnter = e.metaKey || e.ctrlKey; + const sendOnEnter = this.plugin.settings.sendOnEnter; + if (isCmdEnter || (sendOnEnter && !e.shiftKey)) { + e.preventDefault(); + e.stopPropagation(); + this.handleSend(); + } } }); @@ -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 - 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 + // 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 = []; + } + + // 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); + await this.sendMessage(query, [...mentions, ...dateFiles]); } private async fetchAndShowContext(query: string, mentions: TFile[]): Promise { @@ -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 { 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 = { + 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 { + 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 { + 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 { + // 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); + }; + } + } } diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts index 90e0c09..8548b7b 100644 --- a/src/SettingsTab.ts +++ b/src/SettingsTab.ts @@ -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" }); diff --git a/src/VaultSearch.ts b/src/VaultSearch.ts index a014560..2c4e74b 100644 --- a/src/VaultSearch.ts +++ b/src/VaultSearch.ts @@ -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 = 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 { if (!this.indexed) await this.buildIndex(); diff --git a/src/main.ts b/src/main.ts index 769e587..a9c8a26 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,8 +18,16 @@ export default class MemexChatPlugin extends Plugin { async onload(): Promise { // 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 diff --git a/styles.css b/styles.css index 0c5264b..2da7537 100644 --- a/styles.css +++ b/styles.css @@ -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); +}