v0.2.2: Copy/save buttons, system context file, max tokens setting

- Add Copy and Save as Note buttons on assistant messages (hover to reveal)
- Save as Note uses Obsidian's configured default new-note folder
- Add System Context File setting: vault note appended to every system prompt
- Add configurable Max. Antwort-Tokens setting (slider 1024–16000, default 8192)
  fixes Monthly Check responses being cut off
- helpText textarea auto-expands with content, collapses to 1 row when empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-04 23:32:08 +01:00
parent c2d3f12dd5
commit 64fac6bde9
7 changed files with 146 additions and 6 deletions
+49 -2
View File
@@ -337,6 +337,7 @@ ${content}`;
const stream = this.plugin.claude.streamChat(claudeMessages, {
apiKey: this.plugin.settings.apiKey,
model: this.plugin.settings.model,
maxTokens: this.plugin.settings.maxTokens,
systemPrompt
});
for await (const chunk of stream) {
@@ -556,6 +557,45 @@ ${content}`;
link.onclick = () => this.app.workspace.openLinkText(notePath, "", "tab");
}
}
if (!msg.isStreaming && msg.role === "assistant") {
const actions = msgEl.createDiv("vc-msg-actions");
const copyBtn = actions.createEl("button", { cls: "vc-msg-action-btn", title: "Antwort kopieren" });
copyBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" fill="none"><rect x="9" y="9" width="13" height="13" rx="2" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke-width="2"/></svg> Kopieren`;
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(msg.content);
copyBtn.textContent = "\u2713 Kopiert";
setTimeout(() => {
copyBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" fill="none"><rect x="9" y="9" width="13" height="13" rx="2" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke-width="2"/></svg> Kopieren`;
}, 2e3);
};
const saveBtn = actions.createEl("button", { cls: "vc-msg-action-btn", title: "Als neue Notiz speichern" });
saveBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" fill="none"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" stroke-width="2"/><polyline points="17 21 17 13 7 13 7 21" stroke-width="2"/><polyline points="7 3 7 8 15 8" stroke-width="2"/></svg> Als Notiz`;
saveBtn.onclick = async () => {
await this.saveResponseAsNote(msg.content);
};
}
}
async saveResponseAsNote(content) {
var _a;
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
const firstLine = (_a = content.split("\n").find((l) => l.trim())) != null ? _a : "Claude Antwort";
const title = firstLine.replace(/^#+\s*/, "").replace(/[\\/:*?"<>|]/g, " ").slice(0, 60).trim();
const noteContent = `---
created: ${date}
tags: [chat, claude]
---
${content}`;
try {
const folder = this.app.fileManager.getNewFileParent("");
const folderPath = folder.path === "/" ? "" : folder.path + "/";
const fileName = `${folderPath}${date} ${title}.md`;
const existing = this.app.vault.getAbstractFileByPath(fileName);
const file = existing instanceof import_obsidian.TFile ? await this.app.vault.modify(existing, noteContent).then(() => existing) : await this.app.vault.create(fileName, noteContent);
this.app.workspace.openLinkText(file.path, "", "tab");
} catch (e) {
this.setStatus("\u26A0 Fehler beim Speichern: " + e.message);
}
}
updateLastMessage(content) {
const messages = this.messagesEl.querySelectorAll(".vc-msg--assistant");
@@ -1140,7 +1180,7 @@ var ClaudeClient = class {
headers: this.headers(options.apiKey),
body: JSON.stringify({
model: options.model,
max_tokens: (_a = options.maxTokens) != null ? _a : 2048,
max_tokens: (_a = options.maxTokens) != null ? _a : 8192,
system: options.systemPrompt,
messages
}),
@@ -1163,7 +1203,7 @@ var ClaudeClient = class {
headers: this.headers(options.apiKey),
body: JSON.stringify({
model: options.model,
max_tokens: (_a = options.maxTokens) != null ? _a : 2048,
max_tokens: (_a = options.maxTokens) != null ? _a : 8192,
system: options.systemPrompt,
messages
}),
@@ -1181,6 +1221,7 @@ var import_obsidian3 = require("obsidian");
var DEFAULT_SETTINGS = {
apiKey: "",
model: "claude-opus-4-5-20251101",
maxTokens: 8192,
maxContextNotes: 6,
maxCharsPerNote: 2500,
systemPrompt: `Du bist ein hilfreicher Assistent mit Zugriff auf die pers\xF6nliche Wissensdatenbank des Nutzers (Obsidian Vault).
@@ -1241,6 +1282,12 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab {
await this.plugin.saveSettings();
});
});
new import_obsidian3.Setting(containerEl).setName("Max. Antwort-Tokens").setDesc("Maximale L\xE4nge der Claude-Antwort. F\xFCr lange Analysen (z.B. Monthly Check) h\xF6her einstellen. (1024\u201316000)").addSlider(
(slider) => slider.setLimits(1024, 16e3, 512).setValue(this.plugin.settings.maxTokens).setDynamicTooltip().onChange(async (value) => {
this.plugin.settings.maxTokens = value;
await this.plugin.saveSettings();
})
);
new import_obsidian3.Setting(containerEl).setName("Senden mit Enter").setDesc("Ein: Enter sendet. Aus: Cmd+Enter sendet (Enter = neue Zeile)").addToggle(
(toggle) => toggle.setValue(this.plugin.settings.sendOnEnter).onChange(async (value) => {
this.plugin.settings.sendOnEnter = value;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "memex-chat",
"name": "Memex Chat",
"version": "0.2.1",
"version": "0.2.2",
"minAppVersion": "1.4.0",
"description": "Chat with your Obsidian vault using Claude AI — semantic context retrieval, @ mentions, thread history.",
"author": "Sven",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "memex-chat",
"version": "0.2.1",
"version": "0.2.2",
"description": "Obsidian plugin: Chat with your vault using Claude AI",
"main": "main.js",
"scripts": {
+45
View File
@@ -420,6 +420,7 @@ export class ChatView extends ItemView {
const stream = this.plugin.claude.streamChat(claudeMessages, {
apiKey: this.plugin.settings.apiKey,
model: this.plugin.settings.model,
maxTokens: this.plugin.settings.maxTokens,
systemPrompt,
});
@@ -661,6 +662,50 @@ export class ChatView extends ItemView {
link.onclick = () => this.app.workspace.openLinkText(notePath, "", "tab");
}
}
// Action buttons for finished assistant messages
if (!msg.isStreaming && msg.role === "assistant") {
const actions = msgEl.createDiv("vc-msg-actions");
// Copy button
const copyBtn = actions.createEl("button", { cls: "vc-msg-action-btn", title: "Antwort kopieren" });
copyBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" fill="none"><rect x="9" y="9" width="13" height="13" rx="2" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke-width="2"/></svg> Kopieren`;
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(msg.content);
copyBtn.textContent = "✓ Kopiert";
setTimeout(() => {
copyBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" fill="none"><rect x="9" y="9" width="13" height="13" rx="2" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke-width="2"/></svg> Kopieren`;
}, 2000);
};
// Save as note button
const saveBtn = actions.createEl("button", { cls: "vc-msg-action-btn", title: "Als neue Notiz speichern" });
saveBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" fill="none"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" stroke-width="2"/><polyline points="17 21 17 13 7 13 7 21" stroke-width="2"/><polyline points="7 3 7 8 15 8" stroke-width="2"/></svg> Als Notiz`;
saveBtn.onclick = async () => {
await this.saveResponseAsNote(msg.content);
};
}
}
private async saveResponseAsNote(content: string): Promise<void> {
const date = new Date().toISOString().slice(0, 10);
// Use first non-empty line as title (max 60 chars, strip markdown headers)
const firstLine = content.split("\n").find((l) => l.trim()) ?? "Claude Antwort";
const title = firstLine.replace(/^#+\s*/, "").replace(/[\\/:*?"<>|]/g, " ").slice(0, 60).trim();
const noteContent = `---\ncreated: ${date}\ntags: [chat, claude]\n---\n\n${content}`;
try {
// Use Obsidian's configured default new-note folder
const folder = this.app.fileManager.getNewFileParent("");
const folderPath = folder.path === "/" ? "" : folder.path + "/";
const fileName = `${folderPath}${date} ${title}.md`;
const existing = this.app.vault.getAbstractFileByPath(fileName);
const file = existing instanceof TFile
? await this.app.vault.modify(existing, noteContent).then(() => existing)
: await this.app.vault.create(fileName, noteContent);
this.app.workspace.openLinkText(file.path, "", "tab");
} catch (e) {
this.setStatus("⚠ Fehler beim Speichern: " + e.message);
}
}
private updateLastMessage(content: string): void {
+2 -2
View File
@@ -45,7 +45,7 @@ export class ClaudeClient {
headers: this.headers(options.apiKey),
body: JSON.stringify({
model: options.model,
max_tokens: options.maxTokens ?? 2048,
max_tokens: options.maxTokens ?? 8192,
system: options.systemPrompt,
messages,
}),
@@ -70,7 +70,7 @@ export class ClaudeClient {
headers: this.headers(options.apiKey),
body: JSON.stringify({
model: options.model,
max_tokens: options.maxTokens ?? 2048,
max_tokens: options.maxTokens ?? 8192,
system: options.systemPrompt,
messages,
}),
+16
View File
@@ -12,6 +12,7 @@ export interface PromptButton {
export interface MemexChatSettings {
apiKey: string;
model: string;
maxTokens: number;
maxContextNotes: number;
maxCharsPerNote: number;
systemPrompt: string;
@@ -28,6 +29,7 @@ export interface MemexChatSettings {
export const DEFAULT_SETTINGS: MemexChatSettings = {
apiKey: "",
model: "claude-opus-4-5-20251101",
maxTokens: 8192,
maxContextNotes: 6,
maxCharsPerNote: 2500,
systemPrompt: `Du bist ein hilfreicher Assistent mit Zugriff auf die persönliche Wissensdatenbank des Nutzers (Obsidian Vault).
@@ -107,6 +109,20 @@ export class MemexChatSettingsTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName("Max. Antwort-Tokens")
.setDesc("Maximale Länge der Claude-Antwort. Für lange Analysen (z.B. Monthly Check) höher einstellen. (102416000)")
.addSlider((slider) =>
slider
.setLimits(1024, 16000, 512)
.setValue(this.plugin.settings.maxTokens)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.maxTokens = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Senden mit Enter")
.setDesc("Ein: Enter sendet. Aus: Cmd+Enter sendet (Enter = neue Zeile)")
+32
View File
@@ -498,6 +498,38 @@
background: var(--background-modifier-hover);
}
/* Message action buttons (Copy, Save as Note) */
.vc-msg-actions {
display: flex;
gap: 6px;
margin-top: 6px;
opacity: 0;
transition: opacity 0.15s;
}
.vc-msg--assistant:hover .vc-msg-actions {
opacity: 1;
}
.vc-msg-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.vc-msg-action-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
/* Context Preview */
.vc-context-preview {
padding: 8px 12px;