claude-desktop/src-tauri/src/claude.rs
Eddy 79f4f9fb21
All checks were successful
Build AppImage / build (push) Successful in 8m20s
fix: UTF-8-Crash + Input-Reset + ApprovalBar + Scroll/Streaming-Polish [appimage]
Crash-Fix:
- src/db.rs:801 panickte mit "byte index 240 is not a char boundary"
  mitten in einem -Emoji → SIGABRT. Neues strutil-Modul mit
  safe_truncate()/safe_truncate_ellipsis() (5 Tests grün), an allen
  &s[..N]-Stellen in db/claude/knowledge/session/memory.rs eingebaut.
- update.rs: Stale Lock-Files vom letzten Crash werden jetzt
  protokolliert ("🧹 Stale Lock-Datei aus vorherigem Crash gefunden").

Chat-Polish:
- Input-Textfeld wird nach Senden zuverlässig geleert (Store-Reset +
  DOM-Reset + tick — Svelte 5 bind:value mit Auto-Subscription
  aktualisiert sonst nicht synchron).
- ApprovalBar.svelte (NEU): Sticky-Bar überm Input mit klar
  beschrifteten Buttons "Übernehmen"/"Verwerfen" statt mehrdeutigem
  "Behalten/Zurueck". Bleibt sichtbar wenn der Chat scrollt. Klick
  auf Datei-Name scrollt zur Inline-Karte und blinkt sie. Shortcuts
  Ctrl+Enter/Ctrl+Backspace.
- MessageList: Auto-Scroll trackt jetzt auch toolCalls.length und
  Status-Änderungen, plus ResizeObserver am Container. Smooth bei
  kleinen Distanzen, instant bei großen.
- Streaming-Caret: pulsierender Block-Cursor mit Glow-Shadow.
- Tool-Cards: Slide-In-Transition + Shimmer-Animation auf running.
- WorkingIndicator: Verb passt sich an processingPhase an.
2026-04-27 20:55:08 +02:00

1307 lines
48 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 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;
use crate::strutil::safe_truncate;
/// 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<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>,
}
/// 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<u32>,
},
}
/// Globaler State für die Bridge
pub struct ClaudeState {
pub connection: Option<BridgeConnection>,
pub request_counter: u64,
pub agents: Vec<AgentStatus>,
}
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<std::path::PathBuf, String> {
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<u32> {
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<u32, String> {
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<u32>) -> 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::<Arc<Mutex<ClaudeState>>>();
{
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::<Arc<Mutex<ClaudeState>>>().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::<BridgeMessage>(&line) {
Ok(msg) => handle_bridge_message(&app_handle, msg),
Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {}{}", e, safe_truncate(&line, 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::<Arc<Mutex<ClaudeState>>>();
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::<Arc<Mutex<ClaudeState>>>();
{
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::<BridgeMessage>(&line) {
Ok(msg) => handle_bridge_message(&app_handle, msg),
Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {}{}", e, safe_truncate(&line, 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::<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);
}
}
}
// 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::<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();
}
"checkpoint-before" => {
// Datei VOR der Aenderung lesen und als Snapshot speichern
if let Some(file_path) = payload.get("filePath").and_then(|v| v.as_str()) {
let content_before = std::fs::read_to_string(file_path).unwrap_or_default();
let tool_id = payload.get("toolId").and_then(|v| v.as_str()).unwrap_or("");
let tool_name = payload.get("tool").and_then(|v| v.as_str()).unwrap_or("");
// In DB speichern
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
let db_lock = db_state.lock().unwrap();
if let Ok(Some(session_id)) = db_lock.get_setting("active_session_id") {
let _ = db_lock.save_checkpoint(
tool_id,
&session_id,
tool_name,
file_path,
&content_before,
);
println!("📸 Checkpoint: {} ({} Bytes)", file_path, content_before.len());
}
}
}
let _ = app.emit("checkpoint-before", &payload);
}
"checkpoint-after" => {
// Datei NACH der Aenderung lesen und Diff ans Frontend senden
if let Some(file_path) = payload.get("filePath").and_then(|v| v.as_str()) {
let tool_id = payload.get("toolId").and_then(|v| v.as_str()).unwrap_or("");
let content_after = std::fs::read_to_string(file_path).unwrap_or_default();
// content_before aus DB holen
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
let db_lock = db_state.lock().unwrap();
if let Ok(Some(checkpoint)) = db_lock.get_checkpoint(tool_id) {
// Erweitertes Event mit Diff-Daten ans Frontend
let change_event = serde_json::json!({
"toolId": tool_id,
"tool": payload.get("tool"),
"filePath": file_path,
"contentBefore": checkpoint.content_before,
"contentAfter": content_after,
});
let _ = app.emit("file-change", &change_event);
println!("📝 Datei-Aenderung: {}", file_path);
// content_after in Checkpoint updaten
let _ = db_lock.update_checkpoint_after(tool_id, &content_after);
}
}
}
}
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
}),
};
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<String, String> {
println!("📨 Nachricht empfangen: {}", safe_truncate(&message, 50));
// Bridge starten falls nicht aktiv
let needs_start = {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
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<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);
}
_ => {}
}
}
// 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<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.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<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.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<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.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<String>,
pub env: std::collections::HashMap<String, String>,
}
/// MCP-Server Configs aus ~/.claude.json laden
fn load_mcp_configs() -> Result<serde_json::Value, String> {
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::<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": "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<Vec<McpServerInfo>, 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<String>,
env: std::collections::HashMap<String, String>,
) -> Result<String, String> {
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<String, String> {
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<String, String> {
// Bridge muss verbunden sein
let needs_start = {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
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::<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": "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<String>, model: 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);
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<u32>,
pub socket_path: String,
}
#[tauri::command]
pub async fn get_bridge_status(app: AppHandle) -> Result<BridgeStatus, String> {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
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<String, String> {
// Verbindung trennen
{
let state = app.state::<Arc<Mutex<ClaudeState>>>();
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())
}