Phase 1.5: Aktivierung & Quick-Wins [appimage]
All checks were successful
Build AppImage / build (push) Successful in 7m51s
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:
parent
29cce7fbd8
commit
0a447591da
14 changed files with 1133 additions and 50 deletions
55
CHANGELOG.md
Normal file
55
CHANGELOG.md
Normal 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
107
CLAUDE.md
Normal 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
|
||||||
|
```
|
||||||
46
ROADMAP.md
46
ROADMAP.md
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
140
src-tauri/src/commands.rs
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
213
src/lib/components/CommandPalette.svelte
Normal file
213
src/lib/components/CommandPalette.svelte
Normal 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>
|
||||||
|
|
@ -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(() => {
|
||||||
|
if (!$updateDialogOpen && !preparing) {
|
||||||
resetState();
|
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}>✕</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}
|
||||||
<p class="no-update-text">
|
<!-- Graceful-Shutdown-Anzeige -->
|
||||||
Du verwendest bereits die neueste Version:
|
<div class="preparing-container">
|
||||||
<strong>v{updateInfo.current_version}</strong>
|
<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>
|
</p>
|
||||||
{:else if updateInfo.available}
|
</div>
|
||||||
|
{:else if awaitingConfirmation}
|
||||||
|
<!-- Bestaetigungs-Dialog nach erfolgreichem Download -->
|
||||||
|
<div class="confirmation-container">
|
||||||
|
<div class="confirmation-icon">✔</div>
|
||||||
|
<p class="confirmation-text">
|
||||||
|
Update wurde heruntergeladen und verifiziert.
|
||||||
|
</p>
|
||||||
|
{#if updateInfo}
|
||||||
<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">→</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>
|
||||||
|
</p>
|
||||||
|
{:else if updateInfo?.available}
|
||||||
|
<div class="version-info">
|
||||||
|
<span class="current">v{updateInfo.current_version}</span>
|
||||||
|
<span class="arrow">→</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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue