From 5003fb9996f54bddedac80f8abb94aa66bdd2c11 Mon Sep 17 00:00:00 2001 From: Eddy Date: Mon, 13 Apr 2026 14:43:34 +0200 Subject: [PATCH] Phase 2: Claude SDK Integration + Event-System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude-bridge.js: Node.js Bridge für Claude Code CLI - claude.rs: Child-Process Management, Event-Verarbeitung - events.ts: Frontend Event-Listener für Tauri-Events - Layout/ChatPanel: Echte Tauri-Commands statt Placeholder Events implementiert: - agent-started/stopped - tool-start/tool-end - claude-text (Streaming) - claude-result (Kosten/Token) Co-Authored-By: Claude Opus 4.5 --- src-tauri/scripts/claude-bridge.js | 193 +++++++++++++++++++++ src-tauri/src/claude.rs | 251 ++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 7 +- src/lib/components/ChatPanel.svelte | 29 +++- src/lib/stores/events.ts | 198 ++++++++++++++++++++++ src/lib/stores/index.ts | 1 + src/routes/+layout.svelte | 19 ++- 7 files changed, 673 insertions(+), 25 deletions(-) create mode 100644 src-tauri/scripts/claude-bridge.js create mode 100644 src/lib/stores/events.ts diff --git a/src-tauri/scripts/claude-bridge.js b/src-tauri/scripts/claude-bridge.js new file mode 100644 index 0000000..7400a47 --- /dev/null +++ b/src-tauri/scripts/claude-bridge.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// Claude Desktop — Bridge zu Claude Code SDK +// Kommuniziert mit Rust-Backend über stdin/stdout (JSON) + +const { spawn } = require('child_process'); +const readline = require('readline'); + +// State +let claudeProcess = null; +let abortController = null; + +// Event an Rust senden +function emit(event, payload) { + const msg = JSON.stringify({ type: 'event', event, payload }); + process.stdout.write(msg + '\n'); +} + +// Antwort an Rust senden +function respond(id, result, error = null) { + const msg = JSON.stringify({ type: 'response', id, result, error }); + process.stdout.write(msg + '\n'); +} + +// Claude Code als Subprocess starten +async function startClaude(message, requestId) { + abortController = new AbortController(); + + emit('agent-started', { + id: 'main', + type: 'Main Agent', + task: message.substring(0, 100) + }); + + try { + // Claude Code CLI aufrufen + claudeProcess = spawn('claude', [ + '--output-format', 'stream-json', + '-p', message + ], { + signal: abortController.signal, + env: { ...process.env, FORCE_COLOR: '0' } + }); + + let fullResponse = ''; + let buffer = ''; + + claudeProcess.stdout.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Unvollständige Zeile behalten + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + handleClaudeEvent(event); + if (event.type === 'assistant' && event.message?.content) { + for (const block of event.message.content) { + if (block.type === 'text') { + fullResponse += block.text; + } + } + } + } catch (e) { + // Kein JSON, ignorieren + } + } + }); + + claudeProcess.stderr.on('data', (data) => { + emit('log', { level: 'error', message: data.toString() }); + }); + + claudeProcess.on('close', (code) => { + emit('agent-stopped', { id: 'main', code }); + respond(requestId, fullResponse || 'Keine Antwort erhalten'); + claudeProcess = null; + abortController = null; + }); + + claudeProcess.on('error', (err) => { + if (err.name === 'AbortError') { + respond(requestId, null, 'Abgebrochen durch Benutzer'); + } else { + respond(requestId, null, err.message); + } + }); + + } catch (err) { + respond(requestId, null, err.message); + } +} + +// Claude SDK Events verarbeiten +function handleClaudeEvent(event) { + switch (event.type) { + case 'tool_use': + emit('tool-start', { + id: event.tool_use_id, + tool: event.name, + input: event.input + }); + break; + + case 'tool_result': + emit('tool-end', { + id: event.tool_use_id, + success: !event.is_error, + output: typeof event.content === 'string' + ? event.content.substring(0, 500) + : JSON.stringify(event.content).substring(0, 500) + }); + break; + + case 'subagent_start': + emit('subagent-start', { + id: event.subagent_id, + type: event.subagent_type, + task: event.prompt?.substring(0, 100) + }); + break; + + case 'subagent_stop': + emit('subagent-stop', { + id: event.subagent_id + }); + break; + + case 'assistant': + if (event.message?.content) { + for (const block of event.message.content) { + if (block.type === 'text') { + emit('text', { text: block.text }); + } + } + } + break; + + case 'result': + emit('result', { + cost: event.cost_usd, + tokens: { + input: event.input_tokens, + output: event.output_tokens + } + }); + break; + } +} + +// Alle Prozesse stoppen +function stopAll() { + if (abortController) { + abortController.abort(); + } + if (claudeProcess) { + claudeProcess.kill('SIGTERM'); + } + emit('all-stopped', {}); +} + +// Stdin lesen +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}); + +rl.on('line', (line) => { + try { + const msg = JSON.parse(line); + + switch (msg.command) { + case 'message': + startClaude(msg.message, msg.id); + break; + case 'stop': + stopAll(); + respond(msg.id, 'stopped'); + break; + case 'ping': + respond(msg.id, 'pong'); + break; + default: + respond(msg.id, null, `Unbekannter Befehl: ${msg.command}`); + } + } catch (e) { + emit('error', { message: e.message }); + } +}); + +// Startup +emit('ready', { version: '0.1.0' }); diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index 02696da..bb9f86f 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -2,7 +2,10 @@ // Kommunikation mit Claude Code via Node.js Child-Process use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, Manager}; /// Status eines Agents #[derive(Debug, Clone, Serialize, Deserialize)] @@ -14,18 +17,238 @@ pub struct AgentStatus { 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, + error: Option, +} + +/// Globaler State für die Bridge +pub struct ClaudeState { + pub bridge_stdin: Option, + pub request_counter: u64, + pub agents: Vec, +} + +impl Default for ClaudeState { + fn default() -> Self { + Self { + 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(); + + let script_path = exe_dir.join("scripts").join("claude-bridge.js"); + let script_path = if script_path.exists() { + script_path + } else { + // Fallback für Entwicklung + std::path::PathBuf::from("scripts/claude-bridge.js") + }; + + println!("🔌 Starte Claude Bridge: {:?}", script_path); + + let mut child = Command::new("node") + .arg(&script_path) + .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")?; + + // State speichern + let state = app.state::>>(); + { + let mut state = state.lock().unwrap(); + state.bridge_stdin = Some(stdin); + } + + // 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" => { + 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 { + 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": 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 { +pub async fn send_message(app: AppHandle, message: String) -> Result { println!("📨 Nachricht empfangen: {}", &message[..message.len().min(50)]); - // TODO: Claude SDK über Node.js Child-Process aufrufen - // Vorläufig: Placeholder-Antwort + // Bridge starten falls nicht aktiv + let needs_start = { + let state = app.state::>>(); + let state_guard = state.lock().unwrap(); + state_guard.bridge_stdin.is_none() + }; - Ok("Claude SDK noch nicht verbunden. Integration folgt in Phase 1.3.".to_string()) + if needs_start { + start_bridge(&app)?; + // Kurz warten bis Bridge bereit + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + + send_to_bridge(&app, "message", &message)?; + + // Hinweis: Die eigentliche Antwort kommt über Events + Ok("Nachricht gesendet. Antwort folgt über Events.".to_string()) } /// Alle Agents stoppen @@ -33,9 +256,7 @@ pub async fn send_message( pub async fn stop_all_agents(app: AppHandle) -> Result<(), String> { println!("⏹️ STOPP: Alle Agents werden gestoppt"); - // TODO: AbortController für alle laufenden Prozesse triggern - - // Event an Frontend senden + let _ = send_to_bridge(&app, "stop", ""); app.emit("agents-stopped", ()).map_err(|e| e.to_string())?; Ok(()) @@ -43,8 +264,8 @@ pub async fn stop_all_agents(app: AppHandle) -> Result<(), String> { /// Status aller Agents abrufen #[tauri::command] -pub async fn get_agent_status() -> Result, String> { - // TODO: Echte Agent-Daten zurückgeben - - Ok(vec![]) +pub async fn get_agent_status(app: AppHandle) -> Result, String> { + let state = app.state::>>(); + let state = state.lock().unwrap(); + Ok(state.agents.clone()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cdde255..e32becd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,19 @@ // Claude Desktop — Tauri Backend // Hauptmodul für die Rust-Seite der App +use std::sync::{Arc, Mutex}; use tauri::Manager; +mod audit; mod claude; mod memory; -mod audit; /// Initialisiert die App #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .manage(Arc::new(Mutex::new(claude::ClaudeState::default()))) .invoke_handler(tauri::generate_handler![ // Claude SDK claude::send_message, @@ -38,6 +40,9 @@ pub fn run() { // TODO: memory::load_memory aufrufen }); + // Bridge optional beim Start starten (oder lazy bei erster Nachricht) + // let _ = claude::start_bridge(&app.handle()); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/src/lib/components/ChatPanel.svelte b/src/lib/components/ChatPanel.svelte index 6878f44..a5b8869 100644 --- a/src/lib/components/ChatPanel.svelte +++ b/src/lib/components/ChatPanel.svelte @@ -1,4 +1,5 @@