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:
+298
-37
@@ -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")
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user