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:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.js.map
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Generated
+602
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type MemexChatPlugin from "./main";
|
||||||
|
|
||||||
|
export interface MemexChatSettings {
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
maxContextNotes: number;
|
||||||
|
maxCharsPerNote: number;
|
||||||
|
systemPrompt: string;
|
||||||
|
autoRetrieveContext: boolean;
|
||||||
|
showContextPreview: boolean;
|
||||||
|
saveThreadsToVault: boolean;
|
||||||
|
threadsFolder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: MemexChatSettings = {
|
||||||
|
apiKey: "",
|
||||||
|
model: "claude-opus-4-5-20251101",
|
||||||
|
maxContextNotes: 6,
|
||||||
|
maxCharsPerNote: 2500,
|
||||||
|
systemPrompt: `Du bist ein hilfreicher Assistent mit Zugriff auf die persönliche Wissensdatenbank des Nutzers (Obsidian Vault).
|
||||||
|
|
||||||
|
Wenn du Fragen beantwortest:
|
||||||
|
- Nutze die bereitgestellten Notizen als primäre Wissensquelle
|
||||||
|
- Verweise auf relevante Notizen mit [[doppelten eckigen Klammern]]
|
||||||
|
- Antworte auf Deutsch, wenn die Frage auf Deutsch gestellt wird
|
||||||
|
- Wenn der Kontext unzureichend ist, sage das ehrlich und gib an, was noch fehlen könnte
|
||||||
|
- Verknüpfe Konzepte aus verschiedenen Notizen kreativ miteinander`,
|
||||||
|
autoRetrieveContext: true,
|
||||||
|
showContextPreview: true,
|
||||||
|
saveThreadsToVault: true,
|
||||||
|
threadsFolder: "Calendar/Chat",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MODELS = [
|
||||||
|
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Stärkst)" },
|
||||||
|
{ id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5 (Empfohlen)" },
|
||||||
|
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (Schnell)" },
|
||||||
|
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class MemexChatSettingsTab extends PluginSettingTab {
|
||||||
|
plugin: MemexChatPlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: MemexChatPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "Memex Chat Einstellungen" });
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
containerEl.createEl("h3", { text: "Claude API" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("API Key")
|
||||||
|
.setDesc("Dein Anthropic API Key (sk-ant-...)")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("sk-ant-api03-...")
|
||||||
|
.setValue(this.plugin.settings.apiKey)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.apiKey = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Modell")
|
||||||
|
.setDesc("Welches Claude-Modell verwenden?")
|
||||||
|
.addDropdown((drop) => {
|
||||||
|
for (const m of MODELS) drop.addOption(m.id, m.name);
|
||||||
|
drop.setValue(this.plugin.settings.model).onChange(async (value) => {
|
||||||
|
this.plugin.settings.model = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Context ---
|
||||||
|
containerEl.createEl("h3", { text: "Kontext-Einstellungen" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Max. Kontext-Notizen")
|
||||||
|
.setDesc("Wie viele Notizen werden automatisch als Kontext hinzugefügt? (1–15)")
|
||||||
|
.addSlider((slider) =>
|
||||||
|
slider
|
||||||
|
.setLimits(1, 15, 1)
|
||||||
|
.setValue(this.plugin.settings.maxContextNotes)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.maxContextNotes = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Max. Zeichen pro Notiz")
|
||||||
|
.setDesc("Wie viele Zeichen einer Notiz in den Kontext einbezogen werden (1000–8000)")
|
||||||
|
.addSlider((slider) =>
|
||||||
|
slider
|
||||||
|
.setLimits(1000, 8000, 500)
|
||||||
|
.setValue(this.plugin.settings.maxCharsPerNote)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.maxCharsPerNote = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Automatischer Kontext-Abruf")
|
||||||
|
.setDesc("Beim Senden automatisch relevante Notizen suchen und einbinden")
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle.setValue(this.plugin.settings.autoRetrieveContext).onChange(async (value) => {
|
||||||
|
this.plugin.settings.autoRetrieveContext = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Kontext-Vorschau anzeigen")
|
||||||
|
.setDesc("Vor dem Senden zeigen, welche Notizen als Kontext verwendet werden")
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle.setValue(this.plugin.settings.showContextPreview).onChange(async (value) => {
|
||||||
|
this.plugin.settings.showContextPreview = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Threads ---
|
||||||
|
containerEl.createEl("h3", { text: "Thread-History" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Threads im Vault speichern")
|
||||||
|
.setDesc("Chat-Threads als Markdown-Notizen im Vault ablegen")
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle.setValue(this.plugin.settings.saveThreadsToVault).onChange(async (value) => {
|
||||||
|
this.plugin.settings.saveThreadsToVault = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Threads-Ordner")
|
||||||
|
.setDesc("Pfad im Vault, wo Chat-Threads gespeichert werden")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("Calendar/Chat")
|
||||||
|
.setValue(this.plugin.settings.threadsFolder)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.threadsFolder = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- System Prompt ---
|
||||||
|
containerEl.createEl("h3", { text: "System Prompt" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("System Prompt")
|
||||||
|
.setDesc("Instruktionen für Claude (wie soll er sich verhalten?)")
|
||||||
|
.addTextArea((textarea) => {
|
||||||
|
textarea
|
||||||
|
.setValue(this.plugin.settings.systemPrompt)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.systemPrompt = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
textarea.inputEl.rows = 8;
|
||||||
|
textarea.inputEl.style.width = "100%";
|
||||||
|
textarea.inputEl.style.fontFamily = "monospace";
|
||||||
|
textarea.inputEl.style.fontSize = "12px";
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
containerEl.createEl("h3", { text: "Aktionen" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Index neu aufbauen")
|
||||||
|
.setDesc("Vault-Index für die Suche neu aufbauen (dauert je nach Vault-Größe einige Sekunden)")
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText("Index neu aufbauen")
|
||||||
|
.setCta()
|
||||||
|
.onClick(async () => {
|
||||||
|
btn.setButtonText("Indiziere…");
|
||||||
|
btn.setDisabled(true);
|
||||||
|
await this.plugin.rebuildIndex();
|
||||||
|
btn.setButtonText("✓ Fertig!");
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.setButtonText("Index neu aufbauen");
|
||||||
|
btn.setDisabled(false);
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user