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

View file

@ -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<String, Str
}
// 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() {
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() {

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

View file

@ -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<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 ============
/// 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):

View file

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

View file

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

View file

@ -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 @@
</div>
<div class="chat-input">
<CommandPalette
bind:this={commandPaletteRef}
query={commandQuery}
visible={showCommandPalette}
onSelect={handleCommandSelect}
/>
{#if liveTranscript}
<div class="live-transcript">
<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;
}
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<UpdateStatus | null>(null);
let downloading = $state(false);
let progress = $state<DownloadProgress | null>(null);
let error = $state<string | null>(null);
let downloadedPath = $state<string | null>(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;
</script>
{#if $updateDialogOpen && updateInfo}
{#if $updateDialogOpen && (updateInfo || preparing)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" on:click={closeDialog}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
{#if isNoUpdateDialog}
<h2>✅ Aktuell</h2>
{:else if error && !updateInfo.available}
<h2>⚠️ Update-Check fehlgeschlagen</h2>
{#if preparing}
<h2>Update wird vorbereitet...</h2>
{:else if awaitingConfirmation}
<h2>Update bereit</h2>
{:else if isNoUpdateDialog}
<h2>Aktuell</h2>
{:else if error && !updateInfo?.available}
<h2>Update-Check fehlgeschlagen</h2>
{:else}
<h2>🔄 Update verfügbar</h2>
<h2>Update verfuegbar</h2>
{/if}
{#if !preparing}
<button class="close-btn" on:click={closeDialog}>&#x2715;</button>
{/if}
<button class="close-btn" on:click={closeDialog}>✕</button>
</div>
<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">
Du verwendest bereits die neueste Version:
<strong>v{updateInfo.current_version}</strong>
<strong>v{updateInfo?.current_version}</strong>
</p>
{:else if updateInfo.available}
{:else if updateInfo?.available}
<div class="version-info">
<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>
</div>
{#if updateInfo.release_notes}
<div class="release-notes">
<h3>Änderungen:</h3>
<h3>Aenderungen:</h3>
<div class="notes-content">
{@html updateInfo.release_notes.replace(/\n/g, '<br>')}
</div>
@ -202,23 +271,29 @@
</div>
<div class="modal-footer">
{#if isNoUpdateDialog}
<button class="btn btn-primary" on:click={closeDialog}>OK</button>
{:else if downloadedPath}
<button class="btn btn-primary" on:click={applyUpdate}>
Jetzt installieren & neustarten
{#if preparing}
<!-- Keine Buttons waehrend Vorbereitung -->
<span class="footer-hint">Bitte nicht schliessen...</span>
{:else if awaitingConfirmation}
<button class="btn btn-secondary" on:click={postponeUpdate}>
Spaeter
</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}
<button class="btn btn-disabled" disabled>
Wird heruntergeladen...
</button>
{:else if updateInfo.available}
<button class="btn btn-secondary" on:click={closeDialog}>Später</button>
{:else if updateInfo?.available}
<button class="btn btn-secondary" on:click={closeDialog}>Spaeter</button>
<button class="btn btn-primary" on:click={startDownload}>
Jetzt aktualisieren
</button>
{:else}
<button class="btn btn-primary" on:click={closeDialog}>Schließen</button>
<button class="btn btn-primary" on:click={closeDialog}>Schliessen</button>
{/if}
</div>
</div>
@ -348,6 +423,69 @@
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 {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);

View file

@ -2,7 +2,8 @@
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
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
let isListening = false;
@ -170,8 +171,55 @@
}
async function sendToClaudeWithTts(text: string) {
// TODO: Claude-Request mit TTS-Flag
// Für jetzt: Normaler Send + TTS der Antwort
// Nachricht an Claude senden und Antwort per TTS vorlesen
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) {

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
listeners.push(
await listen<AgentEvent>('agent-started', (event) => {
@ -210,6 +222,12 @@ export async function initEventListeners(): Promise<void> {
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
try {
// Command aus Input extrahieren (je nach Tool)
@ -241,9 +259,38 @@ export async function initEventListeners(): Promise<void> {
// Tool Ende
listeners.push(
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');
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);
});
}
})
);