Phase 1.5: Aktivierung & Quick-Wins [appimage]
All checks were successful
Build AppImage / build (push) Successful in 7m51s

- KB-Hints werden automatisch in jeden Claude-Prompt injiziert
- SQL-Queries berücksichtigen jetzt Priority (DESC)
- Voice-zu-Claude-Pipeline: Sprache → Transkription → Claude → TTS
- Hook-System feuert echte Events (SessionStart, Pre/PostToolUse)
- Pattern-Detektion bei Tool-Fehlern aktiviert
- Slash-Command Autocomplete mit CommandPalette
- Updater abgesichert: Lock-Datei, Prozess-Guard, Bestätigungs-Dialog
- ROADMAP.md und CHANGELOG.md aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 13:00:40 +02:00
parent 29cce7fbd8
commit 0a447591da
14 changed files with 1133 additions and 50 deletions

55
CHANGELOG.md Normal file
View file

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

107
CLAUDE.md Normal file
View file

@ -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:<forgejo-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:<forgejo-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 '<RUN_ID>.log.zst' | head -1) /tmp/build.log.zst"
ssh unraid "zstd -d -c /tmp/build.log.zst" | tail -100
```

View file

@ -1,6 +1,6 @@
# Claude Desktop — Roadmap # Claude Desktop — Roadmap
Stand: 14.04.2026 Stand: 20.04.2026
## Aktueller Status ## Aktueller Status
@ -40,6 +40,19 @@ Stand: 14.04.2026
| **VSCodium-Integration (Phase 13)** | ✅ | 14.04.2026 | | **VSCodium-Integration (Phase 13)** | ✅ | 14.04.2026 |
| **Programm-Steuerung (Phase 14)** | ✅ | 14.04.2026 | | **Programm-Steuerung (Phase 14)** | ✅ | 14.04.2026 |
| **Schulungsmodus (Phase 15)** | ✅ | 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 ## Nicht geplant / Zukunft
- [ ] MCP-Server Integration in App - [ ] MCP-Server Integration in App
- [ ] Plugin-System - [ ] 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 | 9d73684 | Monitor-Events Backend-Persistierung |
| 14.04.2026 | 6b8f281 | Performance-Panel (Kosten-Tracker, Statistiken) | | 14.04.2026 | 6b8f281 | Performance-Panel (Kosten-Tracker, Statistiken) |
| 14.04.2026 | be65dee | Claude-Session-ID für SDK-Fortsetzung | | 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 |

View file

@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use crate::db; use crate::db;
use crate::knowledge;
/// Status eines Agents /// Status eines Agents
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -371,12 +372,32 @@ pub async fn send_message(app: AppHandle, message: String) -> Result<String, Str
} }
// Context aus DB laden (Schicht 1: Sticky Context) // Context aus DB laden (Schicht 1: Sticky Context)
let context = load_sticky_context_for_prompt(&app); let mut context = load_sticky_context_for_prompt(&app);
if context.is_some() { if context.is_some() {
println!("📌 Sticky Context geladen (~{} Token)", context.as_ref().map(|c| c.len() / 4).unwrap_or(0)); 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 // Claude-Session-ID für Fortsetzung laden
let resume_session_id = load_claude_session_id(&app); let resume_session_id = load_claude_session_id(&app);
if resume_session_id.is_some() { if resume_session_id.is_some() {

140
src-tauri/src/commands.rs Normal file
View file

@ -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<SlashCommand> {
let mut commands: Vec<SlashCommand> = 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<SlashCommand> {
scan_commands()
}

View file

@ -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) Ok(fired)
} }

View file

@ -65,6 +65,78 @@ fn create_pool() -> Pool {
Pool::new(url.as_str()) 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<String, String> {
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<String>, 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 <knowledge-hints> Block formatieren
let mut hints = Vec::new();
hints.push("<knowledge-hints>".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("</knowledge-hints>".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 ============ // ============ Tauri Commands ============
/// Wissensbasis durchsuchen (Volltext) /// Wissensbasis durchsuchen (Volltext)
@ -89,7 +161,8 @@ pub async fn search_knowledge(
FROM knowledge FROM knowledge
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
AND category = ? AND category = ?
ORDER BY relevance DESC AND status = 'active'
ORDER BY priority DESC, relevance DESC
LIMIT ?"#, LIMIT ?"#,
(&query, &query, &cat, limit), (&query, &query, &cat, limit),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): |(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 MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance
FROM knowledge FROM knowledge
WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) WHERE MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY relevance DESC AND status = 'active'
ORDER BY priority DESC, relevance DESC
LIMIT ?"#, LIMIT ?"#,
(&query, &query, limit), (&query, &query, limit),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at, relevance): |(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 related_ids, source, created_at, updated_at
FROM knowledge FROM knowledge
WHERE status = 'active' AND category = ? WHERE status = 'active' AND category = ?
ORDER BY updated_at DESC ORDER BY priority DESC, updated_at DESC
LIMIT ?"#, LIMIT ?"#,
(&cat, limit), (&cat, limit),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): |(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 related_ids, source, created_at, updated_at
FROM knowledge FROM knowledge
WHERE status = 'active' WHERE status = 'active'
ORDER BY updated_at DESC ORDER BY priority DESC, updated_at DESC
LIMIT ?"#, LIMIT ?"#,
(limit,), (limit,),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): |(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' WHERE status = 'active'
AND category = ? AND category = ?
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) 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"#, LIMIT 3"#,
(&cat, &query_string), (&cat, &query_string),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): |(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 FROM knowledge
WHERE status = 'active' WHERE status = 'active'
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE) 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"#, LIMIT 3"#,
(&query_string,), (&query_string,),
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at): |(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):

View file

@ -10,6 +10,7 @@ use tauri::{
mod audit; mod audit;
mod claude; mod claude;
mod commands;
mod context; mod context;
mod db; mod db;
mod guard; mod guard;
@ -136,6 +137,8 @@ pub fn run() {
update::download_update, update::download_update,
update::apply_update, update::apply_update,
update::get_current_version, update::get_current_version,
// Slash-Command Registry
commands::get_slash_commands,
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
@ -223,8 +226,25 @@ pub fn run() {
app.manage(tray_icon); app.manage(tray_icon);
println!("🔲 Tray-Icon eingerichtet"); println!("🔲 Tray-Icon eingerichtet");
// Lock-Datei erstellen (Instanz-Schutz + Update-Safety)
update::create_lock_file();
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("Fehler beim Starten der App"); .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)
}
});
} }

View file

@ -8,6 +8,92 @@ use sha2::{Digest, Sha256};
use std::path::PathBuf; use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager}; 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 /// Endpoint der Manifest-Datei
const UPDATE_JSON_URL: &str = const UPDATE_JSON_URL: &str =
"https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/update.json"; "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 === // === Backup + Rename ===
let backup_path = { let backup_path = {
let mut p = target.clone(); let mut p = target.clone();
@ -331,6 +420,13 @@ pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), St
p 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) std::fs::rename(&target, &backup_path)
.map_err(|e| format!("Backup fehlgeschlagen ({}): {}", mode_label, e))?; .map_err(|e| format!("Backup fehlgeschlagen ({}): {}", mode_label, e))?;

View file

@ -5,6 +5,7 @@
import { marked, type Tokens } from 'marked'; import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte'; import { tick, onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import CommandPalette from './CommandPalette.svelte';
// Input-Referenz für Focus-Shortcuts // Input-Referenz für Focus-Shortcuts
let inputTextarea: HTMLTextAreaElement; let inputTextarea: HTMLTextAreaElement;
@ -109,6 +110,36 @@
let audioChunks: Blob[] = []; let audioChunks: Blob[] = [];
let levelAnimationFrame: number | null = null; 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 // VAD (Voice Activity Detection) — automatisches Stoppen nach Sprechpause
const VAD_SILENCE_THRESHOLD = 15; // Pegel unter dem als Stille gilt const VAD_SILENCE_THRESHOLD = 15; // Pegel unter dem als Stille gilt
const VAD_SILENCE_DURATION = 1500; // ms Stille vor Auto-Stopp const VAD_SILENCE_DURATION = 1500; // ms Stille vor Auto-Stopp
@ -473,6 +504,12 @@
} }
function handleKeydown(event: KeyboardEvent) { 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) // Ctrl+Enter: Immer senden (auch bei mehrzeiligem Text)
if (event.key === 'Enter' && event.ctrlKey) { if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault(); event.preventDefault();
@ -770,6 +807,12 @@
</div> </div>
<div class="chat-input"> <div class="chat-input">
<CommandPalette
bind:this={commandPaletteRef}
query={commandQuery}
visible={showCommandPalette}
onSelect={handleCommandSelect}
/>
{#if liveTranscript} {#if liveTranscript}
<div class="live-transcript"> <div class="live-transcript">
<span class="transcript-icon">🎤</span> <span class="transcript-icon">🎤</span>

View file

@ -0,0 +1,213 @@
<script lang="ts">
// Slash-Command Autocomplete-Dropdown
// Zeigt verfuegbare Commands wenn der User "/" tippt
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
// Props (Svelte 5 Runes)
let {
query = '',
visible = false,
onSelect = (_cmd: { name: string; description: string; category: string }) => {}
}: {
query: string;
visible: boolean;
onSelect: (cmd: { name: string; description: string; category: string }) => void;
} = $props();
// Gecachte Command-Liste
let allCommands: Array<{ name: string; description: string; category: string; source: string }> = $state([]);
let selectedIndex = $state(0);
let loaded = $state(false);
// Gefilterte Commands basierend auf Query
let filtered = $derived.by(() => {
if (!allCommands.length) return [];
const q = query.toLowerCase();
const result = allCommands.filter(cmd =>
cmd.name.toLowerCase().startsWith(q) || cmd.name.toLowerCase().includes(q)
);
// Maximal 8 Eintraege anzeigen
return result.slice(0, 8);
});
// Selektionsindex zuruecksetzen wenn sich die Filter-Ergebnisse aendern
$effect(() => {
if (filtered.length > 0) {
// Index begrenzen falls die Liste kuerzer geworden ist
if (selectedIndex >= filtered.length) {
selectedIndex = 0;
}
} else {
selectedIndex = 0;
}
});
// Commands beim ersten Sichtbarwerden laden (einmalig)
$effect(() => {
if (visible && !loaded) {
loadCommands();
}
});
async function loadCommands() {
try {
const cmds = await invoke<typeof allCommands>('get_slash_commands');
allCommands = cmds;
loaded = true;
} catch (err) {
console.error('Slash-Commands laden fehlgeschlagen:', err);
}
}
// Keyboard-Navigation (wird vom Parent aufgerufen)
export function handleKey(event: KeyboardEvent): boolean {
if (!visible || !filtered.length) return false;
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = selectedIndex <= 0 ? filtered.length - 1 : selectedIndex - 1;
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = selectedIndex >= filtered.length - 1 ? 0 : selectedIndex + 1;
return true;
}
if (event.key === 'Tab' || event.key === 'Enter') {
event.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) {
onSelect(cmd);
}
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
onSelect({ name: '', description: '', category: '' }); // Signalisiert Schliessen
return true;
}
return false;
}
// Kategorie-Badge Farbe bestimmen
function categoryColor(cat: string): string {
switch (cat) {
case 'builtin': return 'var(--accent)';
case 'custom': return 'var(--success)';
case 'skill': return 'var(--warning)';
default: return 'var(--text-secondary)';
}
}
// Kategorie-Label
function categoryLabel(cat: string): string {
switch (cat) {
case 'builtin': return 'Built-in';
case 'custom': return 'Custom';
case 'skill': return 'Skill';
default: return cat;
}
}
</script>
{#if visible && filtered.length > 0}
<div class="command-palette" role="listbox" aria-label="Slash-Commands">
{#each filtered as cmd, i}
<button
class="command-item"
class:selected={i === selectedIndex}
role="option"
aria-selected={i === selectedIndex}
onmouseenter={() => { selectedIndex = i; }}
onclick={() => onSelect(cmd)}
>
<span class="command-name">/{cmd.name}</span>
<span class="command-desc">{cmd.description}</span>
<span class="command-badge" style="background: {categoryColor(cmd.category)}">
{categoryLabel(cmd.category)}
</span>
</button>
{/each}
</div>
{/if}
<style>
.command-palette {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
overflow: hidden;
z-index: 100;
max-height: 320px;
overflow-y: auto;
}
.command-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-bottom: 1px solid var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
}
.command-item:last-child {
border-bottom: none;
}
.command-item:hover,
.command-item.selected {
background: var(--bg-hover);
}
.command-item.selected {
border-left: 2px solid var(--accent);
padding-left: calc(var(--spacing-md) - 2px);
}
.command-name {
font-family: var(--font-mono);
font-weight: 600;
color: var(--accent);
white-space: nowrap;
min-width: 80px;
}
.command-desc {
flex: 1;
color: var(--text-secondary);
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-badge {
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
font-size: 0.6rem;
font-weight: 600;
color: white;
white-space: nowrap;
opacity: 0.85;
}
</style>

View file

@ -20,24 +20,39 @@
percent: number; percent: number;
} }
let updateInfo: UpdateStatus | null = null; // Svelte 5 Runes
let downloading = false; let updateInfo = $state<UpdateStatus | null>(null);
let progress: DownloadProgress | null = null; let downloading = $state(false);
let error: string | null = null; let progress = $state<DownloadProgress | null>(null);
let downloadedPath: string | null = null; let error = $state<string | null>(null);
let checking = false; let downloadedPath = $state<string | null>(null);
let manualMode = false; 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 progressListener: UnlistenFn | null = null;
let preparingListener: UnlistenFn | null = null;
let manualUnsub: (() => void) | null = null; let manualUnsub: (() => void) | null = null;
// Reaktiv: wenn Store schließt → State zurücksetzen // Reaktiv: wenn Store schließt → State zurücksetzen (aber nicht wenn Bestätigung aussteht)
$: if (!$updateDialogOpen) { $effect(() => {
resetState(); if (!$updateDialogOpen && !preparing) {
} resetState();
}
});
// Manueller Check-Trigger aus dem Settings-Panel // Manueller Check-Trigger aus dem Settings-Panel
$: manualMode = $updateCheckManual; $effect(() => {
manualMode = $updateCheckManual;
});
// Abgeleiteter Zustand
let isNoUpdateDialog = $derived(
$updateDialogOpen && updateInfo && !updateInfo.available && !error
);
onMount(async () => { onMount(async () => {
// Progress-Events vom Backend // Progress-Events vom Backend
@ -45,6 +60,11 @@
progress = event.payload; progress = event.payload;
}); });
// Graceful-Shutdown-Event vom Backend
preparingListener = await listen('update-preparing', () => {
preparing = true;
});
// Manueller Check wird via Store gestartet // Manueller Check wird via Store gestartet
manualUnsub = updateCheckManual.subscribe((active) => { manualUnsub = updateCheckManual.subscribe((active) => {
if (active) { if (active) {
@ -60,6 +80,7 @@
onDestroy(() => { onDestroy(() => {
progressListener?.(); progressListener?.();
preparingListener?.();
manualUnsub?.(); manualUnsub?.();
}); });
@ -101,7 +122,10 @@
downloadUrl: updateInfo.download_url, downloadUrl: updateInfo.download_url,
expectedSha256: updateInfo.sha256, 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) { } catch (err) {
error = String(err); error = String(err);
downloading = false; downloading = false;
@ -110,15 +134,27 @@
async function applyUpdate() { async function applyUpdate() {
if (!downloadedPath) return; if (!downloadedPath) return;
// Sofort in Preparing-Zustand wechseln
awaitingConfirmation = false;
preparing = true;
try { try {
await invoke('apply_update', { updatePath: downloadedPath }); await invoke('apply_update', { updatePath: downloadedPath });
// App startet neu, kein weiterer Code erreicht // App startet neu, kein weiterer Code erreicht
} catch (err) { } catch (err) {
preparing = false;
error = String(err); 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() { function closeDialog() {
if (preparing) return; // Während Vorbereitung nicht schließbar
updateDialogOpen.set(false); updateDialogOpen.set(false);
} }
@ -126,7 +162,9 @@
downloading = false; downloading = false;
progress = null; progress = null;
error = null; error = null;
downloadedPath = null; // downloadedPath bewusst NICHT zurücksetzen — Update bleibt für späteren Neustart
awaitingConfirmation = false;
preparing = false;
manualMode = false; manualMode = false;
} }
@ -135,42 +173,73 @@
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
} }
$: isNoUpdateDialog = $updateDialogOpen && updateInfo && !updateInfo.available && !error;
</script> </script>
{#if $updateDialogOpen && updateInfo} {#if $updateDialogOpen && (updateInfo || preparing)}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" on:click={closeDialog}> <div class="modal-overlay" on:click={closeDialog}>
<div class="modal" on:click|stopPropagation> <div class="modal" on:click|stopPropagation>
<div class="modal-header"> <div class="modal-header">
{#if isNoUpdateDialog} {#if preparing}
<h2>✅ Aktuell</h2> <h2>Update wird vorbereitet...</h2>
{:else if error && !updateInfo.available} {:else if awaitingConfirmation}
<h2>⚠️ Update-Check fehlgeschlagen</h2> <h2>Update bereit</h2>
{:else if isNoUpdateDialog}
<h2>Aktuell</h2>
{:else if error && !updateInfo?.available}
<h2>Update-Check fehlgeschlagen</h2>
{:else} {:else}
<h2>🔄 Update verfügbar</h2> <h2>Update verfuegbar</h2>
{/if}
{#if !preparing}
<button class="close-btn" on:click={closeDialog}>&#x2715;</button>
{/if} {/if}
<button class="close-btn" on:click={closeDialog}>✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{#if isNoUpdateDialog} {#if preparing}
<!-- Graceful-Shutdown-Anzeige -->
<div class="preparing-container">
<div class="spinner"></div>
<p class="preparing-text">
Update wird vorbereitet, bitte warten...<br>
<span class="preparing-sub">Sessions werden gesichert, App startet gleich neu.</span>
</p>
</div>
{:else if awaitingConfirmation}
<!-- Bestaetigungs-Dialog nach erfolgreichem Download -->
<div class="confirmation-container">
<div class="confirmation-icon">&#x2714;</div>
<p class="confirmation-text">
Update wurde heruntergeladen und verifiziert.
</p>
{#if updateInfo}
<div class="version-info">
<span class="current">v{updateInfo.current_version}</span>
<span class="arrow">&rarr;</span>
<span class="new">v{updateInfo.latest_version}</span>
</div>
{/if}
<p class="confirmation-hint">
Jetzt installieren und neu starten?
</p>
</div>
{:else if isNoUpdateDialog}
<p class="no-update-text"> <p class="no-update-text">
Du verwendest bereits die neueste Version: Du verwendest bereits die neueste Version:
<strong>v{updateInfo.current_version}</strong> <strong>v{updateInfo?.current_version}</strong>
</p> </p>
{:else if updateInfo.available} {:else if updateInfo?.available}
<div class="version-info"> <div class="version-info">
<span class="current">v{updateInfo.current_version}</span> <span class="current">v{updateInfo.current_version}</span>
<span class="arrow"></span> <span class="arrow">&rarr;</span>
<span class="new">v{updateInfo.latest_version}</span> <span class="new">v{updateInfo.latest_version}</span>
</div> </div>
{#if updateInfo.release_notes} {#if updateInfo.release_notes}
<div class="release-notes"> <div class="release-notes">
<h3>Änderungen:</h3> <h3>Aenderungen:</h3>
<div class="notes-content"> <div class="notes-content">
{@html updateInfo.release_notes.replace(/\n/g, '<br>')} {@html updateInfo.release_notes.replace(/\n/g, '<br>')}
</div> </div>
@ -202,23 +271,29 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
{#if isNoUpdateDialog} {#if preparing}
<button class="btn btn-primary" on:click={closeDialog}>OK</button> <!-- Keine Buttons waehrend Vorbereitung -->
{:else if downloadedPath} <span class="footer-hint">Bitte nicht schliessen...</span>
<button class="btn btn-primary" on:click={applyUpdate}> {:else if awaitingConfirmation}
Jetzt installieren & neustarten <button class="btn btn-secondary" on:click={postponeUpdate}>
Spaeter
</button> </button>
<button class="btn btn-primary" on:click={applyUpdate}>
Jetzt installieren
</button>
{:else if isNoUpdateDialog}
<button class="btn btn-primary" on:click={closeDialog}>OK</button>
{:else if downloading} {:else if downloading}
<button class="btn btn-disabled" disabled> <button class="btn btn-disabled" disabled>
Wird heruntergeladen... Wird heruntergeladen...
</button> </button>
{:else if updateInfo.available} {:else if updateInfo?.available}
<button class="btn btn-secondary" on:click={closeDialog}>Später</button> <button class="btn btn-secondary" on:click={closeDialog}>Spaeter</button>
<button class="btn btn-primary" on:click={startDownload}> <button class="btn btn-primary" on:click={startDownload}>
Jetzt aktualisieren Jetzt aktualisieren
</button> </button>
{:else} {:else}
<button class="btn btn-primary" on:click={closeDialog}>Schließen</button> <button class="btn btn-primary" on:click={closeDialog}>Schliessen</button>
{/if} {/if}
</div> </div>
</div> </div>
@ -348,6 +423,69 @@
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }
/* Bestaetigungs-Zustand nach Download */
.confirmation-container {
text-align: center;
padding: var(--spacing-sm) 0;
}
.confirmation-icon {
font-size: 2.5rem;
color: var(--success);
margin-bottom: var(--spacing-sm);
}
.confirmation-text {
font-size: 0.95rem;
margin-bottom: var(--spacing-sm);
}
.confirmation-hint {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
/* Preparing-Zustand (Graceful Shutdown) */
.preparing-container {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-md) 0;
gap: var(--spacing-md);
}
.preparing-text {
text-align: center;
font-size: 0.95rem;
line-height: 1.6;
}
.preparing-sub {
font-size: 0.8rem;
color: var(--text-secondary);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--bg-tertiary);
border-top: 3px solid var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.footer-hint {
font-size: 0.8rem;
color: var(--text-secondary);
font-style: italic;
}
.error { .error {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239, 68, 68, 0.3);

View file

@ -2,7 +2,8 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { isProcessing, addMessage } from '$lib/stores/app'; import { get } from 'svelte/store';
import { isProcessing, messages, addMessage } from '$lib/stores/app';
// Voice-Zustand // Voice-Zustand
let isListening = false; let isListening = false;
@ -170,8 +171,55 @@
} }
async function sendToClaudeWithTts(text: string) { async function sendToClaudeWithTts(text: string) {
// TODO: Claude-Request mit TTS-Flag // Nachricht an Claude senden und Antwort per TTS vorlesen
// Für jetzt: Normaler Send + TTS der Antwort try {
$isProcessing = true;
// Claude-Request abfeuern
await invoke('send_message', { message: text });
// Auf Ende der Verarbeitung warten (all-stopped Event)
await new Promise<void>((resolve) => {
// Timeout nach 120 Sekunden als Sicherheitsnetz
const timeout = setTimeout(() => {
console.warn('TTS-Timeout: Claude hat nach 120s nicht geantwortet');
unlisten();
resolve();
}, 120_000);
let unlisten: UnlistenFn;
listen('all-stopped', () => {
clearTimeout(timeout);
unlisten();
resolve();
}).then((fn) => {
unlisten = fn;
});
});
// Letzte Assistant-Nachricht aus dem Store holen
const allMessages = get(messages);
const lastAssistant = [...allMessages]
.reverse()
.find((m) => m.role === 'assistant' && m.content.trim());
if (lastAssistant) {
// TTS auf max 500 Zeichen begrenzen (lange Antworten abschneiden)
let ttsText = lastAssistant.content.trim();
if (ttsText.length > 500) {
// Am letzten Satzende vor 500 Zeichen abschneiden
const cutoff = ttsText.lastIndexOf('.', 500);
ttsText = cutoff > 200
? ttsText.substring(0, cutoff + 1)
: ttsText.substring(0, 500) + '…';
}
await speakText(ttsText);
}
} catch (err) {
console.error('sendToClaudeWithTts fehlgeschlagen:', err);
}
} }
async function speakText(text: string) { async function speakText(text: string) {

View file

@ -118,6 +118,18 @@ export async function initEventListeners(): Promise<void> {
}) })
); );
// Session erstellt — Hook feuern (fire-and-forget)
listeners.push(
await listen<{ id: string }>('session-created', (event) => {
const { id } = event.payload;
console.log('📂 Session-Created Event empfangen:', id);
invoke('fire_hook', {
event: 'SessionStart',
summary: JSON.stringify({ sessionId: id })
}).catch((err) => console.debug('Hook session-start fehlgeschlagen:', err));
})
);
// Agent gestartet // Agent gestartet
listeners.push( listeners.push(
await listen<AgentEvent>('agent-started', (event) => { await listen<AgentEvent>('agent-started', (event) => {
@ -210,6 +222,12 @@ export async function initEventListeners(): Promise<void> {
return ags; return ags;
}); });
// Hook: pre-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PreToolUse',
summary: JSON.stringify({ tool: tool || 'unknown', input: input || {} })
}).catch((err) => console.debug('Hook pre-tool-use fehlgeschlagen:', err));
// Wissens-Hints aus claude-db laden // Wissens-Hints aus claude-db laden
try { try {
// Command aus Input extrahieren (je nach Tool) // Command aus Input extrahieren (je nach Tool)
@ -241,9 +259,38 @@ export async function initEventListeners(): Promise<void> {
// Tool Ende // Tool Ende
listeners.push( listeners.push(
await listen<ToolEvent>('tool-end', (event) => { await listen<ToolEvent>('tool-end', (event) => {
const { id, success, output } = event.payload; const { id, tool, success, output } = event.payload;
console.log('✅ Tool Ende:', id, success ? 'OK' : 'FEHLER'); console.log('✅ Tool Ende:', id, success ? 'OK' : 'FEHLER');
completeToolCall(id, output, !success); completeToolCall(id, output, !success);
// Hook: post-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PostToolUse',
summary: JSON.stringify({ tool: tool || 'unknown', success: !!success, hasOutput: !!output })
}).catch((err) => console.debug('Hook post-tool-use fehlgeschlagen:', err));
// Pattern-Detektion bei Tool-Fehlern (fire-and-forget)
if (!success && output) {
invoke<{ id: string; name: string; description: string; new_approach: string } | null>(
'detect_issue',
{ errorMessage: output, context: tool || 'unknown' }
).then((pattern) => {
if (pattern) {
console.log('🔍 Bekanntes Problem erkannt:', pattern.name);
addMonitorEvent('error', `Bekanntes Problem: ${pattern.name}`, {
patternId: pattern.id,
beschreibung: pattern.description,
loesung: pattern.new_approach,
toolId: id,
tool: tool || 'unknown',
fehlerAuszug: output.substring(0, 200),
});
}
}).catch((err) => {
// Pattern-Detektion ist optional — Fehler nur loggen
console.debug('Pattern-Detektion fehlgeschlagen:', err);
});
}
}) })
); );