Archiv-Wiki im Panel: vier Tabs, FTS5-Suche, strukturierbares Archiv

- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel
  mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem)
- Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks,
  Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten)
- FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro
  Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine
- Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im
  Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS,
  Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks
- Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert
  statt zu blockieren; Fehler sichtbar als Panel-Toast
- Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und
  Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts
- MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
This commit is contained in:
marcus hinz
2026-07-19 15:25:40 +02:00
parent d4d455fa08
commit 994d5070f0
29 changed files with 3471 additions and 571 deletions
+298 -37
View File
@@ -66,35 +66,142 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
aufrufen und im Chat nur kurz bestätigen.",
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
IMMER `path` statt `text` übergeben — der Server liest die Datei \
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Vollständiger Entwurf als Markdown-Rohtext.",
},
"path": {
"type": "string",
"description":
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \
Panel geladen wird. Genau eines von beiden angeben.",
}
},
"required": ["text"],
},
},
{
"name": "write_commands",
"description":
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
Kacheln im ai-control-Panel. IMMER nutzen, wenn ein Befehl für den \
Nutzer bestimmt ist — statt ihn als Codeblock in den Chat zu \
schreiben; im Chat nur kurz einordnen. Die Befehle werden an die \
Befehls-History der Session angehängt (flüchtig, startet mit \
jeder Session leer).",
"inputSchema": {
"type": "object",
"properties": {
"commands": {
"type": "array",
"description": "Befehle in Ausführungsreihenfolge.",
"items": {
"type": "object",
"properties": {
"cmd": {
"type": "string",
"description": "Der Befehl, exakt so ausführbar.",
},
"note": {
"type": "string",
"description": "Optionale Kurznotiz, was der Befehl tut.",
}
},
"required": ["cmd"],
},
}
},
"required": ["commands"],
},
},
{
"name": "show_commands",
"description":
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
allen bisher ausgegebenen Befehlen), ohne etwas anzuhängen. Nutzen, \
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
„zeige die Befehlsliste“).",
"inputSchema": { "type": "object", "properties": {} },
},
{
"name": "archive_panel",
"description":
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \
(Nutzer sagt etwa „archiviere das“). Ist kein Ordner konfiguriert, \
den Zielpfad im \
Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \
konfigurierten Ordner meldet das Tool das zurück.",
Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \
konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \
nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \
das zurück.",
"inputSchema": {
"type": "object",
"properties": {
"dir": {
"type": "string",
"description":
"Optionaler Archiv-Ordner. Wird gesetzt und für künftige \
Archivierungen gemerkt.",
"Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \
künftige Archivierungen gemerkt.",
},
"folder": {
"type": "string",
"description":
"Optionaler Unterordner im Archiv-Home, relativ (z. B. \
`konzepte/panel`). Wird angelegt.",
},
"description": {
"type": "string",
"description": "Einzeiler zum Inhalt fürs Frontmatter.",
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Schlagwörter fürs Frontmatter (kurze Slugs).",
}
},
},
},
{
"name": "show_archive",
"description":
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
Dokumente nach Ordnern gruppiert, mit Beschreibungen und \
klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \
Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \
(„zeig das Archiv“).",
"inputSchema": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"description": "Optional: Seite dieses Schlagworts statt der Übersicht.",
}
},
},
},
{
"name": "search_archive",
"description":
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \
Archiv nach …“).",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Suchanfrage (FTS5-Syntax).",
},
"tag": {
"type": "string",
"description": "Optional: auf ein Schlagwort einengen.",
}
},
},
@@ -110,44 +217,198 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
fn call_tool(req: &Value) -> Value {
match req["params"]["name"].as_str().unwrap_or("") {
"write_panel" => call_write(req),
"write_commands" => call_write_commands(req),
"show_commands" => call_show_commands(),
"archive_panel" => call_archive(req),
other => json!({
"content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],
"isError": true,
}),
"search_archive" => call_search(req),
"show_archive" => call_show_archive(req),
other => err(format!("Unbekanntes Tool: {other}")),
}
}
/// Erfolgs-Antwort eines Tool-Aufrufs (ein Text-Content).
fn ok(text: String) -> Value {
json!({ "content": [{ "type": "text", "text": text }] })
}
/// Fehler-Antwort eines Tool-Aufrufs.
fn err(text: String) -> Value {
json!({ "content": [{ "type": "text", "text": text }], "isError": true })
}
/// Pfad aus der PTY-Umgebung; fehlt die Variable, läuft das Terminal
/// außerhalb von ai-control.
fn env_path(var: &str) -> Result<String, Value> {
std::env::var(var)
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
}
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL.
fn write_panel_text(text: &str) -> Result<(), Value> {
let path = env_path("AI_CONTROL_PANEL")?;
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))
}
fn call_write(req: &Value) -> Value {
let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
match std::env::var("AI_CONTROL_PANEL") {
Ok(path) => match std::fs::write(&path, text) {
Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
Err(e) => json!({
"content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
"isError": true,
}),
let args = &req["params"]["arguments"];
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
let (text, ok_msg) = match args["path"].as_str() {
Some(src) => match std::fs::read_to_string(src) {
Ok(content) => (content, "Datei ins Panel geladen."),
Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")),
},
Err(_) => json!({
"content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
"isError": true,
}),
None => (
args["text"].as_str().unwrap_or("").to_string(),
"Entwurf ins Panel geschrieben.",
),
};
match write_panel_text(&text) {
Ok(()) => ok(ok_msg.into()),
Err(e) => e,
}
}
/// Merkt, ob dieser Server-Prozess (= eine claude-Session) schon einen
/// Session-Marker in die History geschrieben hat.
static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn call_write_commands(req: &Value) -> Value {
let commands = req["params"]["arguments"]["commands"].clone();
let count = commands.as_array().map(Vec::len).unwrap_or(0);
let path = match env_path("AI_CONTROL_COMMANDS") {
Ok(path) => path,
Err(e) => return e,
};
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Ein Record pro Aufruf; der erste Aufruf der Session bekommt einen
// Marker-Record davor (Session-Trenner in der Kachel-Ansicht).
let mut out = String::new();
if !SESSION_MARKED.swap(true, std::sync::atomic::Ordering::SeqCst) {
out.push_str(&json!({ "ts": ts, "session": true }).to_string());
out.push('\n');
}
out.push_str(&json!({ "ts": ts, "commands": commands }).to_string());
out.push('\n');
let res = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes()));
match res {
Ok(()) => ok(format!("{count} Befehl(e) als Kacheln im Panel abgelegt.")),
Err(e) => err(format!("Command-History nicht schreibbar: {e}")),
}
}
/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie
/// als `commands-update`, das Panel schaltet auf „Befehle“ um.
fn call_show_commands() -> Value {
let path = match env_path("AI_CONTROL_COMMANDS") {
Ok(path) => path,
Err(e) => return e,
};
let res = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
match res {
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()),
Err(e) => err(format!("Command-History nicht erreichbar: {e}")),
}
}
fn call_archive(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let dir = req["params"]["arguments"]["dir"].as_str();
match crate::domain::archive::archive_panel_content(&project, dir) {
Ok(path) => json!({
"content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],
}),
Err(e) => json!({
"content": [{
"type": "text",
"text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."),
}],
"isError": true,
}),
let args = &req["params"]["arguments"];
let dir = args["dir"].as_str();
let meta = crate::domain::archive::ArchiveMeta {
folder: args["folder"].as_str().map(str::to_string),
description: args["description"].as_str().map(str::to_string),
tags: args["tags"]
.as_array()
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default(),
};
match crate::domain::archive::archive_panel_content(&project, dir, &meta) {
Ok(path) => ok(format!("Archiviert: {}", path.display())),
Err(e) => err(format!(
"Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."
)),
}
}
/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer
/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel.
fn call_show_archive(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let home = match crate::domain::archive::require_archive_home(&project) {
Ok(home) => home,
Err(e) => return err(e),
};
let tag = req["params"]["arguments"]["tag"].as_str();
let page = match crate::domain::archive_index::archive_page(&home, tag) {
Ok(page) => page,
Err(e) => return err(e),
};
let path = match env_path("AI_CONTROL_WIKI") {
Ok(path) => path,
Err(e) => return e,
};
let json = match serde_json::to_string(&page) {
Ok(json) => json,
Err(e) => return err(e.to_string()),
};
match std::fs::write(&path, json) {
Ok(()) => ok(match tag {
Some(t) => format!("Schlagwort-Seite #{t} im Panel."),
None => "Archiv-Übersicht im Panel.".to_string(),
}),
Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")),
}
}
/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei
/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude.
fn call_search(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let home = match crate::domain::archive::require_archive_home(&project) {
Ok(home) => home,
Err(e) => return err(e),
};
let args = &req["params"]["arguments"];
let query = args["query"].as_str().unwrap_or("");
let tag = args["tag"].as_str();
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) {
Ok(hits) => hits,
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
};
let search_path = match env_path("AI_CONTROL_SEARCH") {
Ok(path) => path,
Err(e) => return e,
};
let payload = json!({
"query": query,
"tag": tag,
"home": home.display().to_string(),
"hits": hits,
});
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
return err(format!("Suchtreffer-Datei nicht schreibbar: {e}"));
}
let list: Vec<String> = hits
.iter()
.map(|h| format!("- {}{} ({})", h.relpath, h.title, h.snippet))
.collect();
ok(format!(
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}",
hits.len(),
home.display(),
list.join("\n")
))
}