commit 415e2ebe5d428ed49758686d78f0cebd45ae4d91 Author: svemagie <869694+svemagie@users.noreply.github.com> Date: Wed Mar 4 20:48:26 2026 +0100 Initial commit: Memex Chat Obsidian plugin Chat with your Obsidian vault using Claude AI — semantic TF-IDF context retrieval, @ mentions, thread history, streaming responses. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c4042f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.js.map diff --git a/README.md b/README.md new file mode 100644 index 0000000..f90a58c --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Memex Chat — Obsidian Plugin + +Chat with your Obsidian vault using Claude AI. Semantic context retrieval, `@` mentions, thread history. + +## Features + +- **Semantic vault search** — TF-IDF index over all your notes, no external API needed for retrieval +- **Auto context** — relevant notes are automatically found and sent to Claude as context +- **Context preview** — see and edit which notes are included before sending +- **`[[Note]]` mentions** — reference specific notes directly in your message +- **Thread history** — chats saved as Markdown in your vault (default: `Calendar/Chat/`) +- **Streaming responses** — Claude's answer appears token by token +- **Source links** — every answer shows which notes were used + +## Installation + +1. Download `main.js`, `manifest.json`, `styles.css` +2. Copy into `.obsidian/plugins/memex-chat/` in your vault +3. Enable in **Settings → Community Plugins → Memex Chat** +4. Add your [Anthropic API Key](https://console.anthropic.com/) in plugin settings + +## Build from Source + +```bash +npm install +npm run build +``` + +Requires Node 18+. + +## Settings + +| Setting | Default | Description | +|---|---|---| +| API Key | — | Your Anthropic API key | +| Model | claude-sonnet-4-5 | Which Claude model to use | +| Max context notes | 6 | How many notes to retrieve per query | +| Max chars per note | 2500 | How much of each note to include | +| Auto retrieve context | on | Automatically find relevant notes | +| Context preview | on | Show context before sending | +| Save threads to vault | on | Persist chats as Markdown | +| Threads folder | `Calendar/Chat` | Where to save thread files | + +## Commands + +| Command | Description | +|---|---| +| `Memex Chat öffnen` | Open the chat panel | +| `Memex Chat: Index neu aufbauen` | Rebuild the TF-IDF search index | +| `Memex Chat: Aktive Notiz als Kontext` | Ask Claude about the currently open note | + +## License + +MIT diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..b149e80 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,39 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..67bda72 --- /dev/null +++ b/main.js @@ -0,0 +1,944 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/main.ts +var main_exports = {}; +__export(main_exports, { + default: () => VaultChatPlugin +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian3 = require("obsidian"); + +// src/ChatView.ts +var import_obsidian = require("obsidian"); +var VIEW_TYPE_VAULT_CHAT = "vault-chat-view"; +var ChatView = class extends import_obsidian.ItemView { + constructor(leaf, plugin) { + super(leaf); + this.threads = []; + this.activeThreadId = null; + this.pendingContext = []; + this.explicitContext = []; + this.isLoading = false; + this.plugin = plugin; + this.renderComponent = new import_obsidian.Component(); + } + getViewType() { + return VIEW_TYPE_VAULT_CHAT; + } + getDisplayText() { + return "Vault Chat"; + } + getIcon() { + return "message-circle"; + } + async onOpen() { + this.renderComponent.load(); + this.loadThreads(); + this.buildUI(); + if (!this.activeThreadId && this.threads.length === 0) { + this.newThread(); + } else if (!this.activeThreadId && this.threads.length > 0) { + this.switchThread(this.threads[0].id); + } + } + async onClose() { + this.renderComponent.unload(); + this.saveThreads(); + } + // ─── UI Construction ───────────────────────────────────────────────────── + buildUI() { + const root = this.containerEl.children[1]; + root.empty(); + root.addClass("vc-root"); + const header = root.createDiv("vc-header"); + header.createEl("span", { text: "Vault Chat", cls: "vc-header-title" }); + const headerActions = header.createDiv("vc-header-actions"); + const newThreadBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Neuer Thread" }); + newThreadBtn.innerHTML = ``; + newThreadBtn.onclick = () => this.newThread(); + const rebuildBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Index neu aufbauen" }); + rebuildBtn.innerHTML = ``; + rebuildBtn.onclick = async () => { + rebuildBtn.disabled = true; + this.setStatus("Indiziere Vault\u2026"); + await this.plugin.rebuildIndex(); + this.setStatus(`\u2713 ${this.plugin.search.isIndexed() ? "Index bereit" : ""}`); + setTimeout(() => this.setStatus(""), 2e3); + rebuildBtn.disabled = false; + }; + const main = root.createDiv("vc-main"); + const sidebar = main.createDiv("vc-sidebar"); + sidebar.createEl("div", { text: "Threads", cls: "vc-sidebar-title" }); + this.threadListEl = sidebar.createDiv("vc-thread-list"); + const chatArea = main.createDiv("vc-chat-area"); + this.statusEl = chatArea.createDiv("vc-status"); + this.messagesEl = chatArea.createDiv("vc-messages"); + this.contextPreviewEl = chatArea.createDiv("vc-context-preview"); + this.contextPreviewEl.style.display = "none"; + const inputArea = chatArea.createDiv("vc-input-area"); + const inputWrapper = inputArea.createDiv("vc-input-wrapper"); + this.inputEl = inputWrapper.createEl("textarea", { + cls: "vc-input", + attr: { placeholder: "Frage stellen\u2026 (@ f\xFCr Notiz einf\xFCgen)" } + }); + this.inputEl.rows = 3; + const inputActions = inputArea.createDiv("vc-input-actions"); + const contextBtn = inputActions.createEl("button", { cls: "vc-ctx-btn", title: "Kontext manuell ausw\xE4hlen" }); + contextBtn.innerHTML = ` Kontext`; + contextBtn.onclick = () => this.openContextPicker(); + this.sendBtn = inputActions.createEl("button", { cls: "vc-send-btn" }); + this.sendBtn.setText("Senden"); + this.sendBtn.onclick = () => this.handleSend(); + this.inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.handleSend(); + } + }); + this.inputEl.addEventListener("input", () => this.handleInputChange()); + this.renderThreadList(); + } + // ─── Thread Management ──────────────────────────────────────────────────── + newThread() { + const thread = { + id: Date.now().toString(), + title: "Neuer Chat", + messages: [], + created: Date.now(), + updated: Date.now() + }; + this.threads.unshift(thread); + this.switchThread(thread.id); + this.saveThreads(); + } + switchThread(id) { + this.saveThreads(); + this.activeThreadId = id; + this.renderThreadList(); + this.renderMessages(); + this.clearContextPreview(); + } + get activeThread() { + return this.threads.find((t) => t.id === this.activeThreadId); + } + deleteThread(id) { + this.threads = this.threads.filter((t) => t.id !== id); + if (this.activeThreadId === id) { + if (this.threads.length > 0) + this.switchThread(this.threads[0].id); + else + this.newThread(); + } + this.saveThreads(); + this.renderThreadList(); + } + // ─── Send & Context ────────────────────────────────────────────────────── + async handleSend() { + const query = this.inputEl.value.trim(); + if (!query || this.isLoading) + return; + if (!this.plugin.settings.apiKey) { + this.setStatus("\u26A0 Bitte API Key in den Einstellungen eingeben"); + return; + } + const mentionPattern = /\[\[([^\]]+)\]\]/g; + const mentions = []; + let match; + while ((match = mentionPattern.exec(query)) !== null) { + const name = match[1]; + 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; + } + } + await this.sendMessage(query, mentions); + } + async fetchAndShowContext(query, mentions) { + this.setStatus("Suche relevante Notizen\u2026"); + this.isLoading = true; + try { + if (!this.plugin.search.isIndexed()) { + this.setStatus("Indiziere Vault\u2026"); + await this.plugin.search.buildIndex(); + } + this.pendingContext = await this.plugin.search.search(query, this.plugin.settings.maxContextNotes); + this.explicitContext = mentions; + this.renderContextPreview(); + this.setStatus("Kontext bereit \u2014 Senden best\xE4tigen oder anpassen"); + } catch (e) { + this.setStatus("Fehler bei Kontextsuche: " + e.message); + } + this.isLoading = false; + } + async sendMessage(query, additionalFiles = []) { + this.isLoading = true; + this.sendBtn.disabled = true; + const thread = this.activeThread; + if (!thread) + return; + const contextFiles = [ + ...this.explicitContext, + ...this.pendingContext.map((r) => r.file), + ...additionalFiles + ].filter((f, i, arr) => arr.findIndex((x) => x.path === f.path) === i); + const contextNotes = contextFiles.map((f) => f.path); + let contextText = ""; + if (contextFiles.length > 0) { + this.setStatus(`Lade ${contextFiles.length} Notizen\u2026`); + const contents = await Promise.all( + contextFiles.map(async (f) => { + const content = await this.plugin.search.getContent(f, this.plugin.settings.maxCharsPerNote); + return `=== [[${f.basename}]] === +${content}`; + }) + ); + contextText = "\n\n---\nKontext aus dem Vault:\n\n" + contents.join("\n\n"); + } + const userMsg = { + role: "user", + content: query, + timestamp: Date.now(), + contextNotes + }; + thread.messages.push(userMsg); + thread.updated = Date.now(); + if (thread.messages.length === 1) { + thread.title = query.slice(0, 50) + (query.length > 50 ? "\u2026" : ""); + } + this.inputEl.value = ""; + this.pendingContext = []; + this.explicitContext = []; + this.clearContextPreview(); + this.renderMessages(); + this.renderThreadList(); + const assistantMsg = { + role: "assistant", + content: "", + timestamp: Date.now(), + isStreaming: true + }; + thread.messages.push(assistantMsg); + this.renderMessages(); + const claudeMessages = thread.messages.slice(0, -1).map((m) => ({ + role: m.role, + content: m.content + })); + claudeMessages.push({ + role: "user", + content: query + contextText + }); + this.setStatus("Claude denkt\u2026"); + try { + const stream = this.plugin.claude.streamChat(claudeMessages, { + apiKey: this.plugin.settings.apiKey, + model: this.plugin.settings.model, + systemPrompt: this.plugin.settings.systemPrompt + }); + for await (const chunk of stream) { + if (chunk.type === "text" && chunk.text) { + assistantMsg.content += chunk.text; + this.updateLastMessage(assistantMsg.content); + } else if (chunk.type === "error") { + assistantMsg.content = `\u274C Fehler: ${chunk.error}`; + this.updateLastMessage(assistantMsg.content); + break; + } + } + assistantMsg.isStreaming = false; + assistantMsg.contextNotes = contextNotes; + this.setStatus(""); + this.renderMessages(); + this.saveThreads(); + if (this.plugin.settings.saveThreadsToVault) { + await this.saveThreadToVault(thread); + } + } catch (e) { + assistantMsg.content = `\u274C Fehler: ${e.message}`; + assistantMsg.isStreaming = false; + this.renderMessages(); + this.setStatus(""); + } + this.isLoading = false; + this.sendBtn.disabled = false; + this.scrollToBottom(); + } + // ─── Context Preview ────────────────────────────────────────────────────── + renderContextPreview() { + this.contextPreviewEl.empty(); + this.contextPreviewEl.style.display = "block"; + const header = this.contextPreviewEl.createDiv("vc-ctx-header"); + header.createEl("span", { text: `\u{1F4CE} Kontext (${this.pendingContext.length} Notizen)`, cls: "vc-ctx-title" }); + const actions = header.createDiv("vc-ctx-actions"); + const confirmBtn = actions.createEl("button", { cls: "vc-send-btn vc-send-btn--sm", text: "\u2713 Senden" }); + confirmBtn.onclick = () => this.sendMessage(this.inputEl.value.trim()); + const clearBtn = actions.createEl("button", { cls: "vc-ctx-btn", text: "\u2717 Ohne Kontext" }); + clearBtn.onclick = () => { + this.pendingContext = []; + this.explicitContext = []; + this.clearContextPreview(); + this.sendMessage(this.inputEl.value.trim()); + }; + const list = this.contextPreviewEl.createDiv("vc-ctx-list"); + for (const result of this.pendingContext) { + const item = list.createDiv("vc-ctx-item"); + 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); + 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`; + removeBtn.onclick = () => { + this.pendingContext = this.pendingContext.filter((r) => r.file.path !== result.file.path); + this.renderContextPreview(); + }; + const excerpt = item.createDiv("vc-ctx-excerpt"); + excerpt.setText(result.excerpt.slice(0, 120) + "\u2026"); + } + } + clearContextPreview() { + this.contextPreviewEl.style.display = "none"; + this.contextPreviewEl.empty(); + this.pendingContext = []; + this.explicitContext = []; + this.setStatus(""); + } + async openContextPicker() { + const query = this.inputEl.value.trim() || "Notiz"; + this.setStatus("Suche Notizen\u2026"); + try { + if (!this.plugin.search.isIndexed()) + await this.plugin.search.buildIndex(); + const results = await this.plugin.search.search(query, this.plugin.settings.maxContextNotes); + this.pendingContext = results; + this.renderContextPreview(); + this.setStatus(""); + } catch (e) { + this.setStatus("Fehler: " + e.message); + } + } + // ─── Rendering ──────────────────────────────────────────────────────────── + renderThreadList() { + this.threadListEl.empty(); + for (const thread of this.threads) { + 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); + const del = item.createEl("button", { cls: "vc-icon-btn vc-thread-del", title: "L\xF6schen" }); + del.innerHTML = "\u2715"; + del.onclick = (e) => { + e.stopPropagation(); + this.deleteThread(thread.id); + }; + } + } + renderMessages() { + this.messagesEl.empty(); + const thread = this.activeThread; + if (!thread || thread.messages.length === 0) { + 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" }); + return; + } + for (const msg of thread.messages) { + this.renderMessage(msg); + } + this.scrollToBottom(); + } + renderMessage(msg) { + var _a; + const msgEl = this.messagesEl.createDiv(`vc-msg vc-msg--${msg.role}`); + const bubble = msgEl.createDiv("vc-bubble"); + if (msg.role === "user") { + bubble.setText(msg.content); + } else { + const mdEl = bubble.createDiv("vc-md"); + if (msg.isStreaming) { + mdEl.setText(msg.content); + mdEl.createEl("span", { cls: "vc-cursor", text: "\u2588" }); + } else { + import_obsidian.MarkdownRenderer.render(this.app, msg.content, mdEl, "", this.renderComponent); + } + } + if (!msg.isStreaming && msg.contextNotes && msg.contextNotes.length > 0) { + const sources = msgEl.createDiv("vc-sources"); + sources.createEl("span", { text: "Quellen: ", cls: "vc-sources-label" }); + for (const notePath of msg.contextNotes) { + 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); + } + } + } + updateLastMessage(content) { + const messages = this.messagesEl.querySelectorAll(".vc-msg--assistant"); + const last = messages[messages.length - 1]; + if (!last) + return; + const mdEl = last.querySelector(".vc-md"); + if (mdEl) { + mdEl.textContent = content; + const cursor = mdEl.querySelector(".vc-cursor"); + if (!cursor) + mdEl.createEl("span", { cls: "vc-cursor", text: "\u2588" }); + } + this.scrollToBottom(); + } + scrollToBottom() { + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + setStatus(text) { + this.statusEl.setText(text); + this.statusEl.style.display = text ? "block" : "none"; + } + handleInputChange() { + this.inputEl.style.height = "auto"; + this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 200) + "px"; + } + // ─── Persistence ───────────────────────────────────────────────────────── + loadThreads() { + var _a; + this.threads = (_a = this.plugin.data.threads) != null ? _a : []; + } + saveThreads() { + this.plugin.data.threads = this.threads; + this.plugin.saveData(this.plugin.data); + } + async saveThreadToVault(thread) { + try { + const folder = this.plugin.settings.threadsFolder; + await this.app.vault.createFolder(folder).catch(() => { + }); + const date = new Date(thread.created).toISOString().slice(0, 10); + const safeName = thread.title.replace(/[\\/:*?"<>|]/g, " ").slice(0, 60); + const fileName = `${folder}/${date} ${safeName}.md`; + let content = `--- +created: ${date} +tags: [chat] +--- + +# ${thread.title} + +`; + for (const msg of thread.messages) { + const role = msg.role === "user" ? "**Du**" : "**Claude**"; + content += `${role}: ${msg.content} + +`; + if (msg.contextNotes && msg.contextNotes.length > 0) { + const names = msg.contextNotes.map((p) => { + var _a, _b; + return `[[${(_b = (_a = p.split("/").pop()) == null ? void 0 : _a.replace(".md", "")) != null ? _b : p}]]`; + }); + content += `> Kontext: ${names.join(", ")} + +`; + } + } + const existing = this.app.vault.getAbstractFileByPath(fileName); + if (existing instanceof import_obsidian.TFile) { + await this.app.vault.modify(existing, content); + } else { + await this.app.vault.create(fileName, content); + } + } catch (e) { + } + } +}; + +// src/VaultSearch.ts +var VaultSearch = class { + constructor(app) { + this.docVectors = /* @__PURE__ */ new Map(); + // path -> term -> tfidf + this.idf = /* @__PURE__ */ new Map(); + this.docContents = /* @__PURE__ */ new Map(); + this.indexed = false; + this.indexing = false; + this.app = app; + } + /** Tokenize text: lowercase, split on non-word chars, keep umlauts */ + tokenize(text) { + return text.toLowerCase().replace(/[^\wäöüßÄÖÜ\s]/g, " ").split(/\s+/).filter((t) => t.length > 2); + } + /** Strip YAML frontmatter and Obsidian-specific markup */ + cleanContent(raw) { + let content = raw; + if (content.startsWith("---")) { + const end = content.indexOf("\n---", 3); + if (end > 0) + content = content.slice(end + 4); + } + content = content.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, target, alias) => alias || target); + content = content.replace(/!\[.*?\]\(.*?\)/g, ""); + content = content.replace(/\[([^\]]+)\]\(.*?\)/g, "$1"); + content = content.replace(/>\s*\[!\w+\][+-]?\s*/g, ""); + content = content.replace(/^#{1,6}\s+/gm, ""); + return content; + } + /** Build or rebuild the TF-IDF index */ + async buildIndex() { + var _a, _b, _c; + if (this.indexing) + return; + this.indexing = true; + this.indexed = false; + this.docVectors.clear(); + this.idf.clear(); + this.docContents.clear(); + const files = this.app.vault.getMarkdownFiles(); + const total = files.length; + const df = /* @__PURE__ */ new Map(); + const tfs = /* @__PURE__ */ new Map(); + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (this.onProgress && i % 100 === 0) + this.onProgress(i, total); + try { + const raw = await this.app.vault.cachedRead(file); + const clean = this.cleanContent(raw); + this.docContents.set(file.path, clean); + const tokens = this.tokenize(clean + " " + file.basename); + const tf = /* @__PURE__ */ new Map(); + for (const t of tokens) { + tf.set(t, ((_a = tf.get(t)) != null ? _a : 0) + 1); + } + const maxTf = Math.max(...tf.values(), 1); + const normalizedTf = /* @__PURE__ */ new Map(); + for (const [t, count] of tf) { + normalizedTf.set(t, count / maxTf); + } + tfs.set(file.path, normalizedTf); + for (const t of tf.keys()) { + df.set(t, ((_b = df.get(t)) != null ? _b : 0) + 1); + } + } catch (e) { + } + } + const N = files.length; + for (const [term, docCount] of df) { + this.idf.set(term, Math.log(N / docCount + 1)); + } + for (const [path, tf] of tfs) { + 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 tfidf = tfVal * idfVal; + vec.set(term, tfidf); + norm += tfidf * tfidf; + } + norm = Math.sqrt(norm); + if (norm > 0) { + for (const [term, val] of vec) { + vec.set(term, val / norm); + } + } + this.docVectors.set(path, vec); + } + this.indexed = true; + this.indexing = false; + if (this.onProgress) + this.onProgress(total, total); + } + isIndexed() { + return this.indexed; + } + /** Search for the top-K most similar notes to the query */ + async search(query, topK = 8) { + var _a, _b, _c; + if (!this.indexed) + await this.buildIndex(); + const tokens = this.tokenize(query); + const qtf = /* @__PURE__ */ new Map(); + for (const t of tokens) + qtf.set(t, ((_a = qtf.get(t)) != null ? _a : 0) + 1); + const qMax = Math.max(...qtf.values(), 1); + const qvec = /* @__PURE__ */ new Map(); + let qnorm = 0; + for (const [t, count] of qtf) { + const tfidf = count / qMax * ((_b = this.idf.get(t)) != null ? _b : 0); + qvec.set(t, tfidf); + qnorm += tfidf * tfidf; + } + qnorm = Math.sqrt(qnorm); + if (qnorm > 0) + for (const [t, v] of qvec) + qvec.set(t, v / qnorm); + const scores = []; + for (const [path, vec] of this.docVectors) { + let score = 0; + for (const [t, qv] of qvec) { + const dv = (_c = vec.get(t)) != null ? _c : 0; + score += qv * dv; + } + if (score > 0.01) + scores.push([path, score]); + } + scores.sort((a, b) => b[1] - a[1]); + const top = scores.slice(0, topK); + const files = this.app.vault.getMarkdownFiles(); + const fileMap = new Map(files.map((f) => [f.path, f])); + return top.map(([path, score]) => { + var _a2; + const file = fileMap.get(path); + if (!file) + return null; + const content = (_a2 = this.docContents.get(path)) != null ? _a2 : ""; + const excerpt = this.buildExcerpt(content, query, 300); + return { file, score, excerpt, title: file.basename }; + }).filter(Boolean); + } + /** Get note content for context injection */ + async getContent(file, maxChars = 3e3) { + try { + const raw = await this.app.vault.cachedRead(file); + return this.cleanContent(raw).slice(0, maxChars); + } catch (e) { + return ""; + } + } + buildExcerpt(content, query, maxLen) { + const queryWords = query.toLowerCase().split(/\s+/); + const lower = content.toLowerCase(); + let bestPos = 0; + let bestScore = 0; + for (let i = 0; i < content.length - maxLen; i += 50) { + const window = lower.slice(i, i + maxLen); + const score = queryWords.filter((w) => window.includes(w)).length; + if (score > bestScore) { + bestScore = score; + bestPos = i; + } + } + let excerpt = content.slice(bestPos, bestPos + maxLen).trim(); + if (bestPos > 0) + excerpt = "\u2026" + excerpt; + if (bestPos + maxLen < content.length) + excerpt += "\u2026"; + return excerpt; + } +}; + +// src/ClaudeClient.ts +var ClaudeClient = class { + constructor() { + this.baseUrl = "https://api.anthropic.com/v1/messages"; + } + /** Stream a chat completion, yielding text chunks */ + async *streamChat(messages, options) { + var _a, _b, _c, _d, _e, _f; + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": options.apiKey, + "anthropic-version": "2023-06-01", + "anthropic-beta": "messages-2023-12-15" + }, + body: JSON.stringify({ + model: options.model, + max_tokens: (_a = options.maxTokens) != null ? _a : 2048, + stream: true, + system: options.systemPrompt, + messages + }) + }); + if (!response.ok) { + const err = await response.text(); + yield { type: "error", error: `API Error ${response.status}: ${err}` }; + return; + } + const reader = (_b = response.body) == null ? void 0 : _b.getReader(); + if (!reader) { + yield { type: "error", error: "No response body" }; + return; + } + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = (_c = lines.pop()) != null ? _c : ""; + for (const line of lines) { + if (!line.startsWith("data: ")) + continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") { + yield { type: "done" }; + return; + } + try { + const json = JSON.parse(data); + if (json.type === "content_block_delta" && ((_d = json.delta) == null ? void 0 : _d.type) === "text_delta") { + yield { type: "text", text: json.delta.text }; + } else if (json.type === "message_stop") { + yield { type: "done" }; + return; + } else if (json.type === "error") { + yield { type: "error", error: (_f = (_e = json.error) == null ? void 0 : _e.message) != null ? _f : "Unknown error" }; + return; + } + } catch (e) { + } + } + } + yield { type: "done" }; + } + /** Non-streaming version for simpler use cases */ + async chat(messages, options) { + var _a, _b, _c, _d; + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": options.apiKey, + "anthropic-version": "2023-06-01" + }, + body: JSON.stringify({ + model: options.model, + max_tokens: (_a = options.maxTokens) != null ? _a : 2048, + system: options.systemPrompt, + messages + }) + }); + if (!response.ok) { + const err = await response.text(); + throw new Error(`API Error ${response.status}: ${err}`); + } + const data = await response.json(); + return (_d = (_c = (_b = data.content) == null ? void 0 : _b[0]) == null ? void 0 : _c.text) != null ? _d : ""; + } +}; + +// src/SettingsTab.ts +var import_obsidian2 = require("obsidian"); +var DEFAULT_SETTINGS = { + apiKey: "", + model: "claude-opus-4-5-20251101", + maxContextNotes: 6, + maxCharsPerNote: 2500, + systemPrompt: `Du bist ein hilfreicher Assistent mit Zugriff auf die pers\xF6nliche Wissensdatenbank des Nutzers (Obsidian Vault). + +Wenn du Fragen beantwortest: +- Nutze die bereitgestellten Notizen als prim\xE4re Wissensquelle +- Verweise auf relevante Notizen mit [[doppelten eckigen Klammern]] +- Antworte auf Deutsch, wenn die Frage auf Deutsch gestellt wird +- Wenn der Kontext unzureichend ist, sage das ehrlich und gib an, was noch fehlen k\xF6nnte +- Verkn\xFCpfe Konzepte aus verschiedenen Notizen kreativ miteinander`, + autoRetrieveContext: true, + showContextPreview: true, + saveThreadsToVault: true, + threadsFolder: "Calendar/Chat" +}; +var MODELS = [ + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (St\xE4rkst)" }, + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5 (Empfohlen)" }, + { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (Schnell)" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" } +]; +var VaultChatSettingsTab = class extends import_obsidian2.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "Vault Chat Einstellungen" }); + containerEl.createEl("h3", { text: "Claude API" }); + new import_obsidian2.Setting(containerEl).setName("API Key").setDesc("Dein Anthropic API Key (sk-ant-...)").addText( + (text) => text.setPlaceholder("sk-ant-api03-...").setValue(this.plugin.settings.apiKey).onChange(async (value) => { + this.plugin.settings.apiKey = value.trim(); + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("Modell").setDesc("Welches Claude-Modell verwenden?").addDropdown((drop) => { + for (const m of MODELS) + drop.addOption(m.id, m.name); + drop.setValue(this.plugin.settings.model).onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + }); + }); + containerEl.createEl("h3", { text: "Kontext-Einstellungen" }); + new import_obsidian2.Setting(containerEl).setName("Max. Kontext-Notizen").setDesc("Wie viele Notizen werden automatisch als Kontext hinzugef\xFCgt? (1\u201315)").addSlider( + (slider) => slider.setLimits(1, 15, 1).setValue(this.plugin.settings.maxContextNotes).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.maxContextNotes = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("Max. Zeichen pro Notiz").setDesc("Wie viele Zeichen einer Notiz in den Kontext einbezogen werden (1000\u20138000)").addSlider( + (slider) => slider.setLimits(1e3, 8e3, 500).setValue(this.plugin.settings.maxCharsPerNote).setDynamicTooltip().onChange(async (value) => { + this.plugin.settings.maxCharsPerNote = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("Automatischer Kontext-Abruf").setDesc("Beim Senden automatisch relevante Notizen suchen und einbinden").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.autoRetrieveContext).onChange(async (value) => { + this.plugin.settings.autoRetrieveContext = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("Kontext-Vorschau anzeigen").setDesc("Vor dem Senden zeigen, welche Notizen als Kontext verwendet werden").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.showContextPreview).onChange(async (value) => { + this.plugin.settings.showContextPreview = value; + await this.plugin.saveSettings(); + }) + ); + containerEl.createEl("h3", { text: "Thread-History" }); + new import_obsidian2.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) => { + this.plugin.settings.saveThreadsToVault = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("Threads-Ordner").setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden").addText( + (text) => text.setPlaceholder("Calendar/Chat").setValue(this.plugin.settings.threadsFolder).onChange(async (value) => { + this.plugin.settings.threadsFolder = value; + await this.plugin.saveSettings(); + }) + ); + containerEl.createEl("h3", { text: "System Prompt" }); + new import_obsidian2.Setting(containerEl).setName("System Prompt").setDesc("Instruktionen f\xFCr Claude (wie soll er sich verhalten?)").addTextArea((textarea) => { + textarea.setValue(this.plugin.settings.systemPrompt).onChange(async (value) => { + this.plugin.settings.systemPrompt = value; + await this.plugin.saveSettings(); + }); + textarea.inputEl.rows = 8; + textarea.inputEl.style.width = "100%"; + textarea.inputEl.style.fontFamily = "monospace"; + textarea.inputEl.style.fontSize = "12px"; + }); + containerEl.createEl("h3", { text: "Aktionen" }); + new import_obsidian2.Setting(containerEl).setName("Index neu aufbauen").setDesc("Vault-Index f\xFCr die Suche neu aufbauen (dauert je nach Vault-Gr\xF6\xDFe einige Sekunden)").addButton( + (btn) => btn.setButtonText("Index neu aufbauen").setCta().onClick(async () => { + btn.setButtonText("Indiziere\u2026"); + btn.setDisabled(true); + await this.plugin.rebuildIndex(); + btn.setButtonText("\u2713 Fertig!"); + setTimeout(() => { + btn.setButtonText("Index neu aufbauen"); + btn.setDisabled(false); + }, 2e3); + }) + ); + } +}; + +// src/main.ts +var VaultChatPlugin = class extends import_obsidian3.Plugin { + async onload() { + var _a, _b; + const loaded = await this.loadData(); + this.data = { + settings: { ...DEFAULT_SETTINGS, ...(_a = loaded == null ? void 0 : loaded.settings) != null ? _a : {} }, + threads: (_b = loaded == null ? void 0 : loaded.threads) != null ? _b : [] + }; + this.settings = this.data.settings; + this.search = new VaultSearch(this.app); + this.claude = new ClaudeClient(); + this.registerView(VIEW_TYPE_VAULT_CHAT, (leaf) => new ChatView(leaf, this)); + this.addRibbonIcon("message-circle", "Vault Chat \xF6ffnen", () => { + this.activateView(); + }); + this.addCommand({ + id: "open-vault-chat", + name: "Vault Chat \xF6ffnen", + callback: () => this.activateView() + }); + this.addCommand({ + id: "vault-chat-rebuild-index", + name: "Vault Chat: Index neu aufbauen", + callback: () => this.rebuildIndex() + }); + this.addCommand({ + id: "vault-chat-active-note", + name: "Vault Chat: Aktive Notiz als Kontext", + callback: () => { + const file = this.app.workspace.getActiveFile(); + if (file) { + this.activateView().then(() => { + const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_VAULT_CHAT)[0]; + if (leaf) { + const view = leaf.view; + view.inputEl.value = `Erkl\xE4re und verkn\xFCpfe [[${file.basename}]] mit anderen Konzepten im Vault.`; + view.explicitContext = [file]; + } + }); + } + } + }); + this.addSettingTab(new VaultChatSettingsTab(this.app, this)); + setTimeout(() => { + if (!this.search.isIndexed()) { + this.search.buildIndex().catch(console.error); + } + }, 3e3); + console.log("[Vault Chat] Plugin geladen"); + } + onunload() { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_VAULT_CHAT); + } + async activateView() { + const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_VAULT_CHAT); + if (existing.length > 0) { + this.app.workspace.revealLeaf(existing[0]); + return; + } + const leaf = this.app.workspace.getRightLeaf(false); + if (!leaf) + return; + await leaf.setViewState({ type: VIEW_TYPE_VAULT_CHAT, active: true }); + this.app.workspace.revealLeaf(leaf); + } + async rebuildIndex() { + var _a; + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_VAULT_CHAT); + const view = (_a = leaves[0]) == null ? void 0 : _a.view; + this.search.onProgress = (done, total) => { + if (view && done % 200 === 0) { + view.setStatus(`Indiziere\u2026 ${done}/${total}`); + } + }; + await this.search.buildIndex(); + this.search.onProgress = void 0; + if (view) { + view.setStatus(`\u2713 ${this.app.vault.getMarkdownFiles().length} Notizen indiziert`); + setTimeout(() => { + view.setStatus(""); + }, 3e3); + } + } + async saveSettings() { + this.data.settings = this.settings; + await this.saveData(this.data); + } +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..698805a --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "memex-chat", + "name": "Memex Chat", + "version": "1.0.0", + "minAppVersion": "1.4.0", + "description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.", + "author": "Sven", + "authorUrl": "", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..361c51f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,602 @@ +{ + "name": "memex-chat", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "memex-chat", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^20.0.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.20.0", + "obsidian": "latest", + "typescript": "^5.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..79594c7 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "memex-chat", + "version": "1.0.0", + "description": "Obsidian plugin: Chat with your vault using Claude AI", + "main": "main.js", + "scripts": { + "build": "node esbuild.config.mjs production", + "dev": "node esbuild.config.mjs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.20.0", + "obsidian": "latest", + "typescript": "^5.0.0" + } +} diff --git a/src/ChatView.ts b/src/ChatView.ts new file mode 100644 index 0000000..61d0417 --- /dev/null +++ b/src/ChatView.ts @@ -0,0 +1,557 @@ +import { ItemView, WorkspaceLeaf, TFile, MarkdownRenderer, Component } from "obsidian"; +import type MemexChatPlugin from "./main"; +import { SearchResult } from "./VaultSearch"; +import { ClaudeMessage } from "./ClaudeClient"; + +export const VIEW_TYPE_MEMEX_CHAT = "memex-chat-view"; + +interface ChatMessage { + role: "user" | "assistant"; + content: string; + timestamp: number; + contextNotes?: string[]; // paths of notes used + isStreaming?: boolean; +} + +interface Thread { + id: string; + title: string; + messages: ChatMessage[]; + created: number; + updated: number; +} + +export class ChatView extends ItemView { + plugin: MemexChatPlugin; + private threads: Thread[] = []; + private activeThreadId: string | null = null; + private pendingContext: SearchResult[] = []; + private explicitContext: TFile[] = []; + private isLoading = false; + private renderComponent: Component; + + // DOM refs + private threadListEl!: HTMLElement; + private messagesEl!: HTMLElement; + private inputEl!: HTMLTextAreaElement; + private contextPreviewEl!: HTMLElement; + private sendBtn!: HTMLButtonElement; + private statusEl!: HTMLElement; + + constructor(leaf: WorkspaceLeaf, plugin: MemexChatPlugin) { + super(leaf); + this.plugin = plugin; + this.renderComponent = new Component(); + } + + getViewType(): string { + return VIEW_TYPE_MEMEX_CHAT; + } + + getDisplayText(): string { + return "Memex Chat"; + } + + getIcon(): string { + return "message-circle"; + } + + async onOpen(): Promise { + this.renderComponent.load(); + this.loadThreads(); + this.buildUI(); + if (!this.activeThreadId && this.threads.length === 0) { + this.newThread(); + } else if (!this.activeThreadId && this.threads.length > 0) { + this.switchThread(this.threads[0].id); + } + } + + async onClose(): Promise { + this.renderComponent.unload(); + this.saveThreads(); + } + + // ─── UI Construction ───────────────────────────────────────────────────── + + private buildUI(): void { + const root = this.containerEl.children[1] as HTMLElement; + root.empty(); + root.addClass("vc-root"); + + // Header + const header = root.createDiv("vc-header"); + header.createEl("span", { text: "Memex Chat", cls: "vc-header-title" }); + const headerActions = header.createDiv("vc-header-actions"); + + const newThreadBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Neuer Thread" }); + newThreadBtn.innerHTML = ``; + newThreadBtn.onclick = () => this.newThread(); + + const rebuildBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Index neu aufbauen" }); + rebuildBtn.innerHTML = ``; + rebuildBtn.onclick = async () => { + rebuildBtn.disabled = true; + this.setStatus("Indiziere Vault…"); + await this.plugin.rebuildIndex(); + this.setStatus(`✓ ${this.plugin.search.isIndexed() ? "Index bereit" : ""}`); + setTimeout(() => this.setStatus(""), 2000); + rebuildBtn.disabled = false; + }; + + const main = root.createDiv("vc-main"); + + // Thread sidebar + const sidebar = main.createDiv("vc-sidebar"); + sidebar.createEl("div", { text: "Threads", cls: "vc-sidebar-title" }); + this.threadListEl = sidebar.createDiv("vc-thread-list"); + + // Chat area + const chatArea = main.createDiv("vc-chat-area"); + + // Status bar + this.statusEl = chatArea.createDiv("vc-status"); + + // Messages + this.messagesEl = chatArea.createDiv("vc-messages"); + + // Context preview + this.contextPreviewEl = chatArea.createDiv("vc-context-preview"); + this.contextPreviewEl.style.display = "none"; + + // Input area + const inputArea = chatArea.createDiv("vc-input-area"); + + const inputWrapper = inputArea.createDiv("vc-input-wrapper"); + this.inputEl = inputWrapper.createEl("textarea", { + cls: "vc-input", + attr: { placeholder: "Frage stellen… (@ für Notiz einfügen)" }, + }); + this.inputEl.rows = 3; + + const inputActions = inputArea.createDiv("vc-input-actions"); + + const contextBtn = inputActions.createEl("button", { cls: "vc-ctx-btn", title: "Kontext manuell auswählen" }); + contextBtn.innerHTML = ` Kontext`; + contextBtn.onclick = () => this.openContextPicker(); + + this.sendBtn = inputActions.createEl("button", { cls: "vc-send-btn" }); + this.sendBtn.setText("Senden"); + this.sendBtn.onclick = () => this.handleSend(); + + // Key bindings + this.inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.handleSend(); + } + }); + + this.inputEl.addEventListener("input", () => this.handleInputChange()); + + this.renderThreadList(); + } + + // ─── Thread Management ──────────────────────────────────────────────────── + + private newThread(): void { + const thread: Thread = { + id: Date.now().toString(), + title: "Neuer Chat", + messages: [], + created: Date.now(), + updated: Date.now(), + }; + this.threads.unshift(thread); + this.switchThread(thread.id); + this.saveThreads(); + } + + private switchThread(id: string): void { + this.saveThreads(); + this.activeThreadId = id; + this.renderThreadList(); + this.renderMessages(); + this.clearContextPreview(); + } + + private get activeThread(): Thread | undefined { + return this.threads.find((t) => t.id === this.activeThreadId); + } + + private deleteThread(id: string): void { + this.threads = this.threads.filter((t) => t.id !== id); + if (this.activeThreadId === id) { + if (this.threads.length > 0) this.switchThread(this.threads[0].id); + else this.newThread(); + } + this.saveThreads(); + this.renderThreadList(); + } + + // ─── Send & Context ────────────────────────────────────────────────────── + + private async handleSend(): Promise { + const query = this.inputEl.value.trim(); + if (!query || this.isLoading) return; + + if (!this.plugin.settings.apiKey) { + this.setStatus("⚠ Bitte API Key in den Einstellungen eingeben"); + return; + } + + // Parse @[[mentions]] from input + const mentionPattern = /\[\[([^\]]+)\]\]/g; + const mentions: TFile[] = []; + let match; + while ((match = mentionPattern.exec(query)) !== null) { + const name = match[1]; + 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 + } + } + + await this.sendMessage(query, mentions); + } + + private async fetchAndShowContext(query: string, mentions: TFile[]): Promise { + this.setStatus("Suche relevante Notizen…"); + this.isLoading = true; + try { + if (!this.plugin.search.isIndexed()) { + this.setStatus("Indiziere Vault…"); + await this.plugin.search.buildIndex(); + } + this.pendingContext = await this.plugin.search.search(query, this.plugin.settings.maxContextNotes); + this.explicitContext = mentions; + this.renderContextPreview(); + this.setStatus("Kontext bereit — Senden bestätigen oder anpassen"); + } catch (e) { + this.setStatus("Fehler bei Kontextsuche: " + e.message); + } + this.isLoading = false; + } + + private async sendMessage(query: string, additionalFiles: TFile[] = []): Promise { + this.isLoading = true; + this.sendBtn.disabled = true; + + const thread = this.activeThread; + if (!thread) return; + + // Build context + const contextFiles: TFile[] = [ + ...this.explicitContext, + ...this.pendingContext.map((r) => r.file), + ...additionalFiles, + ].filter((f, i, arr) => arr.findIndex((x) => x.path === f.path) === i); + + const contextNotes = contextFiles.map((f) => f.path); + + // Build context text + let contextText = ""; + if (contextFiles.length > 0) { + this.setStatus(`Lade ${contextFiles.length} Notizen…`); + const contents = await Promise.all( + contextFiles.map(async (f) => { + const content = await this.plugin.search.getContent(f, this.plugin.settings.maxCharsPerNote); + return `=== [[${f.basename}]] ===\n${content}`; + }) + ); + contextText = "\n\n---\nKontext aus dem Vault:\n\n" + contents.join("\n\n"); + } + + // Add user message + const userMsg: ChatMessage = { + role: "user", + content: query, + timestamp: Date.now(), + contextNotes, + }; + thread.messages.push(userMsg); + thread.updated = Date.now(); + if (thread.messages.length === 1) { + thread.title = query.slice(0, 50) + (query.length > 50 ? "…" : ""); + } + + // Clear input and context + this.inputEl.value = ""; + this.pendingContext = []; + this.explicitContext = []; + this.clearContextPreview(); + this.renderMessages(); + this.renderThreadList(); + + // Add streaming assistant message + const assistantMsg: ChatMessage = { + role: "assistant", + content: "", + timestamp: Date.now(), + isStreaming: true, + }; + thread.messages.push(assistantMsg); + this.renderMessages(); + + // Build Claude messages (history) + const claudeMessages: ClaudeMessage[] = thread.messages + .slice(0, -1) // exclude the empty assistant msg we just added + .map((m) => ({ + role: m.role, + content: m.content, + })); + + // Add user message with context + claudeMessages.push({ + role: "user", + content: query + contextText, + }); + + this.setStatus("Claude denkt…"); + + try { + const stream = this.plugin.claude.streamChat(claudeMessages, { + apiKey: this.plugin.settings.apiKey, + model: this.plugin.settings.model, + systemPrompt: this.plugin.settings.systemPrompt, + }); + + for await (const chunk of stream) { + if (chunk.type === "text" && chunk.text) { + assistantMsg.content += chunk.text; + this.updateLastMessage(assistantMsg.content); + } else if (chunk.type === "error") { + assistantMsg.content = `❌ Fehler: ${chunk.error}`; + this.updateLastMessage(assistantMsg.content); + break; + } + } + + assistantMsg.isStreaming = false; + assistantMsg.contextNotes = contextNotes; + this.setStatus(""); + this.renderMessages(); // final render with sources + this.saveThreads(); + if (this.plugin.settings.saveThreadsToVault) { + await this.saveThreadToVault(thread); + } + } catch (e) { + assistantMsg.content = `❌ Fehler: ${e.message}`; + assistantMsg.isStreaming = false; + this.renderMessages(); + this.setStatus(""); + } + + this.isLoading = false; + this.sendBtn.disabled = false; + this.scrollToBottom(); + } + + // ─── Context Preview ────────────────────────────────────────────────────── + + private renderContextPreview(): void { + this.contextPreviewEl.empty(); + this.contextPreviewEl.style.display = "block"; + + const header = this.contextPreviewEl.createDiv("vc-ctx-header"); + header.createEl("span", { text: `📎 Kontext (${this.pendingContext.length} Notizen)`, cls: "vc-ctx-title" }); + + const actions = header.createDiv("vc-ctx-actions"); + const confirmBtn = actions.createEl("button", { cls: "vc-send-btn vc-send-btn--sm", text: "✓ Senden" }); + confirmBtn.onclick = () => this.sendMessage(this.inputEl.value.trim()); + + const clearBtn = actions.createEl("button", { cls: "vc-ctx-btn", text: "✗ Ohne Kontext" }); + clearBtn.onclick = () => { + this.pendingContext = []; + this.explicitContext = []; + this.clearContextPreview(); + this.sendMessage(this.inputEl.value.trim()); + }; + + const list = this.contextPreviewEl.createDiv("vc-ctx-list"); + for (const result of this.pendingContext) { + const item = list.createDiv("vc-ctx-item"); + 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); + + 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 = `✕`; + removeBtn.onclick = () => { + this.pendingContext = this.pendingContext.filter((r) => r.file.path !== result.file.path); + this.renderContextPreview(); + }; + + const excerpt = item.createDiv("vc-ctx-excerpt"); + excerpt.setText(result.excerpt.slice(0, 120) + "…"); + } + } + + private clearContextPreview(): void { + this.contextPreviewEl.style.display = "none"; + this.contextPreviewEl.empty(); + this.pendingContext = []; + this.explicitContext = []; + this.setStatus(""); + } + + private async openContextPicker(): Promise { + // Simple quick switcher-style: search and add to explicit context + const query = this.inputEl.value.trim() || "Notiz"; + this.setStatus("Suche Notizen…"); + try { + if (!this.plugin.search.isIndexed()) await this.plugin.search.buildIndex(); + const results = await this.plugin.search.search(query, this.plugin.settings.maxContextNotes); + this.pendingContext = results; + this.renderContextPreview(); + this.setStatus(""); + } catch (e) { + this.setStatus("Fehler: " + e.message); + } + } + + // ─── Rendering ──────────────────────────────────────────────────────────── + + private renderThreadList(): void { + this.threadListEl.empty(); + for (const thread of this.threads) { + 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); + + const del = item.createEl("button", { cls: "vc-icon-btn vc-thread-del", title: "Löschen" }); + del.innerHTML = "✕"; + del.onclick = (e) => { + e.stopPropagation(); + this.deleteThread(thread.id); + }; + } + } + + private renderMessages(): void { + this.messagesEl.empty(); + const thread = this.activeThread; + if (!thread || thread.messages.length === 0) { + 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" }); + return; + } + + for (const msg of thread.messages) { + this.renderMessage(msg); + } + this.scrollToBottom(); + } + + private renderMessage(msg: ChatMessage): void { + const msgEl = this.messagesEl.createDiv(`vc-msg vc-msg--${msg.role}`); + + const bubble = msgEl.createDiv("vc-bubble"); + + if (msg.role === "user") { + bubble.setText(msg.content); + } else { + // Render markdown for assistant + const mdEl = bubble.createDiv("vc-md"); + if (msg.isStreaming) { + mdEl.setText(msg.content); + mdEl.createEl("span", { cls: "vc-cursor", text: "█" }); + } else { + MarkdownRenderer.render(this.app, msg.content, mdEl, "", this.renderComponent); + } + } + + // Show context sources + if (!msg.isStreaming && msg.contextNotes && msg.contextNotes.length > 0) { + const sources = msgEl.createDiv("vc-sources"); + sources.createEl("span", { text: "Quellen: ", cls: "vc-sources-label" }); + for (const notePath of msg.contextNotes) { + 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); + } + } + } + + private updateLastMessage(content: string): void { + const messages = this.messagesEl.querySelectorAll(".vc-msg--assistant"); + const last = messages[messages.length - 1]; + if (!last) return; + const mdEl = last.querySelector(".vc-md"); + if (mdEl) { + mdEl.textContent = content; + const cursor = mdEl.querySelector(".vc-cursor"); + if (!cursor) mdEl.createEl("span", { cls: "vc-cursor", text: "█" }); + } + this.scrollToBottom(); + } + + private scrollToBottom(): void { + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + + private setStatus(text: string): void { + this.statusEl.setText(text); + this.statusEl.style.display = text ? "block" : "none"; + } + + private handleInputChange(): void { + // Auto-resize textarea + this.inputEl.style.height = "auto"; + this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 200) + "px"; + } + + // ─── Persistence ───────────────────────────────────────────────────────── + + private loadThreads(): void { + this.threads = (this.plugin.data.threads ?? []) as Thread[]; + } + + saveThreads(): void { + this.plugin.data.threads = this.threads; + this.plugin.saveData(this.plugin.data); + } + + private async saveThreadToVault(thread: Thread): Promise { + try { + const folder = this.plugin.settings.threadsFolder; + await this.app.vault.createFolder(folder).catch(() => {}); + + const date = new Date(thread.created).toISOString().slice(0, 10); + 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`; + for (const msg of thread.messages) { + const role = msg.role === "user" ? "**Du**" : "**Claude**"; + content += `${role}: ${msg.content}\n\n`; + if (msg.contextNotes && msg.contextNotes.length > 0) { + const names = msg.contextNotes.map((p) => `[[${p.split("/").pop()?.replace(".md", "") ?? p}]]`); + content += `> Kontext: ${names.join(", ")}\n\n`; + } + } + + const existing = this.app.vault.getAbstractFileByPath(fileName); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + } else { + await this.app.vault.create(fileName, content); + } + } catch { + // silent fail + } + } +} diff --git a/src/ClaudeClient.ts b/src/ClaudeClient.ts new file mode 100644 index 0000000..daa46aa --- /dev/null +++ b/src/ClaudeClient.ts @@ -0,0 +1,118 @@ +export interface ClaudeMessage { + role: "user" | "assistant"; + content: string; +} + +export interface ClaudeOptions { + apiKey: string; + model: string; + maxTokens?: number; + systemPrompt: string; +} + +export interface ClaudeStreamChunk { + type: "text" | "done" | "error"; + text?: string; + error?: string; +} + +/** Minimal Claude API client */ +export class ClaudeClient { + private baseUrl = "https://api.anthropic.com/v1/messages"; + + /** Stream a chat completion, yielding text chunks */ + async *streamChat( + messages: ClaudeMessage[], + options: ClaudeOptions + ): AsyncGenerator { + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": options.apiKey, + "anthropic-version": "2023-06-01", + "anthropic-beta": "messages-2023-12-15", + }, + body: JSON.stringify({ + model: options.model, + max_tokens: options.maxTokens ?? 2048, + stream: true, + system: options.systemPrompt, + messages, + }), + }); + + if (!response.ok) { + const err = await response.text(); + yield { type: "error", error: `API Error ${response.status}: ${err}` }; + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + yield { type: "error", error: "No response body" }; + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") { + yield { type: "done" }; + return; + } + try { + const json = JSON.parse(data); + if (json.type === "content_block_delta" && json.delta?.type === "text_delta") { + yield { type: "text", text: json.delta.text }; + } else if (json.type === "message_stop") { + yield { type: "done" }; + return; + } else if (json.type === "error") { + yield { type: "error", error: json.error?.message ?? "Unknown error" }; + return; + } + } catch { + // skip malformed lines + } + } + } + yield { type: "done" }; + } + + /** Non-streaming version for simpler use cases */ + async chat(messages: ClaudeMessage[], options: ClaudeOptions): Promise { + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": options.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: options.model, + max_tokens: options.maxTokens ?? 2048, + system: options.systemPrompt, + messages, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`API Error ${response.status}: ${err}`); + } + + const data = await response.json(); + return data.content?.[0]?.text ?? ""; + } +} diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts new file mode 100644 index 0000000..2da5e60 --- /dev/null +++ b/src/SettingsTab.ts @@ -0,0 +1,201 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import type MemexChatPlugin from "./main"; + +export interface MemexChatSettings { + apiKey: string; + model: string; + maxContextNotes: number; + maxCharsPerNote: number; + systemPrompt: string; + autoRetrieveContext: boolean; + showContextPreview: boolean; + saveThreadsToVault: boolean; + threadsFolder: string; +} + +export const DEFAULT_SETTINGS: MemexChatSettings = { + apiKey: "", + model: "claude-opus-4-5-20251101", + maxContextNotes: 6, + maxCharsPerNote: 2500, + systemPrompt: `Du bist ein hilfreicher Assistent mit Zugriff auf die persönliche Wissensdatenbank des Nutzers (Obsidian Vault). + +Wenn du Fragen beantwortest: +- Nutze die bereitgestellten Notizen als primäre Wissensquelle +- Verweise auf relevante Notizen mit [[doppelten eckigen Klammern]] +- Antworte auf Deutsch, wenn die Frage auf Deutsch gestellt wird +- Wenn der Kontext unzureichend ist, sage das ehrlich und gib an, was noch fehlen könnte +- Verknüpfe Konzepte aus verschiedenen Notizen kreativ miteinander`, + autoRetrieveContext: true, + showContextPreview: true, + saveThreadsToVault: true, + threadsFolder: "Calendar/Chat", +}; + +export const MODELS = [ + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Stärkst)" }, + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5 (Empfohlen)" }, + { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (Schnell)" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, +]; + +export class MemexChatSettingsTab extends PluginSettingTab { + plugin: MemexChatPlugin; + + constructor(app: App, plugin: MemexChatPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "Memex Chat Einstellungen" }); + + // --- API --- + containerEl.createEl("h3", { text: "Claude API" }); + + new Setting(containerEl) + .setName("API Key") + .setDesc("Dein Anthropic API Key (sk-ant-...)") + .addText((text) => + text + .setPlaceholder("sk-ant-api03-...") + .setValue(this.plugin.settings.apiKey) + .onChange(async (value) => { + this.plugin.settings.apiKey = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Modell") + .setDesc("Welches Claude-Modell verwenden?") + .addDropdown((drop) => { + for (const m of MODELS) drop.addOption(m.id, m.name); + drop.setValue(this.plugin.settings.model).onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + }); + }); + + // --- Context --- + containerEl.createEl("h3", { text: "Kontext-Einstellungen" }); + + new Setting(containerEl) + .setName("Max. Kontext-Notizen") + .setDesc("Wie viele Notizen werden automatisch als Kontext hinzugefügt? (1–15)") + .addSlider((slider) => + slider + .setLimits(1, 15, 1) + .setValue(this.plugin.settings.maxContextNotes) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.maxContextNotes = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Max. Zeichen pro Notiz") + .setDesc("Wie viele Zeichen einer Notiz in den Kontext einbezogen werden (1000–8000)") + .addSlider((slider) => + slider + .setLimits(1000, 8000, 500) + .setValue(this.plugin.settings.maxCharsPerNote) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.maxCharsPerNote = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Automatischer Kontext-Abruf") + .setDesc("Beim Senden automatisch relevante Notizen suchen und einbinden") + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.autoRetrieveContext).onChange(async (value) => { + this.plugin.settings.autoRetrieveContext = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Kontext-Vorschau anzeigen") + .setDesc("Vor dem Senden zeigen, welche Notizen als Kontext verwendet werden") + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.showContextPreview).onChange(async (value) => { + this.plugin.settings.showContextPreview = value; + await this.plugin.saveSettings(); + }) + ); + + // --- Threads --- + containerEl.createEl("h3", { text: "Thread-History" }); + + new 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) => { + this.plugin.settings.saveThreadsToVault = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Threads-Ordner") + .setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden") + .addText((text) => + text + .setPlaceholder("Calendar/Chat") + .setValue(this.plugin.settings.threadsFolder) + .onChange(async (value) => { + this.plugin.settings.threadsFolder = value; + await this.plugin.saveSettings(); + }) + ); + + // --- System Prompt --- + containerEl.createEl("h3", { text: "System Prompt" }); + + new Setting(containerEl) + .setName("System Prompt") + .setDesc("Instruktionen für Claude (wie soll er sich verhalten?)") + .addTextArea((textarea) => { + textarea + .setValue(this.plugin.settings.systemPrompt) + .onChange(async (value) => { + this.plugin.settings.systemPrompt = value; + await this.plugin.saveSettings(); + }); + textarea.inputEl.rows = 8; + textarea.inputEl.style.width = "100%"; + textarea.inputEl.style.fontFamily = "monospace"; + textarea.inputEl.style.fontSize = "12px"; + }); + + // --- Actions --- + containerEl.createEl("h3", { text: "Aktionen" }); + + new Setting(containerEl) + .setName("Index neu aufbauen") + .setDesc("Vault-Index für die Suche neu aufbauen (dauert je nach Vault-Größe einige Sekunden)") + .addButton((btn) => + btn + .setButtonText("Index neu aufbauen") + .setCta() + .onClick(async () => { + btn.setButtonText("Indiziere…"); + btn.setDisabled(true); + await this.plugin.rebuildIndex(); + btn.setButtonText("✓ Fertig!"); + setTimeout(() => { + btn.setButtonText("Index neu aufbauen"); + btn.setDisabled(false); + }, 2000); + }) + ); + } +} diff --git a/src/VaultSearch.ts b/src/VaultSearch.ts new file mode 100644 index 0000000..2afba38 --- /dev/null +++ b/src/VaultSearch.ts @@ -0,0 +1,209 @@ +import { App, TFile } from "obsidian"; + +export interface SearchResult { + file: TFile; + score: number; + excerpt: string; + title: string; +} + +/** Minimal TF-IDF search engine over the Obsidian vault */ +export class VaultSearch { + private app: App; + private docVectors: Map> = new Map(); // path -> term -> tfidf + private idf: Map = new Map(); + private docContents: Map = new Map(); + private indexed = false; + private indexing = false; + onProgress?: (done: number, total: number) => void; + + constructor(app: App) { + this.app = app; + } + + /** Tokenize text: lowercase, split on non-word chars, keep umlauts */ + private tokenize(text: string): string[] { + return text + .toLowerCase() + .replace(/[^\wäöüßÄÖÜ\s]/g, " ") + .split(/\s+/) + .filter((t) => t.length > 2); + } + + /** Strip YAML frontmatter and Obsidian-specific markup */ + private cleanContent(raw: string): string { + let content = raw; + // Remove frontmatter + if (content.startsWith("---")) { + const end = content.indexOf("\n---", 3); + if (end > 0) content = content.slice(end + 4); + } + // Unwrap wikilinks [[target|alias]] → alias or target + content = content.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, target, alias) => alias || target); + // Remove markdown images/links + content = content.replace(/!\[.*?\]\(.*?\)/g, ""); + content = content.replace(/\[([^\]]+)\]\(.*?\)/g, "$1"); + // Remove callout syntax + content = content.replace(/>\s*\[!\w+\][+-]?\s*/g, ""); + // Remove headers formatting (keep text) + content = content.replace(/^#{1,6}\s+/gm, ""); + return content; + } + + /** Build or rebuild the TF-IDF index */ + async buildIndex(): Promise { + if (this.indexing) return; + this.indexing = true; + this.indexed = false; + this.docVectors.clear(); + this.idf.clear(); + this.docContents.clear(); + + const files = this.app.vault.getMarkdownFiles(); + const total = files.length; + const df: Map = new Map(); // term -> doc count + + // Step 1: Read all files, compute TF + const tfs: Map> = new Map(); + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (this.onProgress && i % 100 === 0) this.onProgress(i, total); + try { + const raw = await this.app.vault.cachedRead(file); + const clean = this.cleanContent(raw); + this.docContents.set(file.path, clean); + + const tokens = this.tokenize(clean + " " + file.basename); + const tf: Map = new Map(); + for (const t of tokens) { + tf.set(t, (tf.get(t) ?? 0) + 1); + } + // Normalize TF + const maxTf = Math.max(...tf.values(), 1); + const normalizedTf: Map = new Map(); + for (const [t, count] of tf) { + normalizedTf.set(t, count / maxTf); + } + tfs.set(file.path, normalizedTf); + + // Update DF + for (const t of tf.keys()) { + df.set(t, (df.get(t) ?? 0) + 1); + } + } catch { + // skip unreadable files + } + } + + // Step 2: Compute IDF and TF-IDF vectors + const N = files.length; + for (const [term, docCount] of df) { + this.idf.set(term, Math.log(N / docCount + 1)); + } + + for (const [path, tf] of tfs) { + const vec: Map = new Map(); + let norm = 0; + for (const [term, tfVal] of tf) { + const idfVal = this.idf.get(term) ?? 0; + const tfidf = tfVal * idfVal; + vec.set(term, tfidf); + norm += tfidf * tfidf; + } + // L2 normalize + norm = Math.sqrt(norm); + if (norm > 0) { + for (const [term, val] of vec) { + vec.set(term, val / norm); + } + } + this.docVectors.set(path, vec); + } + + this.indexed = true; + this.indexing = false; + if (this.onProgress) this.onProgress(total, total); + } + + isIndexed(): boolean { + return this.indexed; + } + + /** Search for the top-K most similar notes to the query */ + async search(query: string, topK = 8): Promise { + if (!this.indexed) await this.buildIndex(); + + const tokens = this.tokenize(query); + // Build query TF vector + const qtf: Map = new Map(); + for (const t of tokens) qtf.set(t, (qtf.get(t) ?? 0) + 1); + const qMax = Math.max(...qtf.values(), 1); + + // Query TF-IDF normalized + const qvec: Map = new Map(); + let qnorm = 0; + for (const [t, count] of qtf) { + const tfidf = (count / qMax) * (this.idf.get(t) ?? 0); + qvec.set(t, tfidf); + qnorm += tfidf * tfidf; + } + qnorm = Math.sqrt(qnorm); + if (qnorm > 0) for (const [t, v] of qvec) qvec.set(t, v / qnorm); + + // Score all documents + const scores: Array<[string, number]> = []; + for (const [path, vec] of this.docVectors) { + let score = 0; + for (const [t, qv] of qvec) { + const dv = vec.get(t) ?? 0; + score += qv * dv; + } + if (score > 0.01) scores.push([path, score]); + } + + scores.sort((a, b) => b[1] - a[1]); + const top = scores.slice(0, topK); + + const files = this.app.vault.getMarkdownFiles(); + const fileMap = new Map(files.map((f) => [f.path, f])); + + return top + .map(([path, score]) => { + const file = fileMap.get(path); + if (!file) return null; + const content = this.docContents.get(path) ?? ""; + const excerpt = this.buildExcerpt(content, query, 300); + return { file, score, excerpt, title: file.basename }; + }) + .filter(Boolean) as SearchResult[]; + } + + /** Get note content for context injection */ + async getContent(file: TFile, maxChars = 3000): Promise { + try { + const raw = await this.app.vault.cachedRead(file); + return this.cleanContent(raw).slice(0, maxChars); + } catch { + return ""; + } + } + + private buildExcerpt(content: string, query: string, maxLen: number): string { + const queryWords = query.toLowerCase().split(/\s+/); + const lower = content.toLowerCase(); + let bestPos = 0; + let bestScore = 0; + for (let i = 0; i < content.length - maxLen; i += 50) { + const window = lower.slice(i, i + maxLen); + const score = queryWords.filter((w) => window.includes(w)).length; + if (score > bestScore) { + bestScore = score; + bestPos = i; + } + } + let excerpt = content.slice(bestPos, bestPos + maxLen).trim(); + if (bestPos > 0) excerpt = "…" + excerpt; + if (bestPos + maxLen < content.length) excerpt += "…"; + return excerpt; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c7684db --- /dev/null +++ b/src/main.ts @@ -0,0 +1,130 @@ +import { Plugin, WorkspaceLeaf } from "obsidian"; +import { ChatView, VIEW_TYPE_MEMEX_CHAT } from "./ChatView"; +import { VaultSearch } from "./VaultSearch"; +import { ClaudeClient } from "./ClaudeClient"; +import { MemexChatSettingsTab, MemexChatSettings, DEFAULT_SETTINGS } from "./SettingsTab"; + +interface PluginData { + settings: MemexChatSettings; + threads: unknown[]; +} + +export default class MemexChatPlugin extends Plugin { + settings!: MemexChatSettings; + search!: VaultSearch; + claude!: ClaudeClient; + data!: PluginData; + + async onload(): Promise { + // Load data + const loaded = (await this.loadData()) as PluginData | null; + this.data = { + settings: { ...DEFAULT_SETTINGS, ...(loaded?.settings ?? {}) }, + threads: loaded?.threads ?? [], + }; + this.settings = this.data.settings; + + // Init services + this.search = new VaultSearch(this.app); + this.claude = new ClaudeClient(); + + // Register view + this.registerView(VIEW_TYPE_MEMEX_CHAT, (leaf) => new ChatView(leaf, this)); + + // Ribbon icon + this.addRibbonIcon("message-circle", "Memex Chat öffnen", () => { + this.activateView(); + }); + + // Commands + this.addCommand({ + id: "open-memex-chat", + name: "Memex Chat öffnen", + callback: () => this.activateView(), + }); + + this.addCommand({ + id: "memex-chat-rebuild-index", + name: "Memex Chat: Index neu aufbauen", + callback: () => this.rebuildIndex(), + }); + + this.addCommand({ + id: "memex-chat-active-note", + name: "Memex Chat: Aktive Notiz als Kontext", + callback: () => { + const file = this.app.workspace.getActiveFile(); + if (file) { + this.activateView().then(() => { + // Pre-fill with active note path + const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT)[0]; + if (leaf) { + const view = leaf.view as ChatView; + // @ts-ignore + view.inputEl.value = `Erkläre und verknüpfe [[${file.basename}]] mit anderen Konzepten im Vault.`; + // @ts-ignore + view.explicitContext = [file]; + } + }); + } + }, + }); + + // Settings tab + this.addSettingTab(new MemexChatSettingsTab(this.app, this)); + + // Build index in background after startup + setTimeout(() => { + if (!this.search.isIndexed()) { + this.search.buildIndex().catch(console.error); + } + }, 3000); + + console.log("[Memex Chat] Plugin geladen"); + } + + onunload(): void { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_MEMEX_CHAT); + } + + async activateView(): Promise { + const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT); + if (existing.length > 0) { + this.app.workspace.revealLeaf(existing[0]); + return; + } + + const leaf = this.app.workspace.getRightLeaf(false); + if (!leaf) return; + await leaf.setViewState({ type: VIEW_TYPE_MEMEX_CHAT, active: true }); + this.app.workspace.revealLeaf(leaf); + } + + async rebuildIndex(): Promise { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMEX_CHAT); + const view = leaves[0]?.view as ChatView | undefined; + + this.search.onProgress = (done, total) => { + if (view && done % 200 === 0) { + // @ts-ignore + view.setStatus(`Indiziere… ${done}/${total}`); + } + }; + + await this.search.buildIndex(); + this.search.onProgress = undefined; + if (view) { + // @ts-ignore + view.setStatus(`✓ ${this.app.vault.getMarkdownFiles().length} Notizen indiziert`); + setTimeout(() => { + // @ts-ignore + view.setStatus(""); + }, 3000); + } + } + + async saveSettings(): Promise { + this.data.settings = this.settings; + await this.saveData(this.data); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..69d254e --- /dev/null +++ b/styles.css @@ -0,0 +1,469 @@ +/* ─── Memex Chat Plugin Styles ───────────────────────────────────────── */ + +.vc-root { + display: flex; + flex-direction: column; + height: 100%; + font-size: 14px; + overflow: hidden; +} + +/* Header */ +.vc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--background-modifier-border); + flex-shrink: 0; +} + +.vc-header-title { + font-weight: 600; + font-size: 15px; + color: var(--text-normal); +} + +.vc-header-actions { + display: flex; + gap: 4px; +} + +.vc-icon-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: background 0.15s, color 0.15s; +} + +.vc-icon-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* Main layout */ +.vc-main { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Thread sidebar */ +.vc-sidebar { + width: 140px; + flex-shrink: 0; + border-right: 1px solid var(--background-modifier-border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.vc-sidebar-title { + padding: 8px 10px 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + flex-shrink: 0; +} + +.vc-thread-list { + overflow-y: auto; + flex: 1; +} + +.vc-thread-item { + display: flex; + align-items: center; + padding: 6px 10px; + cursor: pointer; + border-radius: 0; + gap: 4px; + transition: background 0.1s; +} + +.vc-thread-item:hover { + background: var(--background-modifier-hover); +} + +.vc-thread-item--active { + background: var(--background-modifier-active-hover); + border-left: 2px solid var(--interactive-accent); +} + +.vc-thread-title { + flex: 1; + font-size: 12px; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.vc-thread-del { + font-size: 10px; + opacity: 0; + transition: opacity 0.15s; +} + +.vc-thread-item:hover .vc-thread-del { + opacity: 1; +} + +/* Chat area */ +.vc-chat-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +/* Status */ +.vc-status { + padding: 4px 12px; + font-size: 11px; + color: var(--text-accent); + background: var(--background-secondary); + flex-shrink: 0; + display: none; + border-bottom: 1px solid var(--background-modifier-border); +} + +/* Messages */ +.vc-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.vc-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 8px; + color: var(--text-muted); + text-align: center; + padding: 20px; +} + +.vc-empty-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.vc-empty-text { + font-size: 13px; + line-height: 1.5; +} + +.vc-empty-hint { + font-size: 11px; + opacity: 0.7; + font-style: italic; +} + +.vc-msg { + display: flex; + flex-direction: column; + gap: 4px; +} + +.vc-msg--user { + align-items: flex-end; +} + +.vc-msg--assistant { + align-items: flex-start; +} + +.vc-bubble { + max-width: 90%; + padding: 8px 12px; + border-radius: 12px; + font-size: 13px; + line-height: 1.55; +} + +.vc-msg--user .vc-bubble { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-bottom-right-radius: 4px; + white-space: pre-wrap; +} + +.vc-msg--assistant .vc-bubble { + background: var(--background-secondary); + color: var(--text-normal); + border-bottom-left-radius: 4px; + width: 100%; + max-width: 100%; +} + +/* Markdown rendering inside bubble */ +.vc-md p { + margin: 0 0 8px; +} +.vc-md p:last-child { + margin-bottom: 0; +} +.vc-md h1, .vc-md h2, .vc-md h3 { + margin: 12px 0 6px; + font-size: 1em; + font-weight: 600; +} +.vc-md code { + background: var(--background-primary-alt); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.9em; +} +.vc-md pre { + background: var(--background-primary-alt); + padding: 8px 10px; + border-radius: 6px; + overflow-x: auto; + font-size: 12px; +} +.vc-md ul, .vc-md ol { + padding-left: 20px; + margin: 4px 0; +} +.vc-md li { + margin: 2px 0; +} +.vc-md a.internal-link { + color: var(--text-accent); + cursor: pointer; + text-decoration: none; +} +.vc-md a.internal-link:hover { + text-decoration: underline; +} +.vc-md blockquote { + border-left: 3px solid var(--text-muted); + margin: 8px 0; + padding: 4px 8px; + color: var(--text-muted); +} + +/* Streaming cursor */ +.vc-cursor { + animation: blink 1s step-end infinite; + opacity: 0.7; + font-size: 11px; +} + +@keyframes blink { + 0%, 100% { opacity: 0; } + 50% { opacity: 0.7; } +} + +/* Sources */ +.vc-sources { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + padding: 4px 0 2px; + font-size: 11px; +} + +.vc-sources-label { + color: var(--text-muted); +} + +.vc-source-link { + color: var(--text-accent); + cursor: pointer; + background: var(--background-secondary); + padding: 1px 6px; + border-radius: 10px; + border: 1px solid var(--background-modifier-border); + transition: background 0.1s; +} + +.vc-source-link:hover { + background: var(--background-modifier-hover); +} + +/* Context Preview */ +.vc-context-preview { + padding: 8px 12px; + background: var(--background-secondary); + border-top: 1px solid var(--background-modifier-border); + flex-shrink: 0; + max-height: 200px; + overflow-y: auto; +} + +.vc-ctx-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.vc-ctx-title { + font-size: 12px; + font-weight: 600; + color: var(--text-normal); +} + +.vc-ctx-actions { + display: flex; + gap: 6px; +} + +.vc-ctx-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.vc-ctx-item { + padding: 5px 8px; + background: var(--background-primary); + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + font-size: 12px; +} + +.vc-ctx-item-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +} + +.vc-ctx-item-title { + flex: 1; + font-weight: 500; + color: var(--text-accent); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vc-ctx-item-title:hover { + text-decoration: underline; +} + +.vc-ctx-score { + font-size: 10px; + color: var(--text-muted); + background: var(--background-modifier-hover); + padding: 1px 5px; + border-radius: 8px; +} + +.vc-ctx-remove { + font-size: 10px; + color: var(--text-muted); + padding: 1px 4px; +} + +.vc-ctx-excerpt { + color: var(--text-muted); + font-size: 11px; + line-height: 1.4; +} + +/* Input area */ +.vc-input-area { + padding: 8px 12px 12px; + border-top: 1px solid var(--background-modifier-border); + flex-shrink: 0; +} + +.vc-input-wrapper { + margin-bottom: 6px; +} + +.vc-input { + width: 100%; + resize: none; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + font-family: var(--font-text); + background: var(--background-primary); + color: var(--text-normal); + outline: none; + transition: border-color 0.15s; + line-height: 1.5; + min-height: 60px; + max-height: 200px; + box-sizing: border-box; +} + +.vc-input:focus { + border-color: var(--interactive-accent); +} + +.vc-input-actions { + display: flex; + align-items: center; + justify-content: space-between; +} + +.vc-ctx-btn { + font-size: 11px; + padding: 4px 8px; + border-radius: 5px; + background: var(--background-modifier-hover); + border: 1px solid var(--background-modifier-border); + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: background 0.1s, color 0.1s; +} + +.vc-ctx-btn:hover { + background: var(--background-modifier-active-hover); + color: var(--text-normal); +} + +.vc-send-btn { + padding: 6px 16px; + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} + +.vc-send-btn:hover { + opacity: 0.9; +} + +.vc-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vc-send-btn--sm { + padding: 3px 10px; + font-size: 11px; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0ffb413 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2018", + "allowImportingTsExtensions": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["ES2018", "DOM"] + }, + "include": ["src/**/*.ts"] +}