Phase 2: Claude SDK Integration + Event-System

- 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 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-13 14:43:34 +02:00
parent 2822796c7a
commit 5003fb9996
7 changed files with 673 additions and 25 deletions

View file

@ -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' });

View file

@ -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<String>,
pub input: Option<serde_json::Value>,
pub output: Option<String>,
pub success: Option<bool>,
}
/// Agent Event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentEvent {
pub id: String,
#[serde(rename = "type")]
pub agent_type: Option<String>,
pub task: Option<String>,
pub code: Option<i32>,
}
/// Nachricht von der Bridge
#[derive(Debug, Clone, Deserialize)]
struct BridgeMessage {
#[serde(rename = "type")]
msg_type: String,
event: Option<String>,
payload: Option<serde_json::Value>,
id: Option<String>,
result: Option<serde_json::Value>,
error: Option<String>,
}
/// Globaler State für die Bridge
pub struct ClaudeState {
pub bridge_stdin: Option<std::process::ChildStdin>,
pub request_counter: u64,
pub agents: Vec<AgentStatus>,
}
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::<Arc<Mutex<ClaudeState>>>();
{
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::<BridgeMessage>(&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::<AgentEvent>(payload.clone()) {
println!("🤖 Agent gestartet: {}", agent.id);
let _ = app.emit("agent-started", &payload);
// Agent zur Liste hinzufügen
let state = app.state::<Arc<Mutex<ClaudeState>>>();
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::<AgentEvent>(payload.clone()) {
println!("⏹️ Agent gestoppt: {}", agent.id);
let _ = app.emit("agent-stopped", &payload);
// Agent aus Liste entfernen
let state = app.state::<Arc<Mutex<ClaudeState>>>();
let mut state = state.lock().unwrap();
state.agents.retain(|a| a.id != agent.id);
}
}
"tool-start" => {
if let Ok(tool) = serde_json::from_value::<ToolEvent>(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::<ToolEvent>(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::<Arc<Mutex<ClaudeState>>>();
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<String, String> {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
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<String, String> {
pub async fn send_message(app: AppHandle, message: String) -> Result<String, String> {
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::<Arc<Mutex<ClaudeState>>>();
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<Vec<AgentStatus>, String> {
// TODO: Echte Agent-Daten zurückgeben
Ok(vec![])
pub async fn get_agent_status(app: AppHandle) -> Result<Vec<AgentStatus>, String> {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
let state = state.lock().unwrap();
Ok(state.agents.clone())
}

View file

@ -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!())

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { messages, currentInput, isProcessing, addMessage } from '$lib/stores/app';
async function sendMessage() {
@ -10,12 +11,15 @@
$currentInput = '';
$isProcessing = true;
// TODO: An Claude senden via Tauri
// Placeholder-Antwort
setTimeout(() => {
addMessage('assistant', 'Ich bin noch nicht mit dem Claude SDK verbunden. Die Integration folgt in Phase 1.3.');
try {
// An Claude senden via Tauri
await invoke('send_message', { message: text });
// Antwort kommt über Events (claude-text, agent-stopped)
} catch (err) {
console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}, 1000);
}
}
function handleKeydown(event: KeyboardEvent) {
@ -39,10 +43,16 @@
</div>
{:else}
{#each $messages as message}
<div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'}>
<div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'}>
<div class="message-header">
<span class="message-role">
{message.role === 'user' ? '👤 Du' : '🤖 Claude'}
{#if message.role === 'user'}
👤 Du
{:else if message.role === 'assistant'}
🤖 Claude
{:else}
⚙️ System
{/if}
</span>
<span class="message-time">
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
@ -144,6 +154,11 @@
margin-right: var(--spacing-xl);
}
.message.system {
background: rgba(233, 69, 96, 0.1);
border-left: 3px solid var(--accent);
}
.message-header {
display: flex;
justify-content: space-between;

198
src/lib/stores/events.ts Normal file
View file

@ -0,0 +1,198 @@
// Claude Desktop — Event-Bridge
// Empfängt Events vom Tauri-Backend und aktualisiert die Stores
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import {
agents,
toolCalls,
messages,
isProcessing,
addMessage,
addAgent,
updateAgentStatus,
addToolCall,
completeToolCall,
clearAll
} from './app';
// Event-Typen vom Backend
interface AgentEvent {
id: string;
type?: string;
task?: string;
code?: number;
}
interface ToolEvent {
id: string;
tool?: string;
input?: Record<string, unknown>;
output?: string;
success?: boolean;
}
interface TextEvent {
text: string;
}
interface ResultEvent {
cost?: number;
tokens?: {
input: number;
output: number;
};
}
// Listener-Handles
let listeners: UnlistenFn[] = [];
// Aktuelle Nachricht (wird während Streaming aufgebaut)
let currentResponseText = '';
let currentResponseAgentId: string | null = null;
// Events initialisieren
export async function initEventListeners(): Promise<void> {
console.log('🎧 Initialisiere Event-Listener...');
// Aufräumen falls bereits initialisiert
await cleanupEventListeners();
// Bridge bereit
listeners.push(
await listen('bridge-ready', () => {
console.log('✅ Bridge bereit');
})
);
// Agent gestartet
listeners.push(
await listen<AgentEvent>('agent-started', (event) => {
const { id, type, task } = event.payload;
console.log('🤖 Agent gestartet:', id, type);
const agentType = mapAgentType(type || 'main');
addAgent(agentType, task || 'Verarbeite...');
currentResponseAgentId = id;
isProcessing.set(true);
})
);
// Agent gestoppt
listeners.push(
await listen<AgentEvent>('agent-stopped', (event) => {
const { id } = event.payload;
console.log('⏹️ Agent gestoppt:', id);
updateAgentStatus(id, 'stopped');
// Falls das der Haupt-Agent war, Antwort finalisieren
if (currentResponseAgentId === id && currentResponseText) {
addMessage('assistant', currentResponseText, id);
currentResponseText = '';
currentResponseAgentId = null;
}
// Prüfen ob noch Agents aktiv
agents.update((ags) => {
const stillActive = ags.some((a) => a.status === 'active');
if (!stillActive) {
isProcessing.set(false);
}
return ags;
});
})
);
// Alle Agents gestoppt
listeners.push(
await listen('all-stopped', () => {
console.log('⏹️ Alle Agents gestoppt');
agents.update((ags) => ags.map((a) => ({ ...a, status: 'stopped' as const })));
isProcessing.set(false);
if (currentResponseText) {
addMessage('assistant', currentResponseText);
currentResponseText = '';
}
})
);
// Tool Start
listeners.push(
await listen<ToolEvent>('tool-start', (event) => {
const { id, tool, input } = event.payload;
console.log('🔧 Tool Start:', tool);
// Dem aktiven Haupt-Agent zuordnen
agents.update((ags) => {
const activeAgent = ags.find((a) => a.status === 'active');
if (activeAgent) {
addToolCall(activeAgent.id, tool || 'unknown', input || {});
}
return ags;
});
})
);
// Tool Ende
listeners.push(
await listen<ToolEvent>('tool-end', (event) => {
const { id, success, output } = event.payload;
console.log('✅ Tool Ende:', id, success ? 'OK' : 'FEHLER');
completeToolCall(id, output, !success);
})
);
// Text-Streaming
listeners.push(
await listen<TextEvent>('claude-text', (event) => {
const { text } = event.payload;
currentResponseText += text;
})
);
// Ergebnis (Kosten, Token)
listeners.push(
await listen<ResultEvent>('claude-result', (event) => {
const { cost, tokens } = event.payload;
console.log('📊 Ergebnis:', {
cost: cost ? `$${cost.toFixed(4)}` : 'unbekannt',
tokens
});
})
);
// Agents gestoppt (vom STOPP-Button)
listeners.push(
await listen('agents-stopped', () => {
console.log('🛑 STOPP-Signal empfangen');
clearAll();
})
);
console.log('✅ Event-Listener initialisiert');
}
// Listener aufräumen
export async function cleanupEventListeners(): Promise<void> {
for (const unlisten of listeners) {
unlisten();
}
listeners = [];
}
// Agent-Typ mappen
function mapAgentType(type: string): 'main' | 'explore' | 'plan' | 'bash' {
const typeMap: Record<string, 'main' | 'explore' | 'plan' | 'bash'> = {
main: 'main',
'Main Agent': 'main',
explore: 'explore',
Explore: 'explore',
plan: 'plan',
Plan: 'plan',
bash: 'bash',
Bash: 'bash'
};
return typeMap[type] || 'main';
}

View file

@ -1,2 +1,3 @@
// Stores re-export
export * from './app';
export * from './events';

View file

@ -1,12 +1,27 @@
<script lang="ts">
import '../app.css';
import { isProcessing, agentCount } from '$lib/stores/app';
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { isProcessing, agentCount, initEventListeners, cleanupEventListeners } from '$lib/stores';
import StopButton from '$lib/components/StopButton.svelte';
// Events beim Laden initialisieren
onMount(async () => {
await initEventListeners();
});
onDestroy(async () => {
await cleanupEventListeners();
});
// STOPP-Funktion
async function handleStop() {
console.log('STOPP gedrückt — breche alle Agents ab');
// TODO: Tauri-Command zum Abbrechen aufrufen
try {
await invoke('stop_all_agents');
} catch (err) {
console.error('Fehler beim Stoppen:', err);
}
$isProcessing = false;
}