claude-desktop/src-tauri/src/claude.rs
Eddy 3d84feab6f
All checks were successful
Build AppImage / build (push) Has been skipped
feat: Phase 3 Performance — KB-Cache, Bridge-Warmstart, maxTurns 200
- KB-Cache im RAM: Suchergebnisse 60s gecacht, kein MySQL-Roundtrip pro Nachricht
- Bridge wird beim App-Start sofort gestartet (kein Cold-Start bei erster Nachricht)
- Bridge-Start-Wait von 500ms auf 200ms reduziert
- maxTurns von 25 auf 200 erhoeht (verhindert "maximum turns reached" bei komplexen Tasks)
- invalidate_kb_cache Command fuer manuelles Cache-Leeren

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:02:23 +02:00

744 lines
27 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
use crate::knowledge;
/// 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<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>,
#[allow(dead_code)]
error: Option<String>,
}
/// Globaler State für die Bridge
pub struct ClaudeState {
pub bridge_process: Option<std::process::Child>,
pub bridge_stdin: Option<std::process::ChildStdin>,
pub request_counter: u64,
pub agents: Vec<AgentStatus>,
}
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 — 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"),
];
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::<Arc<Mutex<ClaudeState>>>();
{
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::<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", ());
// Gespeicherten Agent-Modus an Bridge senden (falls vorhanden)
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
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);
}
}
}
}
"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" => {
// 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::<Arc<Mutex<db::Database>>>() {
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::<Arc<Mutex<db::Database>>>() {
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::<Arc<Mutex<ClaudeState>>>();
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<String, String> {
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<String>,
resume_session_id: Option<String>,
) -> 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);
// 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<String, String> {
println!("📨 Nachricht empfangen: {}", &message[..message.len().min(50)]);
// 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()
};
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<String> {
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
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<String> {
use crate::context;
// Versuche Context zu laden
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
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::<context::CredentialHint>(&value) {
sticky.active_credentials.push(cred);
}
}
k if k.starts_with("project:") => {
if let Ok(proj) = serde_json::from_str::<context::ProjectInfo>(&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<Vec<AgentStatus>, String> {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
let state = state.lock().unwrap();
Ok(state.agents.clone())
}
/// Modell wechseln
#[tauri::command]
pub async fn set_model(app: AppHandle, model: String) -> Result<String, String> {
println!("🔄 Modell wechseln zu: {}", model);
// Modell in Settings speichern
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
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::<Arc<Mutex<ClaudeState>>>();
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(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<Vec<ModelInfo>, 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<String, String> {
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
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<String, String> {
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::<Arc<Mutex<crate::db::Database>>>() {
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::<Arc<Mutex<ClaudeState>>>();
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(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<String, String> {
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
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<StickyContextInfo, String> {
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::<Arc<Mutex<crate::db::Database>>>() {
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::<Arc<Mutex<crate::db::Database>>>() {
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::<Arc<Mutex<ClaudeState>>>();
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 (inkl. proaktive Hints)", info.entries, info.estimated_tokens);
} else {
println!(" Kein Sticky Context konfiguriert");
}
Ok(info)
}