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 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-04 20:48:26 +01:00
commit 415e2ebe5d
14 changed files with 3369 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.js.map
+54
View File
@@ -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
+39
View File
@@ -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();
}
+944
View File
@@ -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 = `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none"><path d="M12 5v14M5 12h14" stroke-width="2" stroke-linecap="round"/></svg>`;
newThreadBtn.onclick = () => this.newThread();
const rebuildBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Index neu aufbauen" });
rebuildBtn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none"><path d="M4 4v5h5M20 20v-5h-5" stroke-width="2" stroke-linecap="round"/><path d="M4.07 9a8 8 0 0 1 14.72-1.65M19.93 15a8 8 0 0 1-14.72 1.65" stroke-width="2" stroke-linecap="round"/></svg>`;
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 = `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" stroke-width="2"/><path d="M14 2v6h6M8 13h8M8 17h5" stroke-width="2" stroke-linecap="round"/></svg> 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);
}
};
+10
View File
@@ -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
}
+602
View File
@@ -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
}
}
}
+17
View File
@@ -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"
}
}
+557
View File
@@ -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<void> {
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<void> {
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 = `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none"><path d="M12 5v14M5 12h14" stroke-width="2" stroke-linecap="round"/></svg>`;
newThreadBtn.onclick = () => this.newThread();
const rebuildBtn = headerActions.createEl("button", { cls: "vc-icon-btn", title: "Index neu aufbauen" });
rebuildBtn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none"><path d="M4 4v5h5M20 20v-5h-5" stroke-width="2" stroke-linecap="round"/><path d="M4.07 9a8 8 0 0 1 14.72-1.65M19.93 15a8 8 0 0 1-14.72 1.65" stroke-width="2" stroke-linecap="round"/></svg>`;
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 = `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" stroke-width="2"/><path d="M14 2v6h6M8 13h8M8 17h5" stroke-width="2" stroke-linecap="round"/></svg> 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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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
}
}
}
+118
View File
@@ -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<ClaudeStreamChunk> {
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<string> {
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 ?? "";
}
}
+201
View File
@@ -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? (115)")
.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 (10008000)")
.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);
})
);
}
}
+209
View File
@@ -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<string, Map<string, number>> = new Map(); // path -> term -> tfidf
private idf: Map<string, number> = new Map();
private docContents: Map<string, string> = 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<void> {
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<string, number> = new Map(); // term -> doc count
// Step 1: Read all files, compute TF
const tfs: Map<string, Map<string, number>> = 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<string, number> = 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<string, number> = 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<string, number> = 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<SearchResult[]> {
if (!this.indexed) await this.buildIndex();
const tokens = this.tokenize(query);
// Build query TF vector
const qtf: Map<string, number> = 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<string, number> = 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<string, TFile>(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<string> {
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;
}
}
+130
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
this.data.settings = this.settings;
await this.saveData(this.data);
}
}
+469
View File
@@ -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;
}
+17
View File
@@ -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"]
}