diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b657dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +Alle nennenswerten Änderungen an Claude Desktop werden hier dokumentiert. + +Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). + +--- + +## [Unreleased] - 2025-04-20 + +### Hinzugefügt +- **Slash-Command Autocomplete**: `/`-Eingabe im Chat öffnet Dropdown mit allen Commands, Skills und Built-ins (`commands.rs`, `CommandPalette.svelte`) +- **KB-Hints Injection**: Jede Nachricht an Claude bekommt automatisch relevante Wissensbasis-Einträge (`claude.rs`, `knowledge.rs`) +- **Voice-zu-Claude-Pipeline**: Spracheingabe wird transkribiert, an Claude gesendet, Antwort per TTS vorgelesen (`VoicePanel.svelte`) +- **Pattern-Detektion**: Tool-Fehler werden automatisch gegen bekannte Fehler-Patterns geprüft (`events.ts`) +- **Hook-Dispatch**: SessionStart, PreToolUse, PostToolUse feuern echte Events ans Frontend (`hooks.rs`, `events.ts`) +- **Updater Lock-Datei System**: PID-basiertes Locking verhindert parallele Update-Instanzen (`update.rs`) +- **Updater Bestätigungs-Dialog**: User muss Update-Installation bestätigen statt Überraschungs-Restart (`UpdateDialog.svelte`) +- **Updater Graceful Shutdown**: Frontend bekommt 2s Zeit zum State-Speichern vor Restart (`update.rs`, `lib.rs`) +- **Command-Registry**: Scannt `~/.claude/commands/` und `~/.claude/skills/` für Autocomplete (`commands.rs`) + +### Geändert +- SQL-Queries in `knowledge.rs` sortieren jetzt nach `priority DESC` (höchste Priorität zuerst) +- `get_tool_hints()` korrigiert: War fälschlich `priority ASC`, jetzt `DESC` +- `search_knowledge()` filtert jetzt auch nach `status = 'active'` +- `UpdateDialog.svelte` auf Svelte 5 Runes migriert (`$state`, `$effect`, `$derived`) +- `lib.rs`: App-Lifecycle erweitert um Lock-Datei create/remove bei Start/Exit + +### Behoben +- Updater konnte Binary ersetzen während App noch lief (kein Lock, kein Prozess-Check) + +--- + +## [0.1.0] - 2026-04-14 + +### Erstveröffentlichung + +Enthält Phase 1-16 der Roadmap: + +- Tauri 2.0 + SvelteKit 5 App-Grundgerüst +- Claude Agent SDK Integration mit Live-Streaming +- 4-Panel Layout mit 24 UI-Komponenten +- SQLite Persistierung + Session-Management +- Guard-Rails System für kontrollierte OS-Zugriffe +- Claude-DB Integration (Wissensbasis durchsuchen/speichern) +- Intelligentes Context-Management (3-Schichten-Gedächtnis) +- Sprach-Interface (Whisper STT + OpenAI TTS) +- Multi-Agent-Architektur (Solo/Handlanger/Experten-Modi) +- Hook-System für Automatisierung +- VSCodium-Integration (WebSocket-Bridge) +- Programm-Steuerung (D-Bus, Xvfb, Playwright) +- Präsentations- & Schulungsmodus +- System-Monitor mit Performance-Metriken +- Subagent-Hierarchie mit Baumansicht +- CI/CD Pipeline (Forgejo Actions → AppImage) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6fd28e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# Claude Desktop + +Native Tauri-2.0-Desktop-App die Claude Code/Agent SDK als Backend nutzt. Sprach-Interface, Live-Aktivität, Guard-Rails. Eigenes Pendant zur VSCodium-Sidebar mit voller Kontrolle über das System. + +Detail-Übersicht + Status: [README.md](README.md). Phasen-Stand: [ROADMAP.md](ROADMAP.md). + +## Tech-Stack + +- **Backend**: Rust 2021, Tauri 2 (`tauri = "2"` mit `tray-icon`-Feature), tokio, mysql_async, rusqlite, reqwest +- **Frontend**: SvelteKit 2, Svelte 5, TypeScript, Vite 5, paneforge (Multi-Pane-Layout) +- **AI**: `@anthropic-ai/claude-agent-sdk` + `@anthropic-ai/claude-code` +- **Sprache**: Whisper (STT lokal), TTS Cloud-Streaming +- **DB**: claude-DB (MySQL 192.168.155.11 `claude`) für Wissensbasis, SQLite lokal für Sessions/Persistenz +- **Build**: NixOS-Dev-Shell (`shell.nix`), Forgejo CI für AppImage + +## Wichtige Pfade + +- Rust-Module: [src-tauri/src/](src-tauri/src/) — 16 Module (`main.rs`, `lib.rs`, `claude.rs`, `db.rs`, `guard.rs`, `voice.rs`, `hooks.rs`, `ide.rs`, …) +- UI-Komponenten: [src/lib/components/](src/lib/components/) — 24 Panels +- Routes: [src/routes/](src/routes/) (`+layout.svelte`, `+page.svelte`, `presentation/+page.svelte`) +- VS-Code-Extension: [vscode-extension/](vscode-extension/) (Bridge auf WebSocket-Port 7890) +- Workflow: [.forgejo/workflows/build-appimage.yml](.forgejo/workflows/build-appimage.yml) +- Dev-Shell: [shell.nix](shell.nix) +- Tauri-Config: [src-tauri/tauri.conf.json](src-tauri/tauri.conf.json), [src-tauri/Cargo.toml](src-tauri/Cargo.toml) + +## Build & Run + +### Native Dev (Hot-Reload) +```bash +nix-shell shell.nix --run 'npm ci && npm run tauri:dev' +``` + +### Native Production-Build (NixOS — **Pflicht-Weg auf NixOS!**) +```bash +# Cargo-target NICHT auf SMB-Mount! → I/O-Errors. Lokales tmpfs nutzen: +CARGO_TARGET_DIR=/tmp/claude-target \ + nix-shell shell.nix --run 'npm run tauri build -- --bundles appimage' +# Bundling kann mit pkg-config-Bug crashen — Binary unter +# /tmp/claude-target/release/claude-desktop ist trotzdem fertig & startbar: +nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop +``` + +### CI-Build via Forgejo Actions +Commit mit `[appimage]` in der Message → Build auf `16-Forgejo-Runner-AppImage` (Debian) → Upload in Package-Registry. Triggert auch bei Tag-Push `v*`. + +## NixOS-Spezialfall — AppImage funktioniert NICHT + +Das vom CI gebaute AppImage hat auf NixOS einen WebKit2GTK ↔ Mesa ABI-Konflikt (`EGL_BAD_PARAMETER` im WebKitWebProcess). **Keine** Kombination aus `WEBKIT_DISABLE_DMABUF_RENDERER` / `_COMPOSITING_MODE` / `_SANDBOX_THIS_IS_DANGEROUS` / `GDK_BACKEND=x11` / `LIBGL_ALWAYS_SOFTWARE` löst es. Workarounds erschöpft → **immer nativ bauen via `shell.nix`**. Vollständige Diagnose: KB-Eintrag #381. + +Auf Debian/Ubuntu/Fedora/Arch funktioniert das AppImage problemlos. + +## Konventionen + +- **Sprache**: Deutsch in Code-Kommentaren, README, CHANGELOG, Commit-Messages, UI-Strings +- **Modell-Defaults**: Sonnet 4.6 für normale Tasks, Opus für Komplex, Haiku für Routineanfragen — UI-Auswahl im Footer +- **Guard-Rails**: alle System-Aktionen über `guard.rs` klassifizieren (Safe / Moderate / Critical / Blocked) — siehe `src-tauri/src/guard.rs` +- **Audit**: jede Tool-Ausführung landet im Audit-Log (`audit.rs` → SQLite + UI-Panel) +- **Sessions**: persistent in SQLite, restartbar +- **Bridge zur VSCodium-Extension**: Port 7890, einfaches WebSocket-Protokoll, in `ide.rs` definiert + +## Workflow-Eigenheiten (CI/CD) + +Beim Anpassen von [.forgejo/workflows/build-appimage.yml](.forgejo/workflows/build-appimage.yml) **nicht vergessen**: +- AppImage-Filename hat Leerzeichen → vor Upload `tr ' ' '-'` (curl-URL-Bug) +- Vor jedem Upload **DELETE auf `latest/` UND `VERSION/`** (Forgejo wirft 409 Conflict bei PUT auf existing) +- Custom-AppRun **muss `apprun-hooks/linuxdeploy-plugin-gtk.sh` sourcen + `AppRun.wrapped` aufrufen**, sonst finden WebKit-Subprozesse ihre Helpers nicht (KB #384) +- Re-Bundle nach AppRun-Patch mit `appimagetool --no-appstream` +- Ntfy-Notifications inline (nicht via `data/ntfy-action`, weil das Repo cross-org ist — für `data-it/*`-Repos siehe KB #220 für vollständige URL-Variante) + +## Wissensbasis (Claude-DB) + +Bei spezifischen Bug-Themen vorab `mysql_search_knowledge` mit Schlüsselwort: + +| Thema | KB-ID | +|---|---| +| Pipeline-Übersicht | #311 | +| Debian-Runner Setup | #371 | +| libssl-dev / openssl-sys | #372 | +| NixOS WebKit-EGL-Crash | #381 | +| Cargo auf SMB → CARGO_TARGET_DIR | #382 | +| Custom-AppRun + linuxdeploy-Hook | #384 | +| Bluetooth 5.2 ≠ LE Audio | #385 | +| Tauri shell.nix-Vorlage | #248 | +| Forgejo Package-Registry 409 | #161 | +| Ntfy-Pattern (cross-org URL) | #190/#191/#220 | + +## Häufige Befehle + +```bash +# Build-Status checken +curl -sS -u "token:" \ + 'https://git.data-it-solution.de/api/v1/repos/data/claude-desktop/actions/tasks?limit=3' | jq + +# AppImage frisch ziehen (Standard-Linux) +rm -f ~/Applications/Claude-Desktop.AppImage +curl -sSL -o ~/Applications/Claude-Desktop.AppImage \ + -u "token:" \ + 'https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/Claude-Desktop_0.1.0_amd64.AppImage' +chmod +x ~/Applications/Claude-Desktop.AppImage + +# Native NixOS-Start (nach einmaligem Build) +nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop + +# Build-Logs aus Forgejo-Container ziehen (bei Failure) +ssh unraid "docker cp 18-Forgejo:\$(docker exec 18-Forgejo find /data/gitea/actions_log/data/claude-desktop -name '.log.zst' | head -1) /tmp/build.log.zst" +ssh unraid "zstd -d -c /tmp/build.log.zst" | tail -100 +``` diff --git a/ROADMAP.md b/ROADMAP.md index 5580c0c..57ee461 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Claude Desktop — Roadmap -Stand: 14.04.2026 +Stand: 20.04.2026 ## Aktueller Status @@ -40,6 +40,19 @@ Stand: 14.04.2026 | **VSCodium-Integration (Phase 13)** | ✅ | 14.04.2026 | | **Programm-Steuerung (Phase 14)** | ✅ | 14.04.2026 | | **Schulungsmodus (Phase 15)** | ✅ | 14.04.2026 | +| **Aktivierung & Quick-Wins (Phase 1.5)** | ✅ | 20.04.2026 | + +### Phase 1.5: Aktivierung & Quick-Wins ✅ ERLEDIGT (20.04.2026) + +| Feature | Status | Datei(en) | +|---------|--------|-----------| +| SQL Priority-Fix: KB-Queries mit `priority DESC` | ✅ | `knowledge.rs` | +| KB-Hints in Claude-Prompt: Relevante Wissensbasis-Einträge automatisch injiziert | ✅ | `claude.rs`, `knowledge.rs` | +| Voice → Claude → TTS: `sendToClaudeWithTts()` Pipeline | ✅ | `VoicePanel.svelte` | +| Hook-System aktiviert: SessionStart, PreToolUse, PostToolUse feuern echte Events | ✅ | `hooks.rs`, `events.ts` | +| Pattern-Detektion: Tool-Fehler gegen bekannte Patterns prüfen | ✅ | `events.ts` | +| Slash-Command Autocomplete: `/`-Eingabe zeigt Dropdown | ✅ | `commands.rs`, `CommandPalette.svelte`, `ChatPanel.svelte` | +| Updater-Absicherung: Lock-Datei, Prozess-Guard, Graceful Shutdown, Bestätigungsdialog | ✅ | `update.rs`, `lib.rs`, `UpdateDialog.svelte` | --- @@ -1331,6 +1344,33 @@ END; --- +## In Arbeit / Geplant + +### Phase 2.0: Proaktive Intelligenz (geplant) +- [ ] MySQL Pool als Managed State (Effizienz-Fix für knowledge.rs) +- [ ] Proaktive KB-Abfrage bei SessionStart +- [ ] Themen-Erkennung aus User-Nachrichten für KB-Suche +- [ ] Auto-Fehler-Pattern-Speicherung (3x gleicher Fehler → Pattern) + +### Phase 2.1: Desktop-Zugriff erweitern (geplant) +- [ ] Guard-Rails in Claude-Bridge einbauen +- [ ] Desktop-Steuerung erweitern (open_application, focus_window, type_text) +- [ ] VSCodium-Extension fertigstellen (Auto-Connect, IDE-Status im Context) + +### Phase 2.2: Voice fertigstellen & lokal (geplant) +- [ ] Lokales Whisper (whisper.cpp) als Fallback +- [ ] Piper-TTS lokal mit deutscher Stimme +- [ ] Vollständiger Voice-Conversation-Loop +- [ ] Mikrofon-Button im ChatPanel (Option A) +- [ ] Fullscreen Voice-Gesprächsmodus (Option B) + +### Phase 2.3: UX & Polish (Zukunft) +- [ ] Globaler Voice/Text Modus-Toggle +- [ ] Voice und Chat teilen eine Session nahtlos +- [ ] Hands-free Modus + +--- + ## Nicht geplant / Zukunft - [ ] MCP-Server Integration in App - [ ] Plugin-System @@ -1388,3 +1428,7 @@ CARGO_TARGET_DIR=/tmp/claude-desktop-target nix-shell --run "npx tauri build" | 14.04.2026 | 9d73684 | Monitor-Events Backend-Persistierung | | 14.04.2026 | 6b8f281 | Performance-Panel (Kosten-Tracker, Statistiken) | | 14.04.2026 | be65dee | Claude-Session-ID für SDK-Fortsetzung | +| 20.04.2026 | 3993387 | Security-Fixes + UI-Verbesserungen | +| 20.04.2026 | 506f1d3 | Auto-Updater: Package Registry + update.json | +| 20.04.2026 | 29cce7f | UI-Polish: Icon, Stop-Button, Chat-Queue, Update-Safety | +| 20.04.2026 | (wip) | **Phase 1.5:** Aktivierung & Quick-Wins | diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index b326dc8..ff24fcb 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, Manager}; use crate::db; +use crate::knowledge; /// Status eines Agents #[derive(Debug, Clone, Serialize, Deserialize)] @@ -371,12 +372,32 @@ pub async fn send_message(app: AppHandle, message: String) -> Result { + // 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() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..aa3bd95 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,140 @@ +// Slash-Command Registry — scannt ~/.claude/commands/ und ~/.claude/skills/ +// und liefert eine kombinierte Liste inkl. Built-in-Commands an das Frontend. + +use serde::Serialize; +use std::fs; +use std::path::PathBuf; + +/// Ein Slash-Command mit Metadaten +#[derive(Debug, Clone, Serialize)] +pub struct SlashCommand { + /// Name des Commands (ohne führenden Slash) + pub name: String, + /// Beschreibung (erste Zeile der .md-Datei oder fest hinterlegt) + pub description: String, + /// Kategorie: "builtin", "custom", "skill" + pub category: String, + /// Herkunft: Dateipfad oder "builtin" + pub source: String, +} + +/// Liest die erste nicht-leere Zeile einer Datei als Beschreibung. +/// Entfernt dabei führende Markdown-Header-Zeichen (#). +fn read_first_line(path: &PathBuf) -> String { + match fs::read_to_string(path) { + Ok(content) => { + content + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| line.trim().trim_start_matches('#').trim().to_string()) + .unwrap_or_else(|| "Keine Beschreibung".into()) + } + Err(_) => "Datei nicht lesbar".into(), + } +} + +/// Scannt alle verfügbaren Slash-Commands aus verschiedenen Quellen +fn scan_commands() -> Vec { + let mut commands: Vec = Vec::new(); + + // 1. Built-in-Commands (hardcoded) + let builtins = vec![ + ("help", "Hilfe und verfügbare Commands anzeigen"), + ("clear", "Chat-Verlauf leeren"), + ("compact", "Konversation kompaktieren (Token sparen)"), + ("model", "KI-Modell wechseln (Sonnet/Opus/Haiku)"), + ("cost", "Aktuelle Token-Kosten anzeigen"), + ("doctor", "System-Diagnose und Health-Check"), + ("review", "Code-Review für aktuelles Projekt"), + ]; + + for (name, desc) in builtins { + commands.push(SlashCommand { + name: name.to_string(), + description: desc.to_string(), + category: "builtin".to_string(), + source: "builtin".to_string(), + }); + } + + // Home-Verzeichnis ermitteln (ohne externe Dependency) + let home = match std::env::var("HOME") { + Ok(h) => PathBuf::from(h), + Err(_) => { + eprintln!("⚠️ HOME-Umgebungsvariable nicht gesetzt"); + return commands; + } + }; + + // 2. Custom Commands aus ~/.claude/commands/*.md + let commands_dir = home.join(".claude").join("commands"); + if commands_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&commands_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "md") { + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let description = read_first_line(&path); + commands.push(SlashCommand { + name, + description, + category: "custom".to_string(), + source: path.to_string_lossy().to_string(), + }); + } + } + } + } + + // 3. Skills aus ~/.claude/skills/*/SKILL.md + let skills_dir = home.join(".claude").join("skills"); + if skills_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&skills_dir) { + for entry in entries.flatten() { + let skill_dir = entry.path(); + if skill_dir.is_dir() { + let skill_md = skill_dir.join("SKILL.md"); + if skill_md.exists() { + let name = skill_dir + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let description = read_first_line(&skill_md); + commands.push(SlashCommand { + name, + description, + category: "skill".to_string(), + source: skill_md.to_string_lossy().to_string(), + }); + } + } + } + } + } + + // Alphabetisch sortieren (Built-ins zuerst, dann Custom, dann Skills) + commands.sort_by(|a, b| { + let cat_order = |c: &str| match c { + "builtin" => 0, + "custom" => 1, + "skill" => 2, + _ => 3, + }; + cat_order(&a.category) + .cmp(&cat_order(&b.category)) + .then_with(|| a.name.cmp(&b.name)) + }); + + commands +} + +/// Tauri-Command: Gibt alle verfügbaren Slash-Commands zurück +#[tauri::command] +pub fn get_slash_commands() -> Vec { + scan_commands() +} diff --git a/src-tauri/src/hooks.rs b/src-tauri/src/hooks.rs index bc334a5..842ab14 100644 --- a/src-tauri/src/hooks.rs +++ b/src-tauri/src/hooks.rs @@ -230,5 +230,42 @@ pub async fn fire_hook( }), ); + // Spezifische Hook-Events dispatchen — Frontend kann gezielt darauf reagieren + // Summary wird als JSON-Payload durchgereicht (Tool-Name, Argumente, Ergebnis etc.) + match hook_event { + HookEvent::SessionStart => { + let _ = app.emit("hook-session-start", serde_json::json!({ + "hooks": fired, + "payload": summary, + })); + } + HookEvent::PreToolUse => { + let _ = app.emit("hook-pre-tool-use", serde_json::json!({ + "hooks": fired, + "payload": summary, + })); + } + HookEvent::PostToolUse => { + let _ = app.emit("hook-post-tool-use", serde_json::json!({ + "hooks": fired, + "payload": summary, + })); + } + HookEvent::BeforeCompacting => { + let _ = app.emit("hook-before-compacting", serde_json::json!({ + "hooks": fired, + "payload": summary, + })); + } + HookEvent::AfterCompacting => { + let _ = app.emit("hook-after-compacting", serde_json::json!({ + "hooks": fired, + "payload": summary, + })); + } + // ContextFailure + AgentStarted: aktuell kein eigenes Frontend-Event noetig + _ => {} + } + Ok(fired) } diff --git a/src-tauri/src/knowledge.rs b/src-tauri/src/knowledge.rs index e9396df..02f369f 100644 --- a/src-tauri/src/knowledge.rs +++ b/src-tauri/src/knowledge.rs @@ -65,6 +65,78 @@ fn create_pool() -> Pool { Pool::new(url.as_str()) } +// ============ Interne Funktionen (kein Tauri-Command) ============ + +/// KB-Hints für eine Nachricht laden — fehlertolerant, gibt leeren String bei DB-Problemen +/// Wird von claude.rs aufgerufen bevor die Nachricht an die Bridge geht +pub async fn search_knowledge_internal(query: &str, limit: i32) -> Result { + let pool = create_pool(); + + // Verbindung mit Timeout — DB nicht erreichbar soll nicht blockieren + let conn_result = tokio::time::timeout( + std::time::Duration::from_secs(3), + pool.get_conn() + ).await; + + let mut conn = match conn_result { + Ok(Ok(c)) => c, + Ok(Err(e)) => { + println!("⚠️ KB-Hints: DB-Verbindung fehlgeschlagen: {}", e); + return Ok(String::new()); + } + Err(_) => { + println!("⚠️ KB-Hints: DB-Timeout (3s)"); + return Ok(String::new()); + } + }; + + // Volltext-Suche — nur Titel und Zusammenfassung, kein ganzer Content + let results: Vec<(i64, String, String, String, Option, f64)> = conn.exec( + r#"SELECT + id, category, title, + SUBSTRING(content, 1, 300) as content_preview, + tags, + MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance + FROM knowledge + WHERE status = 'active' + AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) + ORDER BY priority DESC, relevance DESC + LIMIT ?"#, + (query, query, limit), + ).await.map_err(|e| e.to_string())?; + + drop(conn); + let _ = pool.disconnect().await; + + if results.is_empty() { + println!("🔍 KB-Hints für '{}': keine Treffer", &query[..query.len().min(40)]); + return Ok(String::new()); + } + + // Als Block formatieren + let mut hints = Vec::new(); + hints.push("".to_string()); + hints.push(format!("Relevante KB-Einträge ({} Treffer):", results.len())); + + for (id, category, title, content_preview, tags, _relevance) in &results { + hints.push(format!("\n**#{}** [{}] {}", id, category, title)); + hints.push(content_preview.clone()); + if let Some(t) = tags { + if !t.is_empty() { + hints.push(format!("Tags: {}", t)); + } + } + } + + hints.push("".to_string()); + + let block = hints.join("\n"); + println!("🔍 KB-Hints für '{}': {} Treffer, {} Bytes", + &query[..query.len().min(40)], results.len(), block.len()); + + Ok(block) +} + // ============ Tauri Commands ============ /// Wissensbasis durchsuchen (Volltext) @@ -89,7 +161,8 @@ pub async fn search_knowledge( FROM knowledge WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) AND category = ? - ORDER BY relevance DESC + AND status = 'active' + ORDER BY priority DESC, relevance DESC LIMIT ?"#, (&query, &query, &cat, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): @@ -113,7 +186,8 @@ pub async fn search_knowledge( MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance FROM knowledge WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) - ORDER BY relevance DESC + AND status = 'active' + ORDER BY priority DESC, relevance DESC LIMIT ?"#, (&query, &query, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): @@ -227,7 +301,7 @@ pub async fn get_recent_knowledge( related_ids, source, created_at, updated_at FROM knowledge WHERE status = 'active' AND category = ? - ORDER BY updated_at DESC + ORDER BY priority DESC, updated_at DESC LIMIT ?"#, (&cat, limit), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): @@ -246,7 +320,7 @@ pub async fn get_recent_knowledge( related_ids, source, created_at, updated_at FROM knowledge WHERE status = 'active' - ORDER BY updated_at DESC + ORDER BY priority DESC, updated_at DESC LIMIT ?"#, (limit,), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): @@ -330,7 +404,7 @@ pub async fn get_tool_hints( WHERE status = 'active' AND category = ? AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) - ORDER BY priority ASC, updated_at DESC + ORDER BY priority DESC, updated_at DESC LIMIT 3"#, (&cat, &query_string), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): @@ -350,7 +424,7 @@ pub async fn get_tool_hints( FROM knowledge WHERE status = 'active' AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) - ORDER BY priority ASC, updated_at DESC + ORDER BY priority DESC, updated_at DESC LIMIT 3"#, (&query_string,), |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe27e03..5b5e496 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ use tauri::{ mod audit; mod claude; +mod commands; mod context; mod db; mod guard; @@ -136,6 +137,8 @@ pub fn run() { update::download_update, update::apply_update, update::get_current_version, + // Slash-Command Registry + commands::get_slash_commands, ]) .setup(|app| { let handle = app.handle().clone(); @@ -223,8 +226,25 @@ pub fn run() { app.manage(tray_icon); println!("🔲 Tray-Icon eingerichtet"); + // Lock-Datei erstellen (Instanz-Schutz + Update-Safety) + update::create_lock_file(); + Ok(()) }) - .run(tauri::generate_context!()) - .expect("Fehler beim Starten der App"); + .build(tauri::generate_context!()) + .expect("Fehler beim Erstellen der App") + .run(|_app_handle, event| { + // Lock-Datei bei App-Beendigung aufräumen + if let tauri::RunEvent::Exit = event { + update::remove_lock_file(); + println!("🔒 Lock-Datei aufgeräumt, App beendet."); + } + // Bei Fenster-Schließen: Lock entfernen falls App komplett beendet wird + if let tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { .. }, + .. + } = event { + // Lock wird beim tatsächlichen Exit entfernt (RunEvent::Exit oben) + } + }); } diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index fbbbfcb..1a9b052 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -8,6 +8,92 @@ use sha2::{Digest, Sha256}; use std::path::PathBuf; use tauri::{AppHandle, Emitter, Manager}; +/// Pfad zur Lock-Datei (verhindert doppelte Instanzen, wird vor Update entfernt) +const LOCK_FILE_PATH: &str = "/tmp/claude-desktop.lock"; + +// ============ Lock-Datei System ============ + +/// Erstellt die Lock-Datei mit der aktuellen PID. +/// Wenn bereits eine Instanz läuft, wird eine Warnung geloggt. +/// Fehler beim Erstellen werden toleriert (App startet trotzdem). +pub fn create_lock_file() { + // Prüfen ob bereits eine andere Instanz läuft + if check_lock_file() { + if let Ok(content) = std::fs::read_to_string(LOCK_FILE_PATH) { + eprintln!( + "⚠️ Lock-Datei existiert und Prozess {} lebt noch — möglicherweise zweite Instanz!", + content.trim() + ); + } + } + + // PID in Lock-Datei schreiben + let pid = std::process::id(); + match std::fs::write(LOCK_FILE_PATH, pid.to_string()) { + Ok(_) => println!("🔒 Lock-Datei erstellt: {} (PID {})", LOCK_FILE_PATH, pid), + Err(e) => eprintln!( + "⚠️ Lock-Datei konnte nicht erstellt werden: {} — App läuft trotzdem weiter", + e + ), + } +} + +/// Prüft ob die Lock-Datei existiert UND ob der darin gespeicherte Prozess noch lebt. +/// Gibt true zurück wenn ein anderer lebender Prozess die Lock hält. +pub fn check_lock_file() -> bool { + let content = match std::fs::read_to_string(LOCK_FILE_PATH) { + Ok(c) => c, + Err(_) => return false, // Keine Lock-Datei → kein Problem + }; + + let pid: u32 = match content.trim().parse() { + Ok(p) => p, + Err(_) => { + // Ungültige PID in Lock-Datei → aufräumen + std::fs::remove_file(LOCK_FILE_PATH).ok(); + return false; + } + }; + + // Eigene PID → wir sind es selbst, kein Konflikt + if pid == std::process::id() { + return false; + } + + // Prüfen ob der Prozess mit dieser PID noch existiert via /proc/{pid}/ + let proc_path = format!("/proc/{}", pid); + std::path::Path::new(&proc_path).exists() +} + +/// Entfernt die Lock-Datei (Aufräumen bei App-Ende oder vor Update). +pub fn remove_lock_file() { + match std::fs::remove_file(LOCK_FILE_PATH) { + Ok(_) => println!("🔓 Lock-Datei entfernt: {}", LOCK_FILE_PATH), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Bereits entfernt — kein Problem + } + Err(e) => eprintln!("⚠️ Lock-Datei konnte nicht entfernt werden: {}", e), + } +} + +/// Bereitet die App auf ein Update vor: +/// 1. Event an Frontend senden (UI kann State speichern) +/// 2. Kurz warten damit Frontend reagieren kann +/// 3. Lock-Datei entfernen +async fn prepare_for_update(app: &AppHandle) -> Result<(), String> { + // Frontend informieren + app.emit("update-preparing", ()) + .map_err(|e| format!("Konnte update-preparing Event nicht senden: {}", e))?; + + // 2 Sekunden warten — Frontend kann State/Sessions speichern + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // Lock-Datei entfernen bevor Binary ersetzt wird + remove_lock_file(); + + Ok(()) +} + /// Endpoint der Manifest-Datei const UPDATE_JSON_URL: &str = "https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/update.json"; @@ -320,6 +406,9 @@ pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), St } } + // === Graceful Shutdown vorbereiten === + prepare_for_update(&app).await?; + // === Backup + Rename === let backup_path = { let mut p = target.clone(); @@ -331,6 +420,13 @@ pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), St p }; + // Sicherheitshalber prüfen: Lock-Datei sollte bereits entfernt sein + if std::path::Path::new(LOCK_FILE_PATH).exists() { + remove_lock_file(); + // Kurz warten damit Dateisystem synchronisiert + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + std::fs::rename(&target, &backup_path) .map_err(|e| format!("Backup fehlgeschlagen ({}): {}", mode_label, e))?; diff --git a/src/lib/components/ChatPanel.svelte b/src/lib/components/ChatPanel.svelte index 3b147bf..6165642 100644 --- a/src/lib/components/ChatPanel.svelte +++ b/src/lib/components/ChatPanel.svelte @@ -5,6 +5,7 @@ import { marked, type Tokens } from 'marked'; import { tick, onDestroy, onMount } from 'svelte'; import { get } from 'svelte/store'; + import CommandPalette from './CommandPalette.svelte'; // Input-Referenz für Focus-Shortcuts let inputTextarea: HTMLTextAreaElement; @@ -109,6 +110,36 @@ let audioChunks: Blob[] = []; let levelAnimationFrame: number | null = null; + // Slash-Command Autocomplete State + let showCommandPalette = $state(false); + let commandQuery = $state(''); + let commandPaletteRef: CommandPalette | undefined = $state(undefined); + + // Slash-Command Erkennung im Input + $effect(() => { + const text = $currentInput; + if (text.startsWith('/')) { + showCommandPalette = true; + commandQuery = text.slice(1); + } else { + showCommandPalette = false; + commandQuery = ''; + } + }); + + // Command auswählen: Text ersetzen + function handleCommandSelect(cmd: { name: string; description: string; category: string }) { + if (!cmd.name) { + // Escape gedrückt — Palette schliessen, Text behalten + showCommandPalette = false; + return; + } + $currentInput = '/' + cmd.name + ' '; + showCommandPalette = false; + // Focus zurück aufs Input + inputTextarea?.focus(); + } + // VAD (Voice Activity Detection) — automatisches Stoppen nach Sprechpause const VAD_SILENCE_THRESHOLD = 15; // Pegel unter dem als Stille gilt const VAD_SILENCE_DURATION = 1500; // ms Stille vor Auto-Stopp @@ -473,6 +504,12 @@ } function handleKeydown(event: KeyboardEvent) { + // CommandPalette hat Vorrang bei Tastatur-Events + if (showCommandPalette && commandPaletteRef) { + const handled = commandPaletteRef.handleKey(event); + if (handled) return; + } + // Ctrl+Enter: Immer senden (auch bei mehrzeiligem Text) if (event.key === 'Enter' && event.ctrlKey) { event.preventDefault(); @@ -770,6 +807,12 @@
+ {#if liveTranscript}
🎤 diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte new file mode 100644 index 0000000..03e45f3 --- /dev/null +++ b/src/lib/components/CommandPalette.svelte @@ -0,0 +1,213 @@ + + +{#if visible && filtered.length > 0} +
+ {#each filtered as cmd, i} + + {/each} +
+{/if} + + diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index c713a4b..0058e92 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -20,24 +20,39 @@ percent: number; } - let updateInfo: UpdateStatus | null = null; - let downloading = false; - let progress: DownloadProgress | null = null; - let error: string | null = null; - let downloadedPath: string | null = null; - let checking = false; - let manualMode = false; + // Svelte 5 Runes + let updateInfo = $state(null); + let downloading = $state(false); + let progress = $state(null); + let error = $state(null); + let downloadedPath = $state(null); + let checking = $state(false); + let manualMode = $state(false); + // Neuer Zustand: Update heruntergeladen, warte auf User-Bestätigung + let awaitingConfirmation = $state(false); + // Neuer Zustand: Update wird vorbereitet (Graceful Shutdown) + let preparing = $state(false); let progressListener: UnlistenFn | null = null; + let preparingListener: UnlistenFn | null = null; let manualUnsub: (() => void) | null = null; - // Reaktiv: wenn Store schließt → State zurücksetzen - $: if (!$updateDialogOpen) { - resetState(); - } + // Reaktiv: wenn Store schließt → State zurücksetzen (aber nicht wenn Bestätigung aussteht) + $effect(() => { + if (!$updateDialogOpen && !preparing) { + resetState(); + } + }); // Manueller Check-Trigger aus dem Settings-Panel - $: manualMode = $updateCheckManual; + $effect(() => { + manualMode = $updateCheckManual; + }); + + // Abgeleiteter Zustand + let isNoUpdateDialog = $derived( + $updateDialogOpen && updateInfo && !updateInfo.available && !error + ); onMount(async () => { // Progress-Events vom Backend @@ -45,6 +60,11 @@ progress = event.payload; }); + // Graceful-Shutdown-Event vom Backend + preparingListener = await listen('update-preparing', () => { + preparing = true; + }); + // Manueller Check wird via Store gestartet manualUnsub = updateCheckManual.subscribe((active) => { if (active) { @@ -60,6 +80,7 @@ onDestroy(() => { progressListener?.(); + preparingListener?.(); manualUnsub?.(); }); @@ -101,7 +122,10 @@ downloadUrl: updateInfo.download_url, expectedSha256: updateInfo.sha256, }); - console.log('✅ Download abgeschlossen:', downloadedPath); + console.log('Download abgeschlossen:', downloadedPath); + // NICHT sofort installieren — User-Bestätigung abwarten + downloading = false; + awaitingConfirmation = true; } catch (err) { error = String(err); downloading = false; @@ -110,15 +134,27 @@ async function applyUpdate() { if (!downloadedPath) return; + // Sofort in Preparing-Zustand wechseln + awaitingConfirmation = false; + preparing = true; try { await invoke('apply_update', { updatePath: downloadedPath }); // App startet neu, kein weiterer Code erreicht } catch (err) { + preparing = false; error = String(err); } } + function postponeUpdate() { + // Update wurde heruntergeladen, aber User will später installieren + // Beim nächsten App-Start kann es angewendet werden + awaitingConfirmation = false; + updateDialogOpen.set(false); + } + function closeDialog() { + if (preparing) return; // Während Vorbereitung nicht schließbar updateDialogOpen.set(false); } @@ -126,7 +162,9 @@ downloading = false; progress = null; error = null; - downloadedPath = null; + // downloadedPath bewusst NICHT zurücksetzen — Update bleibt für späteren Neustart + awaitingConfirmation = false; + preparing = false; manualMode = false; } @@ -135,42 +173,73 @@ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } - - $: isNoUpdateDialog = $updateDialogOpen && updateInfo && !updateInfo.available && !error; -{#if $updateDialogOpen && updateInfo} +{#if $updateDialogOpen && (updateInfo || preparing)}