All checks were successful
Build AppImage / build (push) Successful in 8m20s
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.
1307 lines
48 KiB
Rust
1307 lines
48 KiB
Rust
// 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())
|
||
}
|