// Claude Desktop — Claude SDK Integration // Kommunikation mit Claude Code via Node.js Child-Process 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}; use crate::db; /// 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, } /// Globaler State für die Bridge pub struct ClaudeState { pub bridge_process: Option, pub bridge_stdin: Option, pub request_counter: u64, pub agents: Vec, } impl Default for ClaudeState { fn default() -> Self { Self { bridge_process: None, bridge_stdin: None, request_counter: 0, agents: vec![], } } } /// Bridge starten pub fn start_bridge(app: &AppHandle) -> Result<(), String> { // Script-Pfad ermitteln 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 let candidates = vec![ exe_dir.join("scripts").join("claude-bridge.js"), // Entwicklung: relativ zum Cargo-Manifest std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../scripts/claude-bridge.js"), // Fallback: CWD std::env::current_dir().unwrap_or_default().join("scripts/claude-bridge.js"), ]; let script_path = candidates.iter() .find(|p| p.exists()) .cloned() .ok_or_else(|| format!("claude-bridge.js nicht gefunden. Gesucht in: {:?}", candidates))?; println!("🔌 Starte Claude Bridge: {:?}", script_path); // Arbeitsverzeichnis = Projektroot (wo node_modules liegt) let project_dir = script_path.parent() .and_then(|p| p.parent()) .unwrap_or_else(|| std::path::Path::new(".")); println!("📂 Bridge Arbeitsverzeichnis: {:?}", project_dir); let mut child = Command::new("node") .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.bridge_process = Some(child); state.bridge_stdin = Some(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) { if let Ok(msg) = serde_json::from_str::(&line) { handle_bridge_message(&app_handle, msg); } } 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", ()); } "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) { 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); } "all-stopped" => { println!("⏹️ Alle Agents gestoppt"); let _ = app.emit("all-stopped", ()); let state = app.state::>>(); let mut state = state.lock().unwrap(); state.agents.clear(); } _ => { println!("📨 Event: {} = {:?}", event, 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 }), }; if let Some(stdin) = &mut state.bridge_stdin { writeln!(stdin, "{}", msg.to_string()).map_err(|e| e.to_string())?; stdin.flush().map_err(|e| e.to_string())?; Ok(request_id) } else { Err("Bridge nicht gestartet".to_string()) } } // ============ 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.bridge_stdin.is_none() }; if needs_start { start_bridge(&app)?; // Kurz warten bis Bridge bereit tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; } // Context aus DB laden (Schicht 1: Sticky Context) let 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)); } // 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); } _ => {} } } let rendered = sticky.render(); if rendered.is_empty() { None } else { Some(rendered) } } 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.bridge_stdin.is_none() }; if needs_start { start_bridge(&app)?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).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.bridge_stdin.is_none() }; if needs_start { start_bridge(&app)?; tokio::time::sleep(tokio::time::Duration::from_millis(500)).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, _ => {} } } } } if let Some(ref ctx) = 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.bridge_stdin.is_none() }; 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", info.entries, info.estimated_tokens); } else { println!("ℹ️ Kein Sticky Context konfiguriert"); } Ok(info) }