// Claude Desktop — Claude SDK Integration // Kommunikation mit Claude Code via Node.js Child-Process oder Unix Domain Socket // // Modi: // 1. UDS-Daemon: Bridge läuft als eigenständiger Prozess, App verbindet sich über Socket // 2. stdio (Fallback): Bridge als Child-Process mit stdin/stdout (wie bisher) use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, Manager}; #[cfg(unix)] use std::os::unix::net::UnixStream; use crate::db; use crate::knowledge; /// Standard-Pfade für UDS-Daemon const SOCKET_PATH: &str = "/tmp/claude-bridge.sock"; const PID_PATH: &str = "/tmp/claude-bridge.pid"; /// Status eines Agents #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStatus { pub id: String, pub agent_type: String, pub status: String, pub task: String, pub tool_calls: u32, } /// Tool-Aufruf Event #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolEvent { pub id: String, pub tool: Option, pub input: Option, pub output: Option, pub success: Option, } /// Agent Event #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentEvent { pub id: String, #[serde(rename = "type")] pub agent_type: Option, pub task: Option, pub code: Option, } /// Nachricht von der Bridge #[derive(Debug, Clone, Deserialize)] struct BridgeMessage { #[serde(rename = "type")] msg_type: String, event: Option, payload: Option, id: Option, result: Option, #[allow(dead_code)] error: Option, } /// IPC-Modus der Bridge-Verbindung #[derive(Debug)] pub enum BridgeConnection { /// stdio: Bridge ist ein Child-Process Stdio { #[allow(dead_code)] // process-Handle muss am Leben bleiben! process: std::process::Child, stdin: std::process::ChildStdin, }, /// UDS: Bridge ist ein Daemon, Verbindung über Unix Socket #[cfg(unix)] Uds { stream: UnixStream, daemon_pid: Option, }, } /// Globaler State für die Bridge pub struct ClaudeState { pub connection: Option, pub request_counter: u64, pub agents: Vec, } impl Default for ClaudeState { fn default() -> Self { Self { connection: None, request_counter: 0, agents: vec![], } } } impl ClaudeState { /// Prüft ob eine aktive Verbindung besteht pub fn is_connected(&self) -> bool { self.connection.is_some() } /// Schreibt eine Zeile an die Bridge (JSON-Line) pub fn write_line(&mut self, line: &str) -> Result<(), String> { match &mut self.connection { Some(BridgeConnection::Stdio { stdin, .. }) => { writeln!(stdin, "{}", line).map_err(|e| e.to_string())?; stdin.flush().map_err(|e| e.to_string())?; Ok(()) } #[cfg(unix)] Some(BridgeConnection::Uds { stream, .. }) => { writeln!(stream, "{}", line).map_err(|e| e.to_string())?; stream.flush().map_err(|e| e.to_string())?; Ok(()) } None => Err("Bridge nicht verbunden".to_string()), } } } /// Script-Pfad der Bridge ermitteln fn find_bridge_script() -> Result { let exe_dir = std::env::current_exe() .map_err(|e| e.to_string())? .parent() .ok_or("Kein Parent-Verzeichnis")? .to_path_buf(); // Script in mehreren Pfaden suchen — Reihenfolge wichtig! // 1. bin/../scripts/ → Nix-Wrapper-Layout (~/.local/share/claude-desktop/scripts/) // 2. bin/scripts/ → Bundle-neben-Binary (AppImage extrahiert / alte Konvention) // 3. Cargo-Manifest → Entwicklungs-Build direkt aus dem Repo // 4. CWD/scripts/ → Fallback falls aus Projektverzeichnis gestartet let parent_dir = exe_dir.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe_dir.clone()); let candidates = vec![ parent_dir.join("scripts").join("claude-bridge.js"), exe_dir.join("scripts").join("claude-bridge.js"), std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../scripts/claude-bridge.js"), std::env::current_dir().unwrap_or_default().join("scripts/claude-bridge.js"), ]; candidates.iter() .find(|p| p.exists()) .cloned() .ok_or_else(|| format!("claude-bridge.js nicht gefunden. Gesucht in: {:?}", candidates)) } /// Prüft ob ein Daemon-Prozess noch lebt #[cfg(unix)] fn is_daemon_alive() -> Option { let pid_path = std::path::Path::new(PID_PATH); if !pid_path.exists() { return None; } let pid_str = std::fs::read_to_string(pid_path).ok()?; let pid: u32 = pid_str.trim().parse().ok()?; // Signal 0 prüft ob Prozess existiert, ohne ihn zu beeinflussen let alive = unsafe { libc::kill(pid as i32, 0) == 0 }; if alive { Some(pid) } else { None } } /// Startet Bridge-Daemon als eigenständigen Prozess (überlebt App-Neustart) #[cfg(unix)] fn start_daemon(script_path: &std::path::Path) -> Result { let project_dir = script_path.parent() .and_then(|p| p.parent()) .unwrap_or_else(|| std::path::Path::new(".")); println!("🔌 Starte Bridge-Daemon: {:?} --socket {}", script_path, SOCKET_PATH); // --max-old-space-size=4096: Node-Default (~2GB) reicht nicht bei langen Sessions // mit großen Thinking-Blocks/Agent-SDK-History (KB #crash-oom-stacktrace). let child = Command::new("node") .arg("--max-old-space-size=4096") .arg(script_path) .arg("--socket") .arg(SOCKET_PATH) .current_dir(project_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Bridge-Daemon konnte nicht gestartet werden: {}", e))?; let pid = child.id(); // Stderr in separatem Thread lesen (Daemon-Logs) if let Some(stderr) = child.stderr { std::thread::spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { println!("🔌 Daemon: {}", line); } }); } // Kurz warten bis Socket-Datei erstellt wird for _ in 0..20 { if std::path::Path::new(SOCKET_PATH).exists() { println!("✅ Bridge-Daemon gestartet (PID: {})", pid); return Ok(pid); } std::thread::sleep(std::time::Duration::from_millis(100)); } Err(format!("Bridge-Daemon gestartet (PID: {}), aber Socket {} nicht erstellt", pid, SOCKET_PATH)) } /// Verbindet sich mit dem UDS-Daemon und startet Reader-Thread #[cfg(unix)] fn connect_uds(app: &AppHandle, daemon_pid: Option) -> Result<(), String> { let stream = UnixStream::connect(SOCKET_PATH) .map_err(|e| format!("UDS-Verbindung fehlgeschlagen: {}", e))?; // Reader-Stream klonen für den Lese-Thread let reader_stream = stream.try_clone() .map_err(|e| format!("UDS-Stream klonen fehlgeschlagen: {}", e))?; // Verbindung speichern let state = app.state::>>(); { let mut state = state.lock().unwrap(); state.connection = Some(BridgeConnection::Uds { stream, daemon_pid, }); } // Reader-Thread: JSON-Lines vom Socket lesen let app_handle = app.clone(); let state_for_reconnect = app.state::>>().inner().clone(); std::thread::spawn(move || { let reader = BufReader::new(reader_stream); for line in reader.lines().map_while(Result::ok) { match serde_json::from_str::(&line) { Ok(msg) => handle_bridge_message(&app_handle, msg), Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {} — {}", e, &line[..line.len().min(100)]), } } println!("⚠️ UDS-Verbindung getrennt — versuche Reconnect..."); // Verbindung als geschlossen markieren { let mut state = state_for_reconnect.lock().unwrap(); state.connection = None; } // Automatischer Reconnect (3 Versuche) for attempt in 1..=3 { std::thread::sleep(std::time::Duration::from_secs(attempt)); if std::path::Path::new(SOCKET_PATH).exists() { println!("🔄 UDS Reconnect Versuch {}/3...", attempt); match connect_uds(&app_handle, daemon_pid) { Ok(()) => { println!("✅ UDS Reconnect erfolgreich"); let _ = app_handle.emit("bridge-ready", ()); return; } Err(e) => println!("⚠️ UDS Reconnect fehlgeschlagen: {}", e), } } } println!("❌ UDS Reconnect endgültig fehlgeschlagen"); let _ = app_handle.emit("bridge-disconnected", ()); }); println!("✅ UDS-Verbindung hergestellt"); Ok(()) } /// Bridge starten — versucht erst UDS-Daemon, dann stdio-Fallback pub fn start_bridge(app: &AppHandle) -> Result<(), String> { // Smart Hints v2: Session-Topic zurücksetzen bei neuer Bridge/Session knowledge::reset_session_topic(); // Bereits verbunden? { let state = app.state::>>(); let state_guard = state.lock().unwrap(); if state_guard.is_connected() { println!("🔌 Bridge bereits verbunden"); return Ok(()); } } let script_path = find_bridge_script()?; // ---- UDS-Daemon-Modus (bevorzugt) ---- #[cfg(unix)] { // 1. Läuft schon ein Daemon? if let Some(pid) = is_daemon_alive() { println!("🔌 Existierender Bridge-Daemon gefunden (PID: {})", pid); if let Ok(()) = connect_uds(app, Some(pid)) { return Ok(()); } println!("⚠️ Verbindung zu bestehendem Daemon fehlgeschlagen, starte neu..."); // Alten Daemon killen unsafe { libc::kill(pid as i32, libc::SIGTERM); } std::thread::sleep(std::time::Duration::from_millis(500)); } // 2. Neuen Daemon starten match start_daemon(&script_path) { Ok(pid) => { match connect_uds(app, Some(pid)) { Ok(()) => return Ok(()), Err(e) => println!("⚠️ UDS-Verbindung nach Daemon-Start fehlgeschlagen: {} — Fallback auf stdio", e), } } Err(e) => println!("⚠️ Daemon-Start fehlgeschlagen: {} — Fallback auf stdio", e), } } // ---- stdio-Fallback (Kompatibilität) ---- start_bridge_stdio(app, &script_path) } /// Bridge im stdio-Modus starten (Child-Process, wie bisher) fn start_bridge_stdio(app: &AppHandle, script_path: &std::path::Path) -> Result<(), String> { let project_dir = script_path.parent() .and_then(|p| p.parent()) .unwrap_or_else(|| std::path::Path::new(".")); println!("🔌 Starte Claude Bridge (stdio): {:?}", script_path); println!("📂 Bridge Arbeitsverzeichnis: {:?}", project_dir); let mut child = Command::new("node") .arg("--max-old-space-size=4096") .arg(script_path) .current_dir(project_dir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Bridge konnte nicht gestartet werden: {}", e))?; let stdin = child.stdin.take().ok_or("Kein stdin verfügbar")?; let stdout = child.stdout.take().ok_or("Kein stdout verfügbar")?; let stderr = child.stderr.take(); // State speichern — child MUSS am Leben bleiben! let state = app.state::>>(); { let mut state = state.lock().unwrap(); state.connection = Some(BridgeConnection::Stdio { process: child, stdin, }); } // Stderr in separatem Thread lesen und loggen if let Some(stderr) = stderr { std::thread::spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { println!("🔌 Bridge stderr: {}", line); } }); } // Stdout in separatem Thread lesen let app_handle = app.clone(); std::thread::spawn(move || { let reader = BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) { match serde_json::from_str::(&line) { Ok(msg) => handle_bridge_message(&app_handle, msg), Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {} — {}", e, &line[..line.len().min(100)]), } } println!("⚠️ Bridge stdout geschlossen"); }); Ok(()) } /// Bridge-Nachrichten verarbeiten fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) { match msg.msg_type.as_str() { "event" => { if let (Some(event), Some(payload)) = (msg.event, msg.payload) { match event.as_str() { "ready" => { println!("✅ Claude Bridge bereit"); let _ = app.emit("bridge-ready", ()); // Gespeicherten Agent-Modus an Bridge senden (falls vorhanden) if let Some(db_state) = app.try_state::>>() { let mode = { let db = db_state.lock().unwrap(); db.get_setting("agent_mode").ok().flatten() }; if let Some(mode) = mode { if mode != "solo" { println!("🔄 Restore Agent-Modus: {}", mode); let _ = send_to_bridge(app, "set-mode", &mode); } } } // MCP-Hub: Server-Configs an Bridge senden match send_mcp_configs_to_bridge(app) { Ok(()) => {} Err(e) => println!("⚠️ MCP-Configs senden fehlgeschlagen: {}", e), } } "agent-started" | "subagent-start" => { if let Ok(agent) = serde_json::from_value::(payload.clone()) { println!("🤖 Agent gestartet: {}", agent.id); let _ = app.emit("agent-started", &payload); // Agent zur Liste hinzufügen let state = app.state::>>(); let mut state = state.lock().unwrap(); state.agents.push(AgentStatus { id: agent.id, agent_type: agent.agent_type.unwrap_or_else(|| "Main".to_string()), status: "active".to_string(), task: agent.task.unwrap_or_default(), tool_calls: 0, }); } } "agent-stopped" | "subagent-stop" => { if let Ok(agent) = serde_json::from_value::(payload.clone()) { println!("⏹️ Agent gestoppt: {}", agent.id); let _ = app.emit("agent-stopped", &payload); // Agent aus Liste entfernen let state = app.state::>>(); let mut state = state.lock().unwrap(); state.agents.retain(|a| a.id != agent.id); } } "tool-start" => { if let Ok(tool) = serde_json::from_value::(payload.clone()) { println!("🔧 Tool Start: {} - {:?}", tool.id, tool.tool); let _ = app.emit("tool-start", &payload); } } "tool-end" => { if let Ok(tool) = serde_json::from_value::(payload.clone()) { println!( "✅ Tool Ende: {} - {}", tool.id, if tool.success.unwrap_or(true) { "OK" } else { "FEHLER" } ); let _ = app.emit("tool-end", &payload); } } "text" => { let _ = app.emit("claude-text", &payload); } "result" => { // Session-ID aus Result extrahieren und in DB speichern if let Some(sid) = payload.get("session_id").and_then(|v| v.as_str()) { if let Some(db_state) = app.try_state::>>() { let db_lock = db_state.lock().unwrap(); if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") { if !active_id.is_empty() { if let Ok(Some(mut session)) = db_lock.get_session(&active_id) { // claude_session_id nur beim ersten Mal setzen — // sonst verlieren Folge-Chats den Kontext der Anfangs-History if session.claude_session_id.is_none() { session.claude_session_id = Some(sid.to_string()); } session.message_count += 1; if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) { session.cost_usd += cost; } if let Some(tin) = payload.get("tokens").and_then(|t| t.get("input")).and_then(|v| v.as_i64()) { session.token_input += tin; } if let Some(tout) = payload.get("tokens").and_then(|t| t.get("output")).and_then(|v| v.as_i64()) { session.token_output += tout; } let _ = db_lock.update_session(&session); } } } } } let _ = app.emit("claude-result", &payload); } "session-reset" => { // Bridge meldet: resume-Session war ungueltig ("No conversation // found with session ID"). Stale claude_session_id aus DB // entfernen, sonst laeuft die App beim naechsten Start wieder // in denselben Fehler. if let Some(db_state) = app.try_state::>>() { let db_lock = db_state.lock().unwrap(); if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") { if !active_id.is_empty() { if let Ok(Some(mut session)) = db_lock.get_session(&active_id) { let old = session.claude_session_id.take(); let _ = db_lock.update_session(&session); println!("🧹 Stale claude_session_id geloescht: {:?}", old); } } } } let _ = app.emit("session-reset", &payload); } "all-stopped" => { println!("⏹️ Alle Agents gestoppt"); let _ = app.emit("all-stopped", ()); let state = app.state::>>(); let mut state = state.lock().unwrap(); state.agents.clear(); } other => { // Generische Weiterleitung aller Bridge-Events ans Frontend // (subagent-started, subagent-stopped, monitor-event, mode-changed, // knowledge-hint, auto-mode-chosen, etc.) let _ = app.emit(other, &payload); } } } } "response" => { if let Some(id) = msg.id { println!("📬 Response für {}: {:?}", id, msg.result); // TODO: Über Channel an wartenden Request weitergeben } } _ => {} } } /// Befehl an Bridge senden fn send_to_bridge(app: &AppHandle, command: &str, message: &str) -> Result { send_to_bridge_full(app, command, message, None, None) } /// Befehl an Bridge senden mit Context und Resume-Session-ID fn send_to_bridge_full( app: &AppHandle, command: &str, message: &str, context: Option, resume_session_id: Option, ) -> Result { let state = app.state::>>(); let mut state = state.lock().unwrap(); state.request_counter += 1; let request_id = format!("req-{}", state.request_counter); // Je nach Command unterschiedliche Payload-Struktur let msg = match command { "set-model" => serde_json::json!({ "command": command, "id": request_id, "model": message }), "set-mode" => serde_json::json!({ "command": command, "id": request_id, "mode": message }), "message" => { let mut payload = serde_json::json!({ "command": command, "id": request_id, "message": message }); // Context hinzufügen wenn vorhanden if let Some(ctx) = context { if !ctx.is_empty() { payload["context"] = serde_json::Value::String(ctx); } } // Resume-Session-ID hinzufügen wenn vorhanden if let Some(sid) = resume_session_id { if !sid.is_empty() { payload["resumeSessionId"] = serde_json::Value::String(sid); } } payload }, "set-context" | "clear-context" => serde_json::json!({ "command": command, "id": request_id, "context": message }), _ => serde_json::json!({ "command": command, "id": request_id, "message": message }), }; state.write_line(&msg.to_string())?; Ok(request_id) } // ============ Tauri Commands ============ /// Nachricht an Claude senden #[tauri::command] pub async fn send_message(app: AppHandle, message: String) -> Result { println!("📨 Nachricht empfangen: {}", &message[..message.len().min(50)]); // Bridge starten falls nicht aktiv let needs_start = { let state = app.state::>>(); let state_guard = state.lock().unwrap(); !state_guard.is_connected() }; if needs_start { start_bridge(&app)?; // Kurz warten bis Bridge bereit — parallel den Context laden tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; } // Context aus DB laden (Schicht 1: Sticky Context) let mut context = load_sticky_context_for_prompt(&app); if context.is_some() { println!("📌 Sticky Context geladen (~{} Token)", context.as_ref().map(|c| c.len() / 4).unwrap_or(0)); } // Schicht 2: KB-Hints aus Wissensbasis laden (fehlertolerant) match knowledge::search_knowledge_internal(&message, 5).await { Ok(hints) if !hints.is_empty() => { // Hints an bestehenden Context anhängen oder neuen erstellen let ctx = context.get_or_insert_with(String::new); if !ctx.is_empty() { ctx.push_str("\n\n"); } ctx.push_str(&hints); println!("💡 KB-Hints an Context angehängt (~{} Bytes)", hints.len()); } Ok(_) => { // Keine Treffer — kein Problem } Err(e) => { // DB-Fehler — loggen aber nicht abbrechen println!("⚠️ KB-Hints Fehler (ignoriert): {}", e); } } // Claude-Session-ID für Fortsetzung laden let resume_session_id = load_claude_session_id(&app); if resume_session_id.is_some() { println!("🔗 Session fortsetzen mit Claude-ID: {:?}", resume_session_id); } send_to_bridge_full(&app, "message", &message, context, resume_session_id)?; // Hinweis: Die eigentliche Antwort kommt über Events Ok("Nachricht gesendet. Antwort folgt über Events.".to_string()) } /// Claude-Session-ID der aktiven Session laden fn load_claude_session_id(app: &AppHandle) -> Option { if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().ok()?; if let Ok(Some(session)) = db.get_active_session() { return session.claude_session_id; } } None } /// Sticky Context aus DB laden und als Prompt-Text rendern fn load_sticky_context_for_prompt(app: &AppHandle) -> Option { use crate::context; // Versuche Context zu laden if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().ok()?; // Context-Tabellen erstellen falls nicht vorhanden let _ = db.create_context_tables(); // Sticky Context laden let entries = db.load_sticky_context().ok()?; if entries.is_empty() { return None; } let mut sticky = context::StickyContext::default(); for (key, value, _priority) in entries { match key.as_str() { "user_info" => sticky.user_info = Some(value), k if k.starts_with("cred:") => { if let Ok(cred) = serde_json::from_str::(&value) { sticky.active_credentials.push(cred); } } k if k.starts_with("project:") => { if let Ok(proj) = serde_json::from_str::(&value) { sticky.current_project = Some(proj); } } k if k.starts_with("rule:") => { sticky.critical_rules.push(value); } _ => {} } } // Auto-Load Memory-Einträge anhängen (Persistent Memory) let memory_entries = db.load_memory_entries().unwrap_or_default(); let autoload: Vec<_> = memory_entries.into_iter().filter(|e| e.auto_load).collect(); let mut memory_section = String::new(); if !autoload.is_empty() { memory_section.push_str("\n\n## Persistentes Gedächtnis\n"); for entry in &autoload { memory_section.push_str(&format!("- **{}** ({}): {}\n", entry.key, format!("{:?}", entry.category), entry.value )); } println!("🧠 {} Auto-Load Memory-Einträge in Context injiziert", autoload.len()); } let rendered = sticky.render(); let combined = format!("{}{}", rendered, memory_section); if combined.trim().is_empty() { None } else { Some(combined) } } else { None } } /// Alle Agents stoppen #[tauri::command] pub async fn stop_all_agents(app: AppHandle) -> Result<(), String> { println!("⏹️ STOPP: Alle Agents werden gestoppt"); let _ = send_to_bridge(&app, "stop", ""); app.emit("agents-stopped", ()).map_err(|e| e.to_string())?; Ok(()) } /// Status aller Agents abrufen #[tauri::command] pub async fn get_agent_status(app: AppHandle) -> Result, String> { let state = app.state::>>(); let state = state.lock().unwrap(); Ok(state.agents.clone()) } /// Modell wechseln #[tauri::command] pub async fn set_model(app: AppHandle, model: String) -> Result { println!("🔄 Modell wechseln zu: {}", model); // Modell in Settings speichern if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().unwrap(); let _ = db.set_setting("claude_model", &model); } // Bridge starten falls nicht aktiv let needs_start = { let state = app.state::>>(); let state_guard = state.lock().unwrap(); !state_guard.is_connected() }; if needs_start { start_bridge(&app)?; tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; } // Modell an Bridge senden send_to_bridge(&app, "set-model", &model)?; Ok(model) } /// Verfügbare Modelle abrufen #[tauri::command] pub async fn get_available_models() -> Result, String> { Ok(vec![ ModelInfo { id: "haiku".to_string(), name: "Claude Haiku".to_string(), description: "Schnell & günstig".to_string(), }, ModelInfo { id: "sonnet".to_string(), name: "Claude Sonnet".to_string(), description: "Ausgewogen".to_string(), }, ModelInfo { id: "opus".to_string(), name: "Claude Opus".to_string(), description: "Leistungsstark".to_string(), }, ]) } /// Aktuelles Modell aus Settings laden #[tauri::command] pub async fn get_current_model(app: AppHandle) -> Result { if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().unwrap(); if let Ok(Some(model)) = db.get_setting("claude_model") { return Ok(model); } } // Default Ok("opus".to_string()) } /// Agent-Modus setzen (solo, handlanger, experten, auto) #[tauri::command] pub async fn set_agent_mode(app: AppHandle, mode: String) -> Result { let valid_modes = ["solo", "handlanger", "experten", "auto"]; if !valid_modes.contains(&mode.as_str()) { return Err(format!("Ungültiger Modus: {}. Verfügbar: {}", mode, valid_modes.join(", "))); } println!("🔄 Agent-Modus wechseln zu: {}", mode); // Modus in Settings speichern if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().unwrap(); let _ = db.set_setting("agent_mode", &mode); } // Bridge starten falls nicht aktiv let needs_start = { let state = app.state::>>(); let state_guard = state.lock().unwrap(); !state_guard.is_connected() }; if needs_start { start_bridge(&app)?; tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; } // Modus an Bridge senden send_to_bridge(&app, "set-mode", &mode)?; Ok(mode) } /// Aktuellen Agent-Modus aus Settings laden #[tauri::command] pub async fn get_agent_mode(app: AppHandle) -> Result { if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().unwrap(); if let Ok(Some(mode)) = db.get_setting("agent_mode") { return Ok(mode); } } // Default: solo Ok("solo".to_string()) } /// Modell-Info Struct #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ModelInfo { pub id: String, pub name: String, pub description: String, } /// Sticky Context Initialisierungs-Info #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct StickyContextInfo { pub loaded: bool, pub entries: usize, pub estimated_tokens: usize, pub has_user_info: bool, pub has_project: bool, pub credentials_count: usize, pub rules_count: usize, } /// Sticky Context beim App-Start initialisieren /// Lädt den Context aus der DB und sendet ihn an die Bridge #[tauri::command] pub async fn init_sticky_context(app: AppHandle) -> Result { println!("📌 Initialisiere Sticky Context..."); // Context aus DB laden let context = load_sticky_context_for_prompt(&app); let mut info = StickyContextInfo { loaded: false, entries: 0, estimated_tokens: 0, has_user_info: false, has_project: false, credentials_count: 0, rules_count: 0, }; // Details aus DB laden für Info if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().unwrap(); let _ = db.create_context_tables(); if let Ok(entries) = db.load_sticky_context() { info.entries = entries.len(); for (key, _value, _priority) in &entries { match key.as_str() { "user_info" => info.has_user_info = true, k if k.starts_with("cred:") => info.credentials_count += 1, k if k.starts_with("project:") => info.has_project = true, k if k.starts_with("rule:") => info.rules_count += 1, _ => {} } } } } // Phase 2.0: Proaktive KB-Hints bei Session-Start laden // Projekt-Name aus Sticky Context extrahieren falls vorhanden let project_name = if let Some(db_state) = app.try_state::>>() { let db = db_state.lock().unwrap(); db.get_setting("current_project_name").ok().flatten() } else { None }; let proactive_hints = match knowledge::proactive_session_hints(project_name.as_deref()).await { Ok(hints) if !hints.is_empty() => { println!("📋 Proaktive Session-Hints: {} Bytes", hints.len()); Some(hints) } Ok(_) => None, Err(e) => { println!("⚠️ Proaktive Hints Fehler (ignoriert): {}", e); None } }; // Context + proaktive Hints kombinieren let mut full_context = context.clone(); if let Some(hints) = proactive_hints { let ctx = full_context.get_or_insert_with(String::new); if !ctx.is_empty() { ctx.push_str("\n\n"); } ctx.push_str(&hints); } if let Some(ref ctx) = full_context { info.loaded = true; info.estimated_tokens = ctx.len() / 4; // Bridge starten falls nicht aktiv let needs_start = { let state = app.state::>>(); let state_guard = state.lock().unwrap(); !state_guard.is_connected() }; if needs_start { start_bridge(&app)?; // Kurz warten bis Bridge bereit tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; } // Context an Bridge senden let _ = send_to_bridge(&app, "set-context", ctx); println!("✅ Sticky Context geladen: {} Einträge, ~{} Token (inkl. proaktive Hints)", info.entries, info.estimated_tokens); } else { println!("ℹ️ Kein Sticky Context konfiguriert"); } Ok(info) } // ============ MCP-Hub ============ /// MCP-Server Info (für UI-Anzeige) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpServerInfo { pub name: String, #[serde(rename = "type")] pub server_type: String, pub command: String, pub args: Vec, pub env: std::collections::HashMap, } /// MCP-Server Configs aus ~/.claude.json laden fn load_mcp_configs() -> Result { let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?; let config_path = std::path::PathBuf::from(&home).join(".claude.json"); if !config_path.exists() { return Ok(serde_json::json!({})); } let content = std::fs::read_to_string(&config_path) .map_err(|e| format!("~/.claude.json lesen fehlgeschlagen: {}", e))?; let config: serde_json::Value = serde_json::from_str(&content) .map_err(|e| format!("~/.claude.json parsen fehlgeschlagen: {}", e))?; Ok(config.get("mcpServers").cloned().unwrap_or(serde_json::json!({}))) } /// MCP-Configs an die Bridge senden (nach Bridge-Start) fn send_mcp_configs_to_bridge(app: &AppHandle) -> Result<(), String> { let configs = load_mcp_configs()?; if configs.as_object().map_or(true, |o| o.is_empty()) { println!("ℹ️ Keine MCP-Server in ~/.claude.json konfiguriert"); return Ok(()); } let server_names: Vec<&str> = configs.as_object() .map(|o| o.keys().map(|k| k.as_str()).collect()) .unwrap_or_default(); println!("🔌 MCP-Hub: {} Server gefunden: {}", server_names.len(), server_names.join(", ")); let state = app.state::>>(); let mut state = state.lock().unwrap(); state.request_counter += 1; let request_id = format!("req-{}", state.request_counter); let msg = serde_json::json!({ "command": "set-mcp-servers", "id": request_id, "servers": configs }); state.write_line(&msg.to_string())?; Ok(()) } /// Verfügbare MCP-Server auflisten (für UI) #[tauri::command] pub async fn list_mcp_servers() -> Result, String> { let configs = load_mcp_configs()?; let mut servers = Vec::new(); if let Some(obj) = configs.as_object() { for (name, config) in obj { servers.push(McpServerInfo { name: name.clone(), server_type: config.get("type").and_then(|v| v.as_str()).unwrap_or("stdio").to_string(), command: config.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(), args: config.get("args") .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(), env: config.get("env") .and_then(|v| v.as_object()) .map(|o| o.iter() .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) .collect()) .unwrap_or_default(), }); } } Ok(servers) } /// MCP-Server hinzufügen (in ~/.claude.json schreiben) #[tauri::command] pub async fn add_mcp_server( app: AppHandle, name: String, command: String, args: Vec, env: std::collections::HashMap, ) -> Result { let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?; let config_path = std::path::PathBuf::from(&home).join(".claude.json"); // Bestehende Config laden let mut config: serde_json::Value = if config_path.exists() { let content = std::fs::read_to_string(&config_path) .map_err(|e| format!("Lesen fehlgeschlagen: {}", e))?; serde_json::from_str(&content).unwrap_or(serde_json::json!({})) } else { serde_json::json!({}) }; // MCP-Server hinzufügen let servers = config.as_object_mut() .ok_or("Config ist kein Objekt")? .entry("mcpServers") .or_insert(serde_json::json!({})); servers[&name] = serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env }); // Zurückschreiben std::fs::write(&config_path, serde_json::to_string_pretty(&config).unwrap()) .map_err(|e| format!("Schreiben fehlgeschlagen: {}", e))?; println!("✅ MCP-Server '{}' hinzugefügt", name); // Aktualisierte Configs an Bridge senden let _ = send_mcp_configs_to_bridge(&app); Ok(format!("MCP-Server '{}' hinzugefügt", name)) } /// MCP-Server entfernen #[tauri::command] pub async fn remove_mcp_server(app: AppHandle, name: String) -> Result { let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?; let config_path = std::path::PathBuf::from(&home).join(".claude.json"); if !config_path.exists() { return Err("~/.claude.json nicht vorhanden".to_string()); } let content = std::fs::read_to_string(&config_path) .map_err(|e| format!("Lesen fehlgeschlagen: {}", e))?; let mut config: serde_json::Value = serde_json::from_str(&content) .map_err(|e| format!("Parsen fehlgeschlagen: {}", e))?; // Server entfernen if let Some(servers) = config.get_mut("mcpServers").and_then(|v| v.as_object_mut()) { if servers.remove(&name).is_some() { std::fs::write(&config_path, serde_json::to_string_pretty(&config).unwrap()) .map_err(|e| format!("Schreiben fehlgeschlagen: {}", e))?; println!("🗑️ MCP-Server '{}' entfernt", name); let _ = send_mcp_configs_to_bridge(&app); return Ok(format!("MCP-Server '{}' entfernt", name)); } } Err(format!("MCP-Server '{}' nicht gefunden", name)) } /// Lokale Abfrage über die Bridge an Ollama senden #[tauri::command] pub async fn local_query(app: AppHandle, message: String) -> Result { // Bridge muss verbunden sein let needs_start = { let state = app.state::>>(); let state_guard = state.lock().unwrap(); !state_guard.is_connected() }; if needs_start { start_bridge(&app)?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; } let state = app.state::>>(); let mut state = state.lock().unwrap(); state.request_counter += 1; let request_id = format!("req-{}", state.request_counter); let msg = serde_json::json!({ "command": "local-query", "id": request_id, "message": message }); state.write_line(&msg.to_string())?; Ok(format!("Lokale Abfrage gesendet ({})", request_id)) } /// Ollama-Konfiguration setzen #[tauri::command] pub async fn set_ollama_config(app: AppHandle, endpoint: Option, model: Option) -> Result { let state = app.state::>>(); let mut state = state.lock().unwrap(); state.request_counter += 1; let request_id = format!("req-{}", state.request_counter); let msg = serde_json::json!({ "command": "set-ollama-config", "id": request_id, "endpoint": endpoint, "model": model }); state.write_line(&msg.to_string())?; Ok("Ollama-Config aktualisiert".to_string()) } /// Bridge-Verbindungsstatus abfragen #[derive(Debug, Clone, serde::Serialize)] pub struct BridgeStatus { pub connected: bool, pub mode: String, // "uds" | "stdio" | "disconnected" pub daemon_pid: Option, pub socket_path: String, } #[tauri::command] pub async fn get_bridge_status(app: AppHandle) -> Result { let state = app.state::>>(); let state = state.lock().unwrap(); let (connected, mode, daemon_pid) = match &state.connection { Some(BridgeConnection::Stdio { .. }) => (true, "stdio".to_string(), None), #[cfg(unix)] Some(BridgeConnection::Uds { daemon_pid, .. }) => (true, "uds".to_string(), *daemon_pid), None => (false, "disconnected".to_string(), None), }; Ok(BridgeStatus { connected, mode, daemon_pid, socket_path: SOCKET_PATH.to_string(), }) } /// Bridge-Daemon explizit stoppen (z.B. für Neustart oder Debugging) #[tauri::command] pub async fn stop_bridge_daemon(app: AppHandle) -> Result { // Verbindung trennen { let state = app.state::>>(); let mut state = state.lock().unwrap(); state.connection = None; } // Daemon-Prozess killen #[cfg(unix)] { if let Some(pid) = is_daemon_alive() { unsafe { libc::kill(pid as i32, libc::SIGTERM); } println!("🔌 Bridge-Daemon (PID: {}) wird gestoppt", pid); return Ok(format!("Daemon PID {} gestoppt", pid)); } } Ok("Kein aktiver Daemon gefunden".to_string()) }