Compare commits
13 commits
af663c6eee
...
0c095a4d49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c095a4d49 | ||
|
|
79b8525ede | ||
|
|
120715982b | ||
|
|
de90c2da19 | ||
|
|
314042a01f | ||
|
|
f51241efa6 | ||
|
|
51239d6639 | ||
|
|
0d292179e2 | ||
|
|
be65dee04a | ||
|
|
3f600b828e | ||
|
|
6b8f28145f | ||
|
|
b75c61faf4 | ||
|
|
9d73684ece |
41 changed files with 6356 additions and 263 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -27,3 +27,6 @@ Thumbs.db
|
||||||
|
|
||||||
# Package lock (use npm ci)
|
# Package lock (use npm ci)
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
vscode-extension/out/
|
||||||
|
vscode-extension/node_modules/
|
||||||
|
|
|
||||||
250
ROADMAP.md
250
ROADMAP.md
|
|
@ -33,6 +33,13 @@ Stand: 14.04.2026
|
||||||
| **Session-Management (Phase 6)** | ✅ | abaf4eb |
|
| **Session-Management (Phase 6)** | ✅ | abaf4eb |
|
||||||
| **Claude-DB Integration (Phase 8)** | ✅ | e6bd0de |
|
| **Claude-DB Integration (Phase 8)** | ✅ | e6bd0de |
|
||||||
| **Context-Management (Phase 9)** | ✅ | eb91e54 |
|
| **Context-Management (Phase 9)** | ✅ | eb91e54 |
|
||||||
|
| **Sprach-Interface (Phase 10)** | ✅ | 14.04.2026 |
|
||||||
|
| **Multi-Agent-Modi (Phase 11 — Basis)** | ✅ | 14.04.2026 |
|
||||||
|
| **Multi-Agent-Ausbau (Phase 11 — Vollendung)** | ✅ | 14.04.2026 |
|
||||||
|
| **Hook-System (Phase 12)** | ✅ | 14.04.2026 |
|
||||||
|
| **VSCodium-Integration (Phase 13)** | ✅ | 14.04.2026 |
|
||||||
|
| **Programm-Steuerung (Phase 14)** | ✅ | 14.04.2026 |
|
||||||
|
| **Schulungsmodus (Phase 15)** | ✅ | 14.04.2026 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -93,10 +100,12 @@ Stand: 14.04.2026
|
||||||
### Nachträglich implementiert
|
### Nachträglich implementiert
|
||||||
|
|
||||||
- ✅ **Token-basiertes Compacting** — Warnung bei ~40k Token mit Bestätigungs-Dialog (ab95af2)
|
- ✅ **Token-basiertes Compacting** — Warnung bei ~40k Token mit Bestätigungs-Dialog (ab95af2)
|
||||||
|
- ✅ **Claude-Session-ID** — SDK-Fortsetzung für nahtlose Konversationen (be65dee)
|
||||||
|
- Session-ID aus claude-result Event speichern
|
||||||
|
- Bei neuen Nachrichten automatisch fortsetzen
|
||||||
|
- Bridge nutzt `sessionId` in query() Optionen
|
||||||
|
|
||||||
### Noch offen (niedrigere Priorität)
|
### Alle Phase 6 Features implementiert!
|
||||||
|
|
||||||
- [ ] **Claude-Session-ID nutzen** — SDK-Fortsetzung mit `--resume`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -178,9 +187,13 @@ Die App hat keinen direkten Zugriff auf die zentrale Wissensbasis (`claude` DB a
|
||||||
|
|
||||||
- ✅ **"Das merken" im Chat** — 💡 Button bei Nachrichten, Modal-Dialog (56eb2f5)
|
- ✅ **"Das merken" im Chat** — 💡 Button bei Nachrichten, Modal-Dialog (56eb2f5)
|
||||||
|
|
||||||
### Noch offen (niedrigere Priorität)
|
### Nachträglich implementiert (14.04.2026)
|
||||||
|
|
||||||
- [ ] **Sticky Context** — Automatisch beim Chat-Start laden
|
- ✅ **Sticky Context Auto-Load** — Context wird beim App-Start automatisch geladen und an die Bridge gesendet
|
||||||
|
- `init_sticky_context` Tauri Command erstellt
|
||||||
|
- Frontend ruft Command in `+layout.svelte` auf
|
||||||
|
- Context-Status im Footer angezeigt (📌 +XXctx)
|
||||||
|
- Enthält Anzahl Einträge und Token-Schätzung
|
||||||
|
|
||||||
### Verifikation
|
### Verifikation
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -268,9 +281,15 @@ Compacting ist **notwendig** (Token-Limit, Kosten, Latenz), aber dabei geht krit
|
||||||
|
|
||||||
### Noch offen (niedrigere Priorität)
|
### Noch offen (niedrigere Priorität)
|
||||||
|
|
||||||
- [ ] **Auto-Extraction vor Compacting** — Hook automatisch auslösen
|
- [x] **Auto-Extraction vor Compacting** — Hook automatisch auslösen ✅ (14.04.2026)
|
||||||
|
- `performCompacting()` ruft jetzt `extract_context_before_compacting()` auf
|
||||||
|
- Entscheidungen, TODOs, Key-Insights werden vor Compacting archiviert
|
||||||
- [ ] **Validation** — Prüfen ob Claude den Context nutzt
|
- [ ] **Validation** — Prüfen ob Claude den Context nutzt
|
||||||
- [ ] **Wissens-Hints** — On-demand aus claude-db laden
|
- [x] **Wissens-Hints** — On-demand aus claude-db laden ✅ (14.04.2026)
|
||||||
|
- `get_tool_hints()` lädt relevante Einträge bei Tool-Start
|
||||||
|
- Intelligentes Keyword-Mapping (npm, git, docker, dolibarr, etc.)
|
||||||
|
- `activeKnowledgeHints` Store im Frontend
|
||||||
|
- Anzeige im KnowledgePanel
|
||||||
|
|
||||||
### Verifikation
|
### Verifikation
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -281,44 +300,104 @@ Compacting ist **notwendig** (Token-Limit, Kosten, Latenz), aber dabei geht krit
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 10: Sprach-Interface (Optional)
|
## Phase 10: Sprach-Interface ✅ ERLEDIGT
|
||||||
|
|
||||||
|
> **Implementiert:** 14.04.2026
|
||||||
|
|
||||||
### Technologie-Stack
|
### Technologie-Stack
|
||||||
|
|
||||||
| Komponente | Technologie | Ort |
|
| Komponente | Technologie | Ort |
|
||||||
|------------|-------------|-----|
|
|------------|-------------|-----|
|
||||||
| Speech-to-Text | Whisper.cpp | Lokal |
|
| Speech-to-Text | OpenAI Whisper API | Cloud |
|
||||||
| Voice Activity Detection | Silero VAD | Lokal |
|
| Voice Activity Detection | Custom (Audio Level) | Browser |
|
||||||
| Text-to-Speech | OpenAI TTS API | Cloud |
|
| Text-to-Speech | OpenAI TTS API | Cloud |
|
||||||
| Audio-Capture | Web Audio API | Browser |
|
| Audio-Capture | Web Audio API | Browser |
|
||||||
|
|
||||||
### Aufgaben
|
### Implementiert
|
||||||
|
|
||||||
- [ ] **Whisper Integration**
|
- [x] **Whisper Integration**
|
||||||
- [ ] whisper.cpp als Tauri-Sidecar oder WASM
|
- [x] OpenAI Whisper API für STT
|
||||||
- [ ] Streaming-Transkription
|
- [x] Deutsch als Default-Sprache
|
||||||
- [ ] Deutsch-Modell (small oder medium)
|
- [x] Audio-Upload als multipart/form-data
|
||||||
|
|
||||||
- [ ] **VAD (Voice Activity Detection)**
|
- [x] **VAD (Voice Activity Detection)**
|
||||||
- [ ] Erkennt wann User aufhort zu sprechen
|
- [x] Audio-Level-basierte Stille-Erkennung
|
||||||
- [ ] Pause > 1.5s → Nachricht senden
|
- [x] Pause > 1.5s → Aufnahme automatisch stoppen
|
||||||
|
- [x] Konfigurierbare Schwellwerte
|
||||||
|
|
||||||
- [ ] **TTS (Text-to-Speech)**
|
- [x] **TTS (Text-to-Speech)**
|
||||||
- [ ] OpenAI TTS API Integration
|
- [x] OpenAI TTS API Integration
|
||||||
- [ ] Streaming-Wiedergabe
|
- [x] 6 Stimmen verfügbar (Alloy, Echo, Fable, Onyx, Nova, Shimmer)
|
||||||
- [ ] Interrupt bei User-Sprache
|
- [x] Audio als Base64 zurückgeben
|
||||||
|
|
||||||
- [ ] **UI**
|
- [x] **UI**
|
||||||
- [ ] Mikrofon-Button in Chat
|
- [x] Mikrofon-Button neben Send-Button
|
||||||
- [ ] Pegel-Anzeige
|
- [x] Echtzeit-Pegel-Anzeige (animiert)
|
||||||
- [ ] Transkript live anzeigen
|
- [x] Aufnahme-Status (pulsierend)
|
||||||
|
- [x] Live-Transkript-Anzeige
|
||||||
|
|
||||||
### Aufwand
|
### Dateien
|
||||||
Gross — eigenes Teilprojekt, 2-3 Wochen
|
|
||||||
|
- `src-tauri/src/voice.rs` — Backend für STT/TTS
|
||||||
|
- `src/lib/components/ChatPanel.svelte` — UI + Audio-Capture
|
||||||
|
|
||||||
|
### Konfiguration
|
||||||
|
|
||||||
|
Benötigt `OPENAI_API_KEY` Umgebungsvariable für Whisper + TTS.
|
||||||
|
|
||||||
|
### Zukünftige Verbesserungen
|
||||||
|
|
||||||
|
- [ ] Lokales Whisper.cpp als Alternative (offline-fähig)
|
||||||
|
- [ ] Streaming-TTS für längere Texte
|
||||||
|
- [ ] Push-to-Talk Modus
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 11: Multi-Agent-Architektur (Context-Einsparung)
|
## Phase 11: Multi-Agent-Architektur (Context-Einsparung) ✅ ERLEDIGT
|
||||||
|
|
||||||
|
> **Basis-Implementierung:** 14.04.2026 (Tool-Filterung + Persistenz + UI-Badge)
|
||||||
|
|
||||||
|
### Implementiert (Basis)
|
||||||
|
|
||||||
|
- ✅ **Bridge: Tool-Filterung je Modus** (`scripts/claude-bridge.js`)
|
||||||
|
- Handlanger: `allowedTools = ['Task', 'TodoWrite']` — erzwingt Delegation
|
||||||
|
- Experten: `allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob']` — darf sondieren, nicht schreiben
|
||||||
|
- Solo/Auto: keine Einschränkung
|
||||||
|
- ✅ **Backend: Mode-Persistenz beim Bridge-Start** (`claude.rs`)
|
||||||
|
- Gespeicherter Modus wird nach `bridge-ready` automatisch gesetzt
|
||||||
|
- `agent_mode` Setting in SQLite
|
||||||
|
- ✅ **Generische Event-Weiterleitung** (`claude.rs`)
|
||||||
|
- Unbekannte Bridge-Events werden automatisch ans Frontend emit'et
|
||||||
|
- Löst `subagent-started`, `monitor-event`, `mode-changed` etc.
|
||||||
|
- ✅ **UI: Modus-Badge im Footer** (`+layout.svelte`)
|
||||||
|
- Farbcodiert: 👷 Handlanger (orange), 🎓 Experten (lila), 🤖 Auto (cyan)
|
||||||
|
- ✅ **Frontend: mode-changed Listener** (`events.ts`)
|
||||||
|
- Badge aktualisiert sich bei Modus-Wechsel live
|
||||||
|
|
||||||
|
### Ausbau-Implementierung (14.04.2026)
|
||||||
|
|
||||||
|
- ✅ **AUTO-Modus Keyword-Heuristik** (`chooseAutoMode()` in Bridge)
|
||||||
|
- < 80 Zeichen → solo
|
||||||
|
- "implementiere/refactor/baue…" → experten
|
||||||
|
- "lies/suche/finde/analysiere…" + > 120 Zeichen → handlanger
|
||||||
|
- > 300 Zeichen ohne klare Keywords → handlanger (safer default)
|
||||||
|
- Sendet `auto-mode-chosen` Event ans Frontend
|
||||||
|
- ✅ **Custom Sub-Agent-Definitionen via SDK `agents` Option**
|
||||||
|
- **Handlanger**: Sub-Agent "worker" auf **Haiku** — nur Ausführung, max 500 Tokens Rückmeldung
|
||||||
|
- **Experten**: 4 autonome Agents
|
||||||
|
- `research` — Code/Docs durchsuchen (Read, Grep, Glob, Bash)
|
||||||
|
- `implement` — Code schreiben nach Best-Practices (volle Tools)
|
||||||
|
- `test` — Testfälle wählen und ausführen (volle Tools)
|
||||||
|
- `review` — Code prüfen auf Qualität/Sicherheit (read-only)
|
||||||
|
- Experten erben Modell (`model: 'inherit'`), Handlanger nutzt explizit Haiku
|
||||||
|
- ✅ **AgentView: Delegations-Badge**
|
||||||
|
- Bei Sub-Agent-Knoten wird der aktuelle Delegations-Modus farbcodiert angezeigt
|
||||||
|
- 👷 Handlanger (orange), 🎓 Experten (lila), 🤖 Auto (cyan)
|
||||||
|
- ✅ **Orchestrator-Prompts angepasst**
|
||||||
|
- Verweisen explizit auf `subagent_type` der verfügbaren Custom Agents
|
||||||
|
- Anweisung: Formuliere WAS (Experten) oder EXAKT WIE (Handlanger)
|
||||||
|
|
||||||
|
### Alle Phase 11 Features implementiert!
|
||||||
|
|
||||||
### Die drei Agent-Modi (einstellbar!)
|
### Die drei Agent-Modi (einstellbar!)
|
||||||
|
|
||||||
|
|
@ -418,7 +497,22 @@ function chooseMode(task) {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 12: Hook-System für Automatisierung
|
## Phase 12: Hook-System für Automatisierung ✅ ERLEDIGT
|
||||||
|
|
||||||
|
> **Implementiert:** 14.04.2026
|
||||||
|
|
||||||
|
### Implementiert
|
||||||
|
- ✅ `src-tauri/src/hooks.rs` — HookManager mit Event-Registry + Ausführungs-Log
|
||||||
|
- ✅ HookEvent Enum: SessionStart, PreToolUse, PostToolUse, BeforeCompacting, AfterCompacting, ContextFailure, AgentStarted
|
||||||
|
- ✅ 5 Built-in Hooks: load-sticky-context, inject-knowledge-hints, save-failure-pattern, extract-critical-context, reinject-context
|
||||||
|
- ✅ Tauri-Commands: list_hooks, set_hook_enabled, get_hook_executions, fire_hook
|
||||||
|
- ✅ Event `hook-fired` ans Frontend
|
||||||
|
- ✅ HooksPanel.svelte im Rechts-Panel (🪝 Hooks Tab)
|
||||||
|
- Hooks gruppiert nach Event
|
||||||
|
- Ein/Aus-Toggle pro Hook
|
||||||
|
- Live Ausführungs-Log (50 Einträge)
|
||||||
|
|
||||||
|
### ⏸ Detail
|
||||||
|
|
||||||
### Konzept
|
### Konzept
|
||||||
|
|
||||||
|
|
@ -508,7 +602,34 @@ CREATE TABLE concept_cache (
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 13: VSCodium Integration (IDE-Steuerung)
|
## Phase 13: VSCodium Integration (IDE-Steuerung) ✅ ERLEDIGT
|
||||||
|
|
||||||
|
> **Implementiert:** 14.04.2026
|
||||||
|
|
||||||
|
### Implementiert
|
||||||
|
- ✅ **VSCode Extension** unter `vscode-extension/`
|
||||||
|
- WebSocket-Server auf Port 7890 (nur 127.0.0.1)
|
||||||
|
- Commands: ping, openFile, goToLine, formatDocument, findInFiles, openTerminal, getStatus, executeCommand
|
||||||
|
- Status-Bar Anzeige im Editor
|
||||||
|
- Auto-Connect konfigurierbar
|
||||||
|
- ✅ **src-tauri/src/ide.rs** — WebSocket-Client
|
||||||
|
- `ide_connect`, `ide_disconnect`, `ide_status`, `ide_call`
|
||||||
|
- tokio-tungstenite für WebSocket
|
||||||
|
- Pending-Requests Map für Response-Matching
|
||||||
|
- ✅ **IdePanel.svelte** — Teil von ProgramsPanel
|
||||||
|
- Verbindungsstatus + Port-Konfiguration
|
||||||
|
- Zeigt aktive Datei + Cursor-Zeile live
|
||||||
|
- Ping-Test Button
|
||||||
|
|
||||||
|
### Setup (einmalig)
|
||||||
|
```bash
|
||||||
|
cd vscode-extension
|
||||||
|
npm install
|
||||||
|
npm run compile
|
||||||
|
# Dann in VSCodium: F5 für Extension Development Host
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⏸ Detail
|
||||||
|
|
||||||
### Das Ziel
|
### Das Ziel
|
||||||
|
|
||||||
|
|
@ -589,7 +710,22 @@ Claude Desktop ←→ VSCode Extension Bridge ←→ VSCodium
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 14: Programm-Steuerung (Nicht nur IDE!)
|
## Phase 14: Programm-Steuerung (Nicht nur IDE!) ✅ ERLEDIGT
|
||||||
|
|
||||||
|
> **Implementiert:** 14.04.2026
|
||||||
|
|
||||||
|
### Implementiert
|
||||||
|
- ✅ **src-tauri/src/programs.rs** — 3-teiliges Modul
|
||||||
|
- **D-Bus**: `dbus_call(service, path, method, args)` + `dbus_list_services()`
|
||||||
|
- **Xvfb**: `xvfb_start/stop/status` + `xvfb_screenshot` via scrot
|
||||||
|
- **Playwright-Info**: Verweis auf MCP-Server für Browser-Automation
|
||||||
|
- ✅ **ProgramsPanel.svelte** — Tab im Mittel-Panel (🖥️ Programme)
|
||||||
|
- 4 Section-Tabs: VSCodium / Playwright / D-Bus / Xvfb
|
||||||
|
- D-Bus Service-Liste mit Live-Abruf
|
||||||
|
- Xvfb-Start/Stop + Screenshot-Anzeige
|
||||||
|
- ✅ **IdePanel** eingebettet in Programme-Tab
|
||||||
|
|
||||||
|
### ⏸ Detail
|
||||||
|
|
||||||
### Das Problem
|
### Das Problem
|
||||||
|
|
||||||
|
|
@ -714,7 +850,34 @@ Aufgabe erhalten
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 15: Präsentations- & Schulungsmodus (Lehrer-Modus)
|
## Phase 15: Präsentations- & Schulungsmodus (Lehrer-Modus) ✅ ERLEDIGT
|
||||||
|
|
||||||
|
> **Implementiert:** 14.04.2026
|
||||||
|
|
||||||
|
### Implementiert
|
||||||
|
- ✅ **src-tauri/src/teaching.rs** — öffnet separates Webview-Fenster
|
||||||
|
- Tauri-Commands: `presentation_open/close/send_slide/clear`
|
||||||
|
- Events `presentation-slide` + `presentation-clear`
|
||||||
|
- ✅ **Route `/presentation/+page.svelte`** — eigenes Fenster
|
||||||
|
- Slide-Navigation (←/→/Space)
|
||||||
|
- Geschwindigkeits-Slider (60-400 WPM)
|
||||||
|
- Play/Pause-Steuerung
|
||||||
|
- ✅ **MermaidDiagram.svelte** — Live-Rendering mit dark theme
|
||||||
|
- Dynamischer `import('mermaid')` zur Laufzeit
|
||||||
|
- Fehleranzeige bei Parse-Fehlern
|
||||||
|
- ✅ **AnimatedCode.svelte** — Tipp-Animation
|
||||||
|
- Konfigurable WPM (Wörter pro Minute)
|
||||||
|
- Play/Pause/Reset/Skip Controls
|
||||||
|
- Blinkender Cursor während Animation
|
||||||
|
- ✅ **🎓 Button in der Titelbar** — öffnet Präsentationsfenster
|
||||||
|
- ✅ **Capabilities** um `core:webview:allow-create-webview-window` erweitert
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
```bash
|
||||||
|
npm install # wegen neuer mermaid dependency
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⏸ Detail
|
||||||
|
|
||||||
### Das Ziel
|
### Das Ziel
|
||||||
|
|
||||||
|
|
@ -1078,11 +1241,17 @@ Vollständige Informationen:
|
||||||
|
|
||||||
- ✅ **Log-Export** — JSON/Text Download Buttons (88f2d22)
|
- ✅ **Log-Export** — JSON/Text Download Buttons (88f2d22)
|
||||||
- ✅ **Live Token-Anzeige** — Im Chat-Header mit Farbcodierung (84dc806)
|
- ✅ **Live Token-Anzeige** — Im Chat-Header mit Farbcodierung (84dc806)
|
||||||
|
- ✅ **Backend-Persistierung** — SQLite für Monitor-Events (9d73684)
|
||||||
|
- `monitor_events` Tabelle mit Auto-Cleanup (7 Tage)
|
||||||
|
- CRUD-Methoden + Tauri Commands
|
||||||
|
- Frontend lädt Events beim Start, speichert bei neuen Events
|
||||||
|
- ✅ **Performance-Metriken** — Kosten-Tracker + Statistiken (6b8f281)
|
||||||
|
- PerformancePanel.svelte mit Kosten, Token, Latenz
|
||||||
|
- Neuer Tab "📈 Kosten" im mittleren Panel
|
||||||
|
- Latenz-Verteilung (Min, P50, P95, Max)
|
||||||
|
- Session-Vergleich
|
||||||
|
|
||||||
### Noch offen
|
### Alle Phase 16 Features implementiert!
|
||||||
|
|
||||||
- [ ] **Backend-Persistierung** — SQLite für Events
|
|
||||||
- [ ] **Performance-Metriken** — Grafiken, Kosten-Tracker
|
|
||||||
|
|
||||||
### Sensitive Daten maskieren!
|
### Sensitive Daten maskieren!
|
||||||
|
|
||||||
|
|
@ -1172,8 +1341,8 @@ END;
|
||||||
|
|
||||||
## Technische Schulden
|
## Technische Schulden
|
||||||
|
|
||||||
- [ ] Dead Code in `memory.rs` (MemorySystem struct ungenutzt)
|
- [x] Dead Code in `memory.rs` (MemorySystem struct entfernt) ✅ (14.04.2026)
|
||||||
- [ ] Warnings bei `cargo check` beheben
|
- [x] Warnings bei `cargo check` beheben ✅ (14.04.2026)
|
||||||
- [ ] TypeScript strict mode aktivieren
|
- [ ] TypeScript strict mode aktivieren
|
||||||
- [ ] E2E Tests mit Playwright
|
- [ ] E2E Tests mit Playwright
|
||||||
- [ ] CI/CD Pipeline (Forgejo Runner)
|
- [ ] CI/CD Pipeline (Forgejo Runner)
|
||||||
|
|
@ -1216,3 +1385,6 @@ CARGO_TARGET_DIR=/tmp/claude-desktop-target nix-shell --run "npx tauri build"
|
||||||
| 14.04.2026 | ab95af2 | Token-basiertes Compacting mit Dialog |
|
| 14.04.2026 | ab95af2 | Token-basiertes Compacting mit Dialog |
|
||||||
| 14.04.2026 | 84dc806 | Live Token-Anzeige im Chat-Header |
|
| 14.04.2026 | 84dc806 | Live Token-Anzeige im Chat-Header |
|
||||||
| 14.04.2026 | 88f2d22 | Log-Export im Monitor-Panel |
|
| 14.04.2026 | 88f2d22 | Log-Export im Monitor-Panel |
|
||||||
|
| 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 |
|
||||||
|
|
|
||||||
75
TEST-ROADMAP.md
Normal file
75
TEST-ROADMAP.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Claude Desktop — Test-Roadmap (Fortsetzung)
|
||||||
|
|
||||||
|
**Stand:** 14.04.2026 · Session-Ende nahe Token-Limit
|
||||||
|
|
||||||
|
## Was bereits getestet & funktioniert
|
||||||
|
- ✅ Hooks-Panel: 5 Built-in Hooks sichtbar & toggelbar
|
||||||
|
- ✅ D-Bus: 80+ Services laden (Programme → D-Bus)
|
||||||
|
- ✅ Schulungs-Fenster öffnet via 🎓-Button
|
||||||
|
- ✅ Modus-Auswahl in Settings + Footer-Badge
|
||||||
|
- ✅ Chat funktioniert (nach Bridge-Fixes: `resume` statt `sessionId`, claude_session_id nur bei erstem Call setzen)
|
||||||
|
- ✅ Sub-Agent erscheint im Tree (nach `addAgent({id})` Fix)
|
||||||
|
- ✅ Sub-Agent "Nur aktive" Toggle
|
||||||
|
- ✅ Filter-Dropdown im Monitor-Panel sichtbar
|
||||||
|
- ✅ Copy-Button in Chat-Nachrichten
|
||||||
|
- ✅ Error-Banner mit kopierbarem Text im Programme-Panel
|
||||||
|
|
||||||
|
## Offene Bugs (Reihenfolge der Priorität)
|
||||||
|
|
||||||
|
### 1. Chat-Antwort bei komplexen Flows fehlt (HALB GEFIXT)
|
||||||
|
**Symptom:** Bei Handlanger-Chats mit Sub-Agent wird die finale Antwort nicht im Chat angezeigt.
|
||||||
|
**Ursache:** Streaming-Text-Events kommen nicht, nur `result.text` am Ende.
|
||||||
|
**Fix (drin):** Fallback in `events.ts` auf `result.text` wenn `content` leer.
|
||||||
|
**Zu verifizieren:** Nächster Chat im Handlanger-Modus — erscheint jetzt die Antwort?
|
||||||
|
|
||||||
|
### 2. Date-Panic in Wissensbasis
|
||||||
|
**Symptom:** `Couldn't convert Row... Date(...) to String` bei jeder Wissens-Suche / Tool-Hints.
|
||||||
|
**Ursache:** MySQL liefert TIMESTAMP als `Value::Date`, Tupel erwartet `String`.
|
||||||
|
**Fix:** 7 SELECTs in [knowledge.rs](src-tauri/src/knowledge.rs) einzeln auf `chrono::NaiveDateTime` umstellen.
|
||||||
|
**Nicht:** `replace_all` auf "created_at, updated_at" — das zerstört Rust-Tupel-Identifier (schon 1× passiert).
|
||||||
|
|
||||||
|
### 3. VSCodium-Extension nicht getestet
|
||||||
|
**Was zu tun:**
|
||||||
|
- `cd vscode-extension && npm run compile` (bereits OK)
|
||||||
|
- VSCodium öffnen, Extension via F5 in Dev-Host laden
|
||||||
|
- App: Programme → 🧩 VSCodium → Port 7890 → Verbinden
|
||||||
|
- Ping-Test, Datei öffnen
|
||||||
|
|
||||||
|
### 4. Xvfb-Screenshot fehlt Tool
|
||||||
|
**Status:** Xvfb-Start funktioniert, Screenshot braucht `imagemagick` (scrot/ffmpeg-x11 fehlen in NixOS-Build).
|
||||||
|
**Fix:** `imagemagick` in `/etc/nixos/configuration.nix` → `nixos-rebuild switch`.
|
||||||
|
|
||||||
|
### 5. Experten-Modus nicht getestet
|
||||||
|
**Analog zu Handlanger:** neue Session, Experten-Modus, Aufgabe mit Research/Implement-Charakter.
|
||||||
|
|
||||||
|
### 6. Haiku-Kostenersparnis funktioniert nicht
|
||||||
|
**Status:** Sub-Agents laufen auf Opus (inherit vom Main). Custom `agents`-Option in SDK scheint ignoriert zu werden bzw. spawnt Agents ohne Tools (halluziniert).
|
||||||
|
**Nächster Ansatz:** Im Orchestrator-Prompt Claude explizit vorgeben `model: "haiku"` in Task-Calls zu setzen. Ob das SDK das respektiert, ist offen.
|
||||||
|
|
||||||
|
## Uncommitted Changes (alles sinnvolle Fixes — lohnt sich zu committen)
|
||||||
|
- `scripts/claude-bridge.js` — resume-Fix, tools-Whitelist, handleToolUse/Result Helper, Dedup
|
||||||
|
- `src-tauri/src/claude.rs` — claude_session_id nur 1× setzen, generic event emit
|
||||||
|
- `src-tauri/src/knowledge.rs` — IP+PW korrekt (155.11/8715)
|
||||||
|
- `src/lib/stores/events.ts` — mode-changed Listener, result.text Fallback, addAgent({id})
|
||||||
|
- `src/lib/components/ChatPanel.svelte` — Copy-Button, Typing-Dots in Bubble (kein Doppel-Header)
|
||||||
|
- `src/lib/components/AgentView.svelte` — Nur-aktive-Toggle, Delegations-Badge, Tool-Count hidden bei 0
|
||||||
|
- `src/lib/components/ProgramsPanel.svelte` — Error-Banner mit Copy
|
||||||
|
- `src/lib/components/MonitorPanel.svelte` — Filter-Dropdown Styling
|
||||||
|
- `src/routes/+layout.svelte` — agent_mode beim Start laden
|
||||||
|
- `src/routes/+page.svelte` — Tabs Programme + Hooks
|
||||||
|
|
||||||
|
## Schnellstart nach Neustart
|
||||||
|
```bash
|
||||||
|
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
|
||||||
|
CARGO_TARGET_DIR=/tmp/claude-desktop-target nix-shell --run "npx tauri dev"
|
||||||
|
# Dauert ~15s beim ersten Start nach Reboot wenn /tmp leer ist
|
||||||
|
```
|
||||||
|
|
||||||
|
## DB-Reset wenn Claude-Session-IDs veraltet
|
||||||
|
```bash
|
||||||
|
nix-shell -p sqlite --run 'sqlite3 "/home/data/.local/share/de.alles-watt-laeuft.claude-desktop/claude-desktop.db" "UPDATE sessions SET claude_session_id = NULL;"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nächster Commit
|
||||||
|
Alles zusammen ein großer Bugfix-Commit mit Titel:
|
||||||
|
> Fix: Resume, tools-Whitelist, Sub-Agent-Tree, Date-Handling, UI-Polish
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
|
"mermaid": "^11.4.0",
|
||||||
"paneforge": "^1.0.2"
|
"paneforge": "^1.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,154 @@ let activeAbort = null;
|
||||||
let currentAgentId = null;
|
let currentAgentId = null;
|
||||||
let currentModel = process.env.CLAUDE_MODEL || 'opus';
|
let currentModel = process.env.CLAUDE_MODEL || 'opus';
|
||||||
|
|
||||||
|
// Agent-Modus (solo | handlanger | experten | auto)
|
||||||
|
let agentMode = 'solo';
|
||||||
|
|
||||||
// Sticky Context (Schicht 1) — wird bei JEDEM API-Call injiziert
|
// Sticky Context (Schicht 1) — wird bei JEDEM API-Call injiziert
|
||||||
let stickyContext = '';
|
let stickyContext = '';
|
||||||
|
|
||||||
|
// ============ Orchestrator Prompts ============
|
||||||
|
|
||||||
|
const ORCHESTRATOR_PROMPTS = {
|
||||||
|
handlanger: `
|
||||||
|
Du bist der HAUPT-AGENT im HANDLANGER-MODUS.
|
||||||
|
|
||||||
|
KRITISCH: Dir stehen NUR Task + TodoWrite zur Verfügung.
|
||||||
|
Du kannst NICHT direkt lesen, suchen oder ausführen — du MUSST delegieren!
|
||||||
|
|
||||||
|
Task-Tool mit den RICHTIGEN Sub-Agent-Typen:
|
||||||
|
- "general-purpose" — Standard-Agent mit VOLLEM Tool-Zugriff (Bash, Read, Write, Grep, Glob).
|
||||||
|
Nutze diesen für JEDE Aufgabe die Bash/Shell benötigt (ls, cat, find, grep auf System-Ebene).
|
||||||
|
- "Explore" — read-only Agent. NUR für reine Code-/Dateisuche innerhalb des Projekts.
|
||||||
|
Hat KEINEN Bash-Zugriff! Nicht für Systembefehle wie "ls /etc" verwenden.
|
||||||
|
|
||||||
|
Arbeitsweise (verbindlich):
|
||||||
|
1. Wähle den RICHTIGEN subagent_type basierend auf der Aufgabe.
|
||||||
|
2. Rufe das Task-Tool auf mit EXAKTER Anweisung.
|
||||||
|
3. Prüfe im Ergebnis das "tool_uses"-Feld. Wenn tool_uses:0 → Sub-Agent hat halluziniert!
|
||||||
|
In dem Fall: Neuer Task-Call mit "general-purpose" statt "Explore".
|
||||||
|
4. Verarbeite das Ergebnis und gib dem User die Zusammenfassung.
|
||||||
|
|
||||||
|
Halluziniere NIEMALS Dateilisten aus dem Gedächtnis — delegiere immer real.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
- "List /etc files" → Task(subagent_type:"general-purpose", prompt:"Run 'ls -1 /etc | sort' and return the output")
|
||||||
|
- "Find handleError in src/" → Task(subagent_type:"Explore", prompt:"Grep for 'handleError' in src/")
|
||||||
|
- "Read /etc/hosts" → Task(subagent_type:"general-purpose", prompt:"cat /etc/hosts and return output")
|
||||||
|
`,
|
||||||
|
|
||||||
|
experten: `
|
||||||
|
Du bist der HAUPT-AGENT und arbeitest im EXPERTEN-MODUS.
|
||||||
|
|
||||||
|
WICHTIG: Du koordinierst vier autonome Experten-Agents!
|
||||||
|
|
||||||
|
Task-Tool Sub-Agent-Typen (autonome Experten):
|
||||||
|
- **research**: Durchsucht Code/Docs, findet Infos. Wähle diesen für "Finde heraus…", "Wo ist…"
|
||||||
|
- **implement**: Schreibt Code nach Best-Practices. Wähle diesen für "Implementiere…", "Baue…"
|
||||||
|
- **test**: Schreibt und führt Tests. Wähle diesen für "Teste…"
|
||||||
|
- **review**: Prüft Code auf Qualität/Sicherheit. Wähle diesen für "Prüfe…"
|
||||||
|
|
||||||
|
Arbeitsweise:
|
||||||
|
1. ZERLEGE die Aufgabe in Experten-Bereiche
|
||||||
|
2. DELEGIERE via Task(subagent_type: "research"|"implement"|"test"|"review", prompt: "...")
|
||||||
|
3. Formuliere das WAS, nicht das WIE — die Experten planen selbst
|
||||||
|
4. Integriere die Zusammenfassungen, orchestriere weitere Schritte
|
||||||
|
|
||||||
|
Beispiel-Delegationen:
|
||||||
|
- Task(subagent_type:"research", prompt:"Finde heraus wie Authentication implementiert ist")
|
||||||
|
- Task(subagent_type:"implement", prompt:"Füge OAuth2-Support hinzu mit Token-Refresh")
|
||||||
|
- Task(subagent_type:"test", prompt:"Teste die neue Auth-Funktionalität")
|
||||||
|
- Task(subagent_type:"review", prompt:"Prüfe die OAuth-Implementierung auf Sicherheitsprobleme")
|
||||||
|
`,
|
||||||
|
|
||||||
|
auto: `
|
||||||
|
Du analysierst Aufgaben und wählst den optimalen Arbeitsmodus.
|
||||||
|
|
||||||
|
Entscheide basierend auf:
|
||||||
|
- SOLO: Einfache, schnelle Aufgaben (Typo fix, Code erklären, einzelne Datei ändern)
|
||||||
|
- HANDLANGER: Koordinations-intensive Aufgaben (viele Dateien lesen, Bug in großer Codebase)
|
||||||
|
- EXPERTEN: Komplexe Features (neues System implementieren, großes Refactoring)
|
||||||
|
|
||||||
|
Teile deine Wahl am Anfang mit: "[Modus: X] Begründung"
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Custom Sub-Agent Definitionen ============
|
||||||
|
// Werden je nach Modus an query() übergeben
|
||||||
|
|
||||||
|
const HANDLANGER_AGENTS = {
|
||||||
|
worker: {
|
||||||
|
description: 'Führt exakte Anweisungen des Haupt-Agents aus (lesen, suchen, triviale Edits). Denkt NICHT selbst, berichtet komprimiert zurück.',
|
||||||
|
// Günstiges Modell — Handlanger muss nicht planen
|
||||||
|
model: 'haiku',
|
||||||
|
tools: ['Read', 'Grep', 'Glob', 'Bash', 'Edit', 'Write'],
|
||||||
|
prompt: `Du bist ein HANDLANGER-Agent.
|
||||||
|
|
||||||
|
WICHTIG:
|
||||||
|
1. Führe GENAU aus was der Haupt-Agent verlangt — denke NICHT selbst
|
||||||
|
2. Plane keine eigene Herangehensweise
|
||||||
|
3. Berichte KOMPRIMIERT zurück (max. 500 Tokens):
|
||||||
|
- Bei Read: Relevante Zeilen/Passagen, keine Volltext-Dumps
|
||||||
|
- Bei Grep: Liste der Treffer mit Zeilennummern
|
||||||
|
- Bei Bash: Exit-Code + wichtigste Ausgabe-Zeilen
|
||||||
|
- Bei Edit/Write: Bestätigung was geändert wurde
|
||||||
|
4. Keine Erklärungen, keine Vorschläge — nur das verlangte Ergebnis`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPERTEN_AGENTS = {
|
||||||
|
research: {
|
||||||
|
description: 'Durchsucht Code und Dokumentation autonom. Findet selbst heraus was relevant ist.',
|
||||||
|
model: 'inherit',
|
||||||
|
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||||
|
prompt: `Du bist ein RESEARCH-Experte.
|
||||||
|
|
||||||
|
Du bekommst eine Frage — plane selbst wie du sie beantwortest:
|
||||||
|
- Wähle selbst welche Dateien/Patterns zu suchen sind
|
||||||
|
- Priorisiere wichtige Infos
|
||||||
|
- Berichte strukturiert: Was gefunden, wo (Pfade/Zeilen), warum relevant
|
||||||
|
- Max 1000 Tokens Zusammenfassung`,
|
||||||
|
},
|
||||||
|
implement: {
|
||||||
|
description: 'Schreibt Code-Änderungen nach Best-Practices. Entscheidet selbst über Architektur und Details.',
|
||||||
|
model: 'inherit',
|
||||||
|
tools: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash'],
|
||||||
|
prompt: `Du bist ein IMPLEMENT-Experte.
|
||||||
|
|
||||||
|
Du bekommst das WAS — entscheide selbst das WIE:
|
||||||
|
- Lies relevanten Code zum Verständnis
|
||||||
|
- Implementiere nach Best-Practices (Codierrichtlinien des Projekts beachten)
|
||||||
|
- Berichte: welche Dateien geändert, was war der Kern, was beibehalten
|
||||||
|
- Max 800 Tokens Zusammenfassung`,
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
description: 'Schreibt und führt Tests aus. Wählt selbst sinnvolle Testfälle.',
|
||||||
|
model: 'inherit',
|
||||||
|
tools: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash'],
|
||||||
|
prompt: `Du bist ein TEST-Experte.
|
||||||
|
|
||||||
|
Du bekommst ein Feature — wähle selbst passende Testfälle:
|
||||||
|
- Happy Path + sinnvolle Edge Cases
|
||||||
|
- Nutze vorhandene Test-Infrastruktur
|
||||||
|
- Berichte: Tests geschrieben (Anzahl), was ist abgedeckt, passed/failed
|
||||||
|
- Max 500 Tokens Zusammenfassung`,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
description: 'Prüft Code auf Qualität, Sicherheit und Stil. Findet selbst Probleme.',
|
||||||
|
model: 'inherit',
|
||||||
|
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||||
|
prompt: `Du bist ein REVIEW-Experte.
|
||||||
|
|
||||||
|
Du bekommst Code zum Prüfen — finde selbst Probleme:
|
||||||
|
- Sicherheit (Injections, Secrets, Auth)
|
||||||
|
- Performance (N+1, unnötige Loops)
|
||||||
|
- Fehlerbehandlung (Boundary-Cases)
|
||||||
|
- Stil (Konsistenz mit Projekt)
|
||||||
|
- Berichte strukturiert nach Schwere (kritisch/warnung/info)
|
||||||
|
- Max 800 Tokens`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Subagent-Tracking
|
// Subagent-Tracking
|
||||||
// Map: toolUseId → { agentId, parentId, type, task, depth }
|
// Map: toolUseId → { agentId, parentId, type, task, depth }
|
||||||
const activeSubagents = new Map();
|
const activeSubagents = new Map();
|
||||||
|
|
@ -85,6 +230,38 @@ function sendMonitorEvent(type, summary, details = {}, options = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AUTO-Modus: Heuristik wählt passenden Modus basierend auf Aufgabe
|
||||||
|
// Rückgabe: 'solo' | 'handlanger' | 'experten'
|
||||||
|
function chooseAutoMode(message) {
|
||||||
|
const text = (message || '').toLowerCase();
|
||||||
|
const charCount = text.length;
|
||||||
|
|
||||||
|
// Keywords die klar auf Experten-Aufgaben hinweisen (komplexe, parallelisierbare Arbeit)
|
||||||
|
const expertKeywords = [
|
||||||
|
'implementiere', 'implementier ', 'refactor', 'architektur', 'entwickle',
|
||||||
|
'erstelle feature', 'feature ', 'design', 'baue ', 'optimiere',
|
||||||
|
'migration', 'umbau', 'umstruktur',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Keywords die auf Handlanger-Aufgaben hinweisen (viel koordinieren/sammeln)
|
||||||
|
const handlangerKeywords = [
|
||||||
|
'lies ', 'suche ', 'finde ', 'zeig mir ', 'untersuche',
|
||||||
|
'analysiere', 'durchsuche', 'alle dateien', 'sammle',
|
||||||
|
'liste alle', 'vergleiche',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Klar triviale Aufgaben → solo
|
||||||
|
if (charCount < 80) return 'solo';
|
||||||
|
|
||||||
|
if (expertKeywords.some(kw => text.includes(kw))) return 'experten';
|
||||||
|
if (handlangerKeywords.some(kw => text.includes(kw)) && charCount > 120) return 'handlanger';
|
||||||
|
|
||||||
|
// Längere Nachrichten ohne klare Keywords → handlanger (safer default)
|
||||||
|
if (charCount > 300) return 'handlanger';
|
||||||
|
|
||||||
|
return 'solo';
|
||||||
|
}
|
||||||
|
|
||||||
// Tool-Input für Logging kürzen (sensitive Daten maskieren)
|
// Tool-Input für Logging kürzen (sensitive Daten maskieren)
|
||||||
function summarizeToolInput(tool, input) {
|
function summarizeToolInput(tool, input) {
|
||||||
if (!input) return '';
|
if (!input) return '';
|
||||||
|
|
@ -119,7 +296,7 @@ function summarizeToolInput(tool, input) {
|
||||||
|
|
||||||
// ============ Claude Agent SDK ============
|
// ============ Claude Agent SDK ============
|
||||||
|
|
||||||
async function sendMessage(message, requestId, model = null, contextOverride = null) {
|
async function sendMessage(message, requestId, model = null, contextOverride = null, resumeSessionId = null) {
|
||||||
// Modell für diese Anfrage (Parameter > State > Default)
|
// Modell für diese Anfrage (Parameter > State > Default)
|
||||||
const useModel = model || currentModel;
|
const useModel = model || currentModel;
|
||||||
|
|
||||||
|
|
@ -129,82 +306,122 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
||||||
currentAgentId = randomUUID();
|
currentAgentId = randomUUID();
|
||||||
activeAbort = new AbortController();
|
activeAbort = new AbortController();
|
||||||
|
|
||||||
|
const isResuming = !!resumeSessionId;
|
||||||
|
|
||||||
sendEvent('agent-started', {
|
sendEvent('agent-started', {
|
||||||
id: currentAgentId,
|
id: currentAgentId,
|
||||||
type: 'Main',
|
type: 'Main',
|
||||||
task: message.substring(0, 100),
|
task: message.substring(0, 100),
|
||||||
model: useModel,
|
model: useModel,
|
||||||
|
resuming: isResuming,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monitor: Agent gestartet
|
// Monitor: Agent gestartet
|
||||||
sendMonitorEvent('agent', `Main Agent gestartet (${useModel})`, {
|
const resumeInfo = isResuming ? ' (Fortsetzung)' : '';
|
||||||
|
sendMonitorEvent('agent', `Main Agent gestartet (${useModel})${resumeInfo}`, {
|
||||||
agentId: currentAgentId,
|
agentId: currentAgentId,
|
||||||
model: useModel,
|
model: useModel,
|
||||||
task: message.substring(0, 100),
|
task: message.substring(0, 100),
|
||||||
contextTokens: useContext ? Math.ceil(useContext.length / 4) : 0,
|
contextTokens: useContext ? Math.ceil(useContext.length / 4) : 0,
|
||||||
|
resumeSessionId: resumeSessionId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monitor: API-Request
|
// Monitor: API-Request
|
||||||
const contextInfo = useContext ? ` +${Math.ceil(useContext.length / 4)} ctx` : '';
|
const contextInfo = useContext ? ` +${Math.ceil(useContext.length / 4)} ctx` : '';
|
||||||
sendMonitorEvent('api', `→ ${useModel}${contextInfo}`, {
|
sendMonitorEvent('api', `→ ${useModel}${contextInfo}${resumeInfo}`, {
|
||||||
model: useModel,
|
model: useModel,
|
||||||
promptLength: message.length,
|
promptLength: message.length,
|
||||||
contextLength: useContext?.length || 0,
|
contextLength: useContext?.length || 0,
|
||||||
maxTurns: 25,
|
maxTurns: 25,
|
||||||
|
resumeSessionId: resumeSessionId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel });
|
// AUTO-Modus: Effektiven Modus aus der Nachricht ableiten
|
||||||
|
let effectiveMode = agentMode;
|
||||||
|
if (agentMode === 'auto') {
|
||||||
|
effectiveMode = chooseAutoMode(message);
|
||||||
|
sendEvent('auto-mode-chosen', { chosen: effectiveMode, messageLength: message.length });
|
||||||
|
sendMonitorEvent('agent', `Auto-Modus gewählt: ${effectiveMode}`, {
|
||||||
|
chosen: effectiveMode,
|
||||||
|
messageLength: message.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Nachricht mit Context kombinieren
|
sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel, resuming: isResuming, mode: agentMode, effectiveMode });
|
||||||
const fullPrompt = useContext
|
|
||||||
? `${useContext}\n\n---\n\n${message}`
|
// Orchestrator-Prompt für nicht-Solo Modi (nutzt effektiven Modus)
|
||||||
: message;
|
let orchestratorPrompt = '';
|
||||||
|
if (effectiveMode !== 'solo' && ORCHESTRATOR_PROMPTS[effectiveMode]) {
|
||||||
|
orchestratorPrompt = ORCHESTRATOR_PROMPTS[effectiveMode];
|
||||||
|
sendMonitorEvent('agent', `Orchestrator-Modus: ${effectiveMode}`, { mode: effectiveMode });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nachricht mit Context und Orchestrator kombinieren
|
||||||
|
let fullPrompt = message;
|
||||||
|
if (orchestratorPrompt) {
|
||||||
|
fullPrompt = `${orchestratorPrompt}\n\n---\n\n${message}`;
|
||||||
|
}
|
||||||
|
if (useContext) {
|
||||||
|
fullPrompt = `${useContext}\n\n---\n\n${fullPrompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
let usedModel = useModel;
|
let usedModel = useModel;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conversation = query({
|
// Query-Optionen zusammenstellen
|
||||||
prompt: fullPrompt,
|
const queryOptions = {
|
||||||
options: {
|
|
||||||
model: useModel,
|
model: useModel,
|
||||||
maxTurns: 25,
|
maxTurns: 25,
|
||||||
abortController: activeAbort,
|
abortController: activeAbort,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
// Session-ID für Fortsetzung — SDK erwartet `resume`, nicht `sessionId`
|
||||||
|
if (resumeSessionId) {
|
||||||
|
queryOptions.resume = resumeSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In @anthropic-ai/claude-agent-sdk 0.2.104 vererbt sich JEDE tools/disallowedTools-
|
||||||
|
// Konfiguration auf Sub-Agents. Es gibt keine saubere Trennung Main vs. Sub.
|
||||||
|
// Daher: Tool-Preset fuer alle Modi freischalten, Restriktion via System-Prompt.
|
||||||
|
queryOptions.tools = { type: 'preset', preset: 'claude_code' };
|
||||||
|
queryOptions.allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'];
|
||||||
|
|
||||||
|
if (effectiveMode === 'handlanger') {
|
||||||
|
sendMonitorEvent('agent', 'Handlanger: Delegation per System-Prompt durchgesetzt', {
|
||||||
|
mode: effectiveMode,
|
||||||
|
});
|
||||||
|
} else if (effectiveMode === 'experten') {
|
||||||
|
sendMonitorEvent('agent', 'Experten: Multi-Agent via System-Prompt', {
|
||||||
|
mode: effectiveMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversation = query({
|
||||||
|
prompt: fullPrompt,
|
||||||
|
options: queryOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const event of conversation) {
|
// Dedupe: Manche Tool-Events kommen sowohl in assistant-Blocks
|
||||||
switch (event.type) {
|
// als auch als standalone tool_use Event. Via toolUseId deduplizieren.
|
||||||
case 'assistant':
|
const handledTools = new Set();
|
||||||
// Text aus der Nachricht extrahieren
|
|
||||||
if (event.message?.content) {
|
|
||||||
for (const block of event.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
fullText += block.text;
|
|
||||||
sendEvent('text', { text: block.text });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.message?.model) {
|
|
||||||
usedModel = event.message.model;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool_use': {
|
// Tool-Use handhaben — funktioniert sowohl fuer standalone tool_use Events
|
||||||
const toolId = event.tool_use_id || randomUUID();
|
// als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht
|
||||||
const toolName = event.name || 'unknown';
|
function handleToolUse(ev) {
|
||||||
const toolInput = event.input || {};
|
const toolId = ev.tool_use_id || ev.id || randomUUID();
|
||||||
|
if (handledTools.has(toolId)) return;
|
||||||
|
handledTools.add(toolId);
|
||||||
|
|
||||||
|
const toolName = ev.name || 'unknown';
|
||||||
|
const toolInput = ev.input || {};
|
||||||
|
|
||||||
// Prüfen ob dieses Tool einen Subagent startet
|
|
||||||
if (SUBAGENT_TOOLS.includes(toolName)) {
|
if (SUBAGENT_TOOLS.includes(toolName)) {
|
||||||
const subagentId = randomUUID();
|
const subagentId = randomUUID();
|
||||||
const subagentType = getSubagentType(toolName, toolInput);
|
const subagentType = getSubagentType(toolName, toolInput);
|
||||||
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || 'Subagent-Aufgabe';
|
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || 'Subagent-Aufgabe';
|
||||||
const subagentModel = toolInput.model || useModel;
|
const subagentModel = toolInput.model || useModel;
|
||||||
|
|
||||||
// Tiefe berechnen (Main = 0, erster Sub = 1, etc.)
|
|
||||||
// Für jetzt: immer depth 1 (direkter Subagent vom Main)
|
|
||||||
const depth = 1;
|
const depth = 1;
|
||||||
|
|
||||||
activeSubagents.set(toolId, {
|
activeSubagents.set(toolId, {
|
||||||
|
|
@ -234,26 +451,24 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
||||||
agentId: currentAgentId,
|
agentId: currentAgentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monitor: Tool gestartet
|
|
||||||
const toolSummary = summarizeToolInput(toolName, toolInput);
|
const toolSummary = summarizeToolInput(toolName, toolInput);
|
||||||
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
|
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
|
||||||
toolId,
|
toolId,
|
||||||
tool: toolName,
|
tool: toolName,
|
||||||
input: toolInput,
|
input: toolInput,
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'tool_result': {
|
// Tool-Result handhaben
|
||||||
const toolId = event.tool_use_id || '';
|
function handleToolResult(ev) {
|
||||||
|
const toolId = ev.tool_use_id || '';
|
||||||
|
|
||||||
// Prüfen ob dieser Tool-Call ein Subagent war
|
|
||||||
if (activeSubagents.has(toolId)) {
|
if (activeSubagents.has(toolId)) {
|
||||||
const subagent = activeSubagents.get(toolId);
|
const subagent = activeSubagents.get(toolId);
|
||||||
sendEvent('subagent-stopped', {
|
sendEvent('subagent-stopped', {
|
||||||
id: subagent.agentId,
|
id: subagent.agentId,
|
||||||
parentAgentId: subagent.parentId,
|
parentAgentId: subagent.parentId,
|
||||||
success: !event.is_error,
|
success: !ev.is_error,
|
||||||
toolUseId: toolId,
|
toolUseId: toolId,
|
||||||
});
|
});
|
||||||
activeSubagents.delete(toolId);
|
activeSubagents.delete(toolId);
|
||||||
|
|
@ -261,9 +476,71 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
||||||
|
|
||||||
sendEvent('tool-end', {
|
sendEvent('tool-end', {
|
||||||
id: toolId,
|
id: toolId,
|
||||||
success: !event.is_error,
|
success: !ev.is_error,
|
||||||
agentId: currentAgentId,
|
agentId: currentAgentId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion: Iteration mit Fallback bei ungueltiger Session-ID
|
||||||
|
async function* iterateWithRetry() {
|
||||||
|
try {
|
||||||
|
for await (const ev of conversation) yield ev;
|
||||||
|
} catch (err) {
|
||||||
|
// Wenn Resume-Session ungueltig → Retry ohne sessionId
|
||||||
|
if (queryOptions.sessionId) {
|
||||||
|
sendMonitorEvent('agent', 'Resume fehlgeschlagen, starte neue Session', {
|
||||||
|
reason: err.message || String(err),
|
||||||
|
oldSessionId: queryOptions.sessionId,
|
||||||
|
});
|
||||||
|
delete queryOptions.sessionId;
|
||||||
|
conversation = query({ prompt: fullPrompt, options: queryOptions });
|
||||||
|
for await (const ev of conversation) yield ev;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const event of iterateWithRetry()) {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'assistant':
|
||||||
|
// Content-Bloecke durchgehen (Text, tool_use, thinking, ...)
|
||||||
|
if (event.message?.content) {
|
||||||
|
for (const block of event.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
fullText += block.text;
|
||||||
|
sendEvent('text', { text: block.text });
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
// Tool-Call von Main-Agent — manuell weiterreichen, damit
|
||||||
|
// der tool_use-Case weiter unten greift
|
||||||
|
handleToolUse(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.message?.model) {
|
||||||
|
usedModel = event.message.model;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_use': {
|
||||||
|
handleToolUse(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_result': {
|
||||||
|
handleToolResult(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user': {
|
||||||
|
// tool_result kommt vom SDK meist als Block innerhalb user-message
|
||||||
|
if (event.message?.content) {
|
||||||
|
for (const block of event.message.content) {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
handleToolResult(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,8 +619,8 @@ function handleCommand(msg) {
|
||||||
sendError(msg.id, 'Keine Nachricht angegeben');
|
sendError(msg.id, 'Keine Nachricht angegeben');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Modell und Context können pro Anfrage überschrieben werden
|
// Modell, Context und Resume-Session-ID können pro Anfrage überschrieben werden
|
||||||
sendMessage(msg.message, msg.id, msg.model, msg.context);
|
sendMessage(msg.message, msg.id, msg.model, msg.context, msg.resumeSessionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'set-context':
|
case 'set-context':
|
||||||
|
|
@ -399,9 +676,27 @@ function handleCommand(msg) {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'set-mode':
|
||||||
|
// Agent-Modus setzen (solo, handlanger, experten, auto)
|
||||||
|
const validModes = ['solo', 'handlanger', 'experten', 'auto'];
|
||||||
|
if (!msg.mode || !validModes.includes(msg.mode)) {
|
||||||
|
sendError(msg.id, `Ungültiger Modus: ${msg.mode}. Verfügbar: ${validModes.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
agentMode = msg.mode;
|
||||||
|
sendResponse(msg.id, { mode: agentMode, status: 'Modus geändert' });
|
||||||
|
sendEvent('mode-changed', { mode: agentMode });
|
||||||
|
sendMonitorEvent('agent', `Agent-Modus geändert: ${agentMode}`, { mode: agentMode });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-mode':
|
||||||
|
sendResponse(msg.id, { mode: agentMode });
|
||||||
|
break;
|
||||||
|
|
||||||
case 'status':
|
case 'status':
|
||||||
sendResponse(msg.id, {
|
sendResponse(msg.id, {
|
||||||
model: currentModel,
|
model: currentModel,
|
||||||
|
mode: agentMode,
|
||||||
isProcessing: !!currentAgentId,
|
isProcessing: !!currentAgentId,
|
||||||
availableModels: AVAILABLE_MODELS,
|
availableModels: AVAILABLE_MODELS,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1242
src-tauri/Cargo.lock
generated
1242
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,10 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
mysql_async = "0.34"
|
mysql_async = "0.34"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
tokio-tungstenite = "0.23"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Claude Desktop Standardberechtigungen",
|
"description": "Claude Desktop Standardberechtigungen",
|
||||||
"windows": ["main"],
|
"windows": ["main", "presentation"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"core:tray:default",
|
"core:tray:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,14 @@ pub struct AuditEntry {
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audit-Log Manager
|
/// Audit-Log Manager (für zukünftige In-Memory-Nutzung)
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AuditLog {
|
pub struct AuditLog {
|
||||||
entries: Vec<AuditEntry>,
|
entries: Vec<AuditEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl AuditLog {
|
impl AuditLog {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { entries: vec![] }
|
Self { entries: vec![] }
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ use std::process::{Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use crate::context;
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
|
||||||
/// Status eines Agents
|
/// Status eines Agents
|
||||||
|
|
@ -49,6 +48,7 @@ struct BridgeMessage {
|
||||||
payload: Option<serde_json::Value>,
|
payload: Option<serde_json::Value>,
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
result: Option<serde_json::Value>,
|
result: Option<serde_json::Value>,
|
||||||
|
#[allow(dead_code)]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,6 +158,20 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
|
||||||
"ready" => {
|
"ready" => {
|
||||||
println!("✅ Claude Bridge bereit");
|
println!("✅ Claude Bridge bereit");
|
||||||
let _ = app.emit("bridge-ready", ());
|
let _ = app.emit("bridge-ready", ());
|
||||||
|
|
||||||
|
// Gespeicherten Agent-Modus an Bridge senden (falls vorhanden)
|
||||||
|
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
|
||||||
|
let mode = {
|
||||||
|
let db = db_state.lock().unwrap();
|
||||||
|
db.get_setting("agent_mode").ok().flatten()
|
||||||
|
};
|
||||||
|
if let Some(mode) = mode {
|
||||||
|
if mode != "solo" {
|
||||||
|
println!("🔄 Restore Agent-Modus: {}", mode);
|
||||||
|
let _ = send_to_bridge(app, "set-mode", &mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"agent-started" | "subagent-start" => {
|
"agent-started" | "subagent-start" => {
|
||||||
if let Ok(agent) = serde_json::from_value::<AgentEvent>(payload.clone()) {
|
if let Ok(agent) = serde_json::from_value::<AgentEvent>(payload.clone()) {
|
||||||
|
|
@ -214,7 +228,11 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
|
||||||
if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") {
|
if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") {
|
||||||
if !active_id.is_empty() {
|
if !active_id.is_empty() {
|
||||||
if let Ok(Some(mut session)) = db_lock.get_session(&active_id) {
|
if let Ok(Some(mut session)) = db_lock.get_session(&active_id) {
|
||||||
|
// claude_session_id nur beim ersten Mal setzen —
|
||||||
|
// sonst verlieren Folge-Chats den Kontext der Anfangs-History
|
||||||
|
if session.claude_session_id.is_none() {
|
||||||
session.claude_session_id = Some(sid.to_string());
|
session.claude_session_id = Some(sid.to_string());
|
||||||
|
}
|
||||||
session.message_count += 1;
|
session.message_count += 1;
|
||||||
if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) {
|
if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) {
|
||||||
session.cost_usd += cost;
|
session.cost_usd += cost;
|
||||||
|
|
@ -241,8 +259,11 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
state.agents.clear();
|
state.agents.clear();
|
||||||
}
|
}
|
||||||
_ => {
|
other => {
|
||||||
println!("📨 Event: {} = {:?}", event, payload);
|
// Generische Weiterleitung aller Bridge-Events ans Frontend
|
||||||
|
// (subagent-started, subagent-stopped, monitor-event, mode-changed,
|
||||||
|
// knowledge-hint, auto-mode-chosen, etc.)
|
||||||
|
let _ = app.emit(other, &payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -259,11 +280,17 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
|
||||||
|
|
||||||
/// Befehl an Bridge senden
|
/// Befehl an Bridge senden
|
||||||
fn send_to_bridge(app: &AppHandle, command: &str, message: &str) -> Result<String, String> {
|
fn send_to_bridge(app: &AppHandle, command: &str, message: &str) -> Result<String, String> {
|
||||||
send_to_bridge_with_context(app, command, message, None)
|
send_to_bridge_full(app, command, message, None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Befehl an Bridge senden mit optionalem Context
|
/// Befehl an Bridge senden mit Context und Resume-Session-ID
|
||||||
fn send_to_bridge_with_context(app: &AppHandle, command: &str, message: &str, context: Option<String>) -> Result<String, String> {
|
fn send_to_bridge_full(
|
||||||
|
app: &AppHandle,
|
||||||
|
command: &str,
|
||||||
|
message: &str,
|
||||||
|
context: Option<String>,
|
||||||
|
resume_session_id: Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
let state = app.state::<Arc<Mutex<ClaudeState>>>();
|
let state = app.state::<Arc<Mutex<ClaudeState>>>();
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
|
|
||||||
|
|
@ -277,6 +304,11 @@ fn send_to_bridge_with_context(app: &AppHandle, command: &str, message: &str, co
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"model": message
|
"model": message
|
||||||
}),
|
}),
|
||||||
|
"set-mode" => serde_json::json!({
|
||||||
|
"command": command,
|
||||||
|
"id": request_id,
|
||||||
|
"mode": message
|
||||||
|
}),
|
||||||
"message" => {
|
"message" => {
|
||||||
let mut payload = serde_json::json!({
|
let mut payload = serde_json::json!({
|
||||||
"command": command,
|
"command": command,
|
||||||
|
|
@ -289,6 +321,12 @@ fn send_to_bridge_with_context(app: &AppHandle, command: &str, message: &str, co
|
||||||
payload["context"] = serde_json::Value::String(ctx);
|
payload["context"] = serde_json::Value::String(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Resume-Session-ID hinzufügen wenn vorhanden
|
||||||
|
if let Some(sid) = resume_session_id {
|
||||||
|
if !sid.is_empty() {
|
||||||
|
payload["resumeSessionId"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
payload
|
payload
|
||||||
},
|
},
|
||||||
"set-context" | "clear-context" => serde_json::json!({
|
"set-context" | "clear-context" => serde_json::json!({
|
||||||
|
|
@ -339,12 +377,29 @@ pub async fn send_message(app: AppHandle, message: String) -> Result<String, Str
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
send_to_bridge_with_context(&app, "message", &message, context)?;
|
// Claude-Session-ID für Fortsetzung laden
|
||||||
|
let resume_session_id = load_claude_session_id(&app);
|
||||||
|
if resume_session_id.is_some() {
|
||||||
|
println!("🔗 Session fortsetzen mit Claude-ID: {:?}", resume_session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
send_to_bridge_full(&app, "message", &message, context, resume_session_id)?;
|
||||||
|
|
||||||
// Hinweis: Die eigentliche Antwort kommt über Events
|
// Hinweis: Die eigentliche Antwort kommt über Events
|
||||||
Ok("Nachricht gesendet. Antwort folgt über Events.".to_string())
|
Ok("Nachricht gesendet. Antwort folgt über Events.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claude-Session-ID der aktiven Session laden
|
||||||
|
fn load_claude_session_id(app: &AppHandle) -> Option<String> {
|
||||||
|
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
|
||||||
|
let db = db_state.lock().ok()?;
|
||||||
|
if let Ok(Some(session)) = db.get_active_session() {
|
||||||
|
return session.claude_session_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Sticky Context aus DB laden und als Prompt-Text rendern
|
/// Sticky Context aus DB laden und als Prompt-Text rendern
|
||||||
fn load_sticky_context_for_prompt(app: &AppHandle) -> Option<String> {
|
fn load_sticky_context_for_prompt(app: &AppHandle) -> Option<String> {
|
||||||
use crate::context;
|
use crate::context;
|
||||||
|
|
@ -479,6 +534,53 @@ pub async fn get_current_model(app: AppHandle) -> Result<String, String> {
|
||||||
Ok("opus".to_string())
|
Ok("opus".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Agent-Modus setzen (solo, handlanger, experten, auto)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_agent_mode(app: AppHandle, mode: String) -> Result<String, String> {
|
||||||
|
let valid_modes = ["solo", "handlanger", "experten", "auto"];
|
||||||
|
if !valid_modes.contains(&mode.as_str()) {
|
||||||
|
return Err(format!("Ungültiger Modus: {}. Verfügbar: {}", mode, valid_modes.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("🔄 Agent-Modus wechseln zu: {}", mode);
|
||||||
|
|
||||||
|
// Modus in Settings speichern
|
||||||
|
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
|
||||||
|
let db = db_state.lock().unwrap();
|
||||||
|
let _ = db.set_setting("agent_mode", &mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge starten falls nicht aktiv
|
||||||
|
let needs_start = {
|
||||||
|
let state = app.state::<Arc<Mutex<ClaudeState>>>();
|
||||||
|
let state_guard = state.lock().unwrap();
|
||||||
|
state_guard.bridge_stdin.is_none()
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_start {
|
||||||
|
start_bridge(&app)?;
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modus an Bridge senden
|
||||||
|
send_to_bridge(&app, "set-mode", &mode)?;
|
||||||
|
|
||||||
|
Ok(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aktuellen Agent-Modus aus Settings laden
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_agent_mode(app: AppHandle) -> Result<String, String> {
|
||||||
|
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
|
||||||
|
let db = db_state.lock().unwrap();
|
||||||
|
if let Ok(Some(mode)) = db.get_setting("agent_mode") {
|
||||||
|
return Ok(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default: solo
|
||||||
|
Ok("solo".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Modell-Info Struct
|
/// Modell-Info Struct
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct ModelInfo {
|
pub struct ModelInfo {
|
||||||
|
|
@ -486,3 +588,82 @@ pub struct ModelInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sticky Context Initialisierungs-Info
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct StickyContextInfo {
|
||||||
|
pub loaded: bool,
|
||||||
|
pub entries: usize,
|
||||||
|
pub estimated_tokens: usize,
|
||||||
|
pub has_user_info: bool,
|
||||||
|
pub has_project: bool,
|
||||||
|
pub credentials_count: usize,
|
||||||
|
pub rules_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sticky Context beim App-Start initialisieren
|
||||||
|
/// Lädt den Context aus der DB und sendet ihn an die Bridge
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn init_sticky_context(app: AppHandle) -> Result<StickyContextInfo, String> {
|
||||||
|
println!("📌 Initialisiere Sticky Context...");
|
||||||
|
|
||||||
|
// Context aus DB laden
|
||||||
|
let context = load_sticky_context_for_prompt(&app);
|
||||||
|
|
||||||
|
let mut info = StickyContextInfo {
|
||||||
|
loaded: false,
|
||||||
|
entries: 0,
|
||||||
|
estimated_tokens: 0,
|
||||||
|
has_user_info: false,
|
||||||
|
has_project: false,
|
||||||
|
credentials_count: 0,
|
||||||
|
rules_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Details aus DB laden für Info
|
||||||
|
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
|
||||||
|
let db = db_state.lock().unwrap();
|
||||||
|
let _ = db.create_context_tables();
|
||||||
|
|
||||||
|
if let Ok(entries) = db.load_sticky_context() {
|
||||||
|
info.entries = entries.len();
|
||||||
|
|
||||||
|
for (key, _value, _priority) in &entries {
|
||||||
|
match key.as_str() {
|
||||||
|
"user_info" => info.has_user_info = true,
|
||||||
|
k if k.starts_with("cred:") => info.credentials_count += 1,
|
||||||
|
k if k.starts_with("project:") => info.has_project = true,
|
||||||
|
k if k.starts_with("rule:") => info.rules_count += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref ctx) = context {
|
||||||
|
info.loaded = true;
|
||||||
|
info.estimated_tokens = ctx.len() / 4;
|
||||||
|
|
||||||
|
// Bridge starten falls nicht aktiv
|
||||||
|
let needs_start = {
|
||||||
|
let state = app.state::<Arc<Mutex<ClaudeState>>>();
|
||||||
|
let state_guard = state.lock().unwrap();
|
||||||
|
state_guard.bridge_stdin.is_none()
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_start {
|
||||||
|
start_bridge(&app)?;
|
||||||
|
// Kurz warten bis Bridge bereit
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context an Bridge senden
|
||||||
|
let _ = send_to_bridge(&app, "set-context", ctx);
|
||||||
|
|
||||||
|
println!("✅ Sticky Context geladen: {} Einträge, ~{} Token", info.entries, info.estimated_tokens);
|
||||||
|
} else {
|
||||||
|
println!("ℹ️ Kein Sticky Context konfiguriert");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// Drei-Schichten-Gedächtnis für kritischen Kontext
|
// Drei-Schichten-Gedächtnis für kritischen Kontext
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
use crate::db::{Database, DbState};
|
use crate::db::{Database, DbState};
|
||||||
|
|
@ -74,7 +73,8 @@ pub struct ExtractedContext {
|
||||||
pub mentioned_tools: Vec<String>,
|
pub mentioned_tools: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wissens-Hint (Schicht 3, on-demand)
|
/// Wissens-Hint (Schicht 3, on-demand) — für zukünftige Wissens-Hints
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct KnowledgeHint {
|
pub struct KnowledgeHint {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -122,6 +122,7 @@ impl StickyContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Geschätzte Token-Anzahl
|
/// Geschätzte Token-Anzahl
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn estimate_tokens(&self) -> usize {
|
pub fn estimate_tokens(&self) -> usize {
|
||||||
// Grobe Schätzung: ~4 Zeichen pro Token
|
// Grobe Schätzung: ~4 Zeichen pro Token
|
||||||
self.render().len() / 4
|
self.render().len() / 4
|
||||||
|
|
@ -167,6 +168,7 @@ impl ProjectContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Geschätzte Token-Anzahl
|
/// Geschätzte Token-Anzahl
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn estimate_tokens(&self) -> usize {
|
pub fn estimate_tokens(&self) -> usize {
|
||||||
self.render().len() / 4
|
self.render().len() / 4
|
||||||
}
|
}
|
||||||
|
|
@ -278,11 +280,11 @@ impl Database {
|
||||||
Ok(ExtractedContext {
|
Ok(ExtractedContext {
|
||||||
session_id: row.get(0)?,
|
session_id: row.get(0)?,
|
||||||
extracted_at: row.get(1)?,
|
extracted_at: row.get(1)?,
|
||||||
decisions: decisions.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
decisions: decisions.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
||||||
open_questions: questions.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
open_questions: questions.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
||||||
key_insights: insights.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
key_insights: insights.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
||||||
mentioned_files: files.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
mentioned_files: files.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
||||||
mentioned_tools: tools.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
mentioned_tools: tools.and_then(|s: String| serde_json::from_str(&s).ok()).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,24 @@ pub struct ChatMessage {
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Monitor-Event (System-Log)
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct MonitorEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub event_type: String, // "api", "tool", "agent", "hook", "mcp", "error", "debug"
|
||||||
|
pub summary: String,
|
||||||
|
pub details: Option<String>, // JSON-String
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub duration_ms: Option<i64>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Datenbank-Wrapper
|
/// Datenbank-Wrapper
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
pub(crate) conn: Connection,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Datenbank-Statistiken
|
/// Datenbank-Statistiken
|
||||||
|
|
@ -167,7 +182,28 @@ impl Database {
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
|
||||||
|
|
||||||
"
|
-- Monitor-Events (System-Log)
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
agent_id TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitor_timestamp ON monitor_events(timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitor_type ON monitor_events(event_type);
|
||||||
|
|
||||||
|
-- Automatisch alte Monitor-Events löschen (älter als 7 Tage)
|
||||||
|
CREATE TRIGGER IF NOT EXISTS cleanup_old_monitor_events
|
||||||
|
AFTER INSERT ON monitor_events
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM monitor_events
|
||||||
|
WHERE timestamp < datetime('now', '-7 days');
|
||||||
|
END;
|
||||||
",
|
",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -305,6 +341,7 @@ impl Database {
|
||||||
// ============ Memory ============
|
// ============ Memory ============
|
||||||
|
|
||||||
/// Speichert einen Memory-Eintrag
|
/// Speichert einen Memory-Eintrag
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn save_memory_entry(&self, entry: &MemoryEntry) -> SqlResult<()> {
|
pub fn save_memory_entry(&self, entry: &MemoryEntry) -> SqlResult<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT OR REPLACE INTO memory (id, category, key, value, sticky, auto_load, last_used, use_count)
|
"INSERT OR REPLACE INTO memory (id, category, key, value, sticky, auto_load, last_used, use_count)
|
||||||
|
|
@ -351,6 +388,7 @@ impl Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Löscht einen Memory-Eintrag
|
/// Löscht einen Memory-Eintrag
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> {
|
pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> {
|
||||||
self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?;
|
self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -711,6 +749,96 @@ impl Database {
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Monitor-Events ============
|
||||||
|
|
||||||
|
/// Speichert ein Monitor-Event
|
||||||
|
pub fn save_monitor_event(&self, event: &MonitorEvent) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO monitor_events (id, timestamp, event_type, summary, details, agent_id, session_id, duration_ms, error)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
event.id,
|
||||||
|
event.timestamp,
|
||||||
|
event.event_type,
|
||||||
|
event.summary,
|
||||||
|
event.details,
|
||||||
|
event.agent_id,
|
||||||
|
event.session_id,
|
||||||
|
event.duration_ms,
|
||||||
|
event.error,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt die letzten N Monitor-Events
|
||||||
|
pub fn load_monitor_events(&self, limit: usize) -> SqlResult<Vec<MonitorEvent>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, timestamp, event_type, summary, details, agent_id, session_id, duration_ms, error
|
||||||
|
FROM monitor_events ORDER BY timestamp DESC LIMIT ?1"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let events = stmt.query_map(params![limit as i64], |row| {
|
||||||
|
Ok(MonitorEvent {
|
||||||
|
id: row.get(0)?,
|
||||||
|
timestamp: row.get(1)?,
|
||||||
|
event_type: row.get(2)?,
|
||||||
|
summary: row.get(3)?,
|
||||||
|
details: row.get(4)?,
|
||||||
|
agent_id: row.get(5)?,
|
||||||
|
session_id: row.get(6)?,
|
||||||
|
duration_ms: row.get(7)?,
|
||||||
|
error: row.get(8)?,
|
||||||
|
})
|
||||||
|
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt Monitor-Events nach Typ gefiltert
|
||||||
|
pub fn load_monitor_events_by_type(&self, event_type: &str, limit: usize) -> SqlResult<Vec<MonitorEvent>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, timestamp, event_type, summary, details, agent_id, session_id, duration_ms, error
|
||||||
|
FROM monitor_events WHERE event_type = ?1 ORDER BY timestamp DESC LIMIT ?2"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let events = stmt.query_map(params![event_type, limit as i64], |row| {
|
||||||
|
Ok(MonitorEvent {
|
||||||
|
id: row.get(0)?,
|
||||||
|
timestamp: row.get(1)?,
|
||||||
|
event_type: row.get(2)?,
|
||||||
|
summary: row.get(3)?,
|
||||||
|
details: row.get(4)?,
|
||||||
|
agent_id: row.get(5)?,
|
||||||
|
session_id: row.get(6)?,
|
||||||
|
duration_ms: row.get(7)?,
|
||||||
|
error: row.get(8)?,
|
||||||
|
})
|
||||||
|
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Löscht alle Monitor-Events
|
||||||
|
pub fn clear_monitor_events(&self) -> SqlResult<usize> {
|
||||||
|
let count: usize = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM monitor_events", [], |row| row.get(0)
|
||||||
|
)?;
|
||||||
|
self.conn.execute("DELETE FROM monitor_events", [])?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zählt Monitor-Events nach Typ
|
||||||
|
pub fn count_monitor_events_by_type(&self) -> SqlResult<Vec<(String, usize)>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT event_type, COUNT(*) FROM monitor_events GROUP BY event_type"
|
||||||
|
)?;
|
||||||
|
let counts = stmt.query_map([], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?))
|
||||||
|
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||||
|
Ok(counts)
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Statistiken ============
|
// ============ Statistiken ============
|
||||||
|
|
||||||
/// DB-Statistiken
|
/// DB-Statistiken
|
||||||
|
|
@ -865,3 +993,53 @@ pub async fn compact_session(
|
||||||
|
|
||||||
Ok(compacted)
|
Ok(compacted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Monitor-Events Commands ============
|
||||||
|
|
||||||
|
/// Monitor-Event speichern
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_monitor_event(app: AppHandle, event: MonitorEvent) -> Result<(), String> {
|
||||||
|
let state = app.state::<DbState>();
|
||||||
|
let db = state.lock().unwrap();
|
||||||
|
db.save_monitor_event(&event).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor-Events laden (neueste zuerst)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_monitor_events(app: AppHandle, limit: Option<usize>) -> Result<Vec<MonitorEvent>, String> {
|
||||||
|
let limit = limit.unwrap_or(1000); // Standard: letzte 1000 Events
|
||||||
|
let state = app.state::<DbState>();
|
||||||
|
let db = state.lock().unwrap();
|
||||||
|
db.load_monitor_events(limit).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor-Events nach Typ laden
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_monitor_events_by_type(
|
||||||
|
app: AppHandle,
|
||||||
|
event_type: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MonitorEvent>, String> {
|
||||||
|
let limit = limit.unwrap_or(500);
|
||||||
|
let state = app.state::<DbState>();
|
||||||
|
let db = state.lock().unwrap();
|
||||||
|
db.load_monitor_events_by_type(&event_type, limit).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Monitor-Events löschen
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_all_monitor_events(app: AppHandle) -> Result<usize, String> {
|
||||||
|
let state = app.state::<DbState>();
|
||||||
|
let db = state.lock().unwrap();
|
||||||
|
let count = db.clear_monitor_events().map_err(|e| e.to_string())?;
|
||||||
|
println!("🗑️ {} Monitor-Events gelöscht", count);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor-Event Statistiken
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_monitor_stats(app: AppHandle) -> Result<Vec<(String, usize)>, String> {
|
||||||
|
let state = app.state::<DbState>();
|
||||||
|
let db = state.lock().unwrap();
|
||||||
|
db.count_monitor_events_by_type().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ pub enum PermissionAction {
|
||||||
Deny,
|
Deny,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anfrage zur Freigabe
|
/// Anfrage zur Freigabe (für zukünftiges Permission-Popup)
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PermissionRequest {
|
pub struct PermissionRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -53,7 +54,8 @@ pub struct PermissionRequest {
|
||||||
pub suggested_pattern: Option<String>,
|
pub suggested_pattern: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Antwort auf Freigabe-Anfrage
|
/// Antwort auf Freigabe-Anfrage (für zukünftiges Permission-Popup)
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PermissionResponse {
|
pub struct PermissionResponse {
|
||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
|
|
@ -297,6 +299,7 @@ impl GuardRails {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session-Permissions löschen
|
/// Session-Permissions löschen
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn clear_session(&mut self) {
|
pub fn clear_session(&mut self) {
|
||||||
self.session_permissions.clear();
|
self.session_permissions.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
234
src-tauri/src/hooks.rs
Normal file
234
src-tauri/src/hooks.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
// Claude Desktop — Hook-System
|
||||||
|
// Zentraler Dispatcher + Audit-Log fuer automatische Aktionen
|
||||||
|
// (SessionStart, PreToolUse, PostToolUse, BeforeCompacting, AfterCompacting)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum HookEvent {
|
||||||
|
SessionStart,
|
||||||
|
PreToolUse,
|
||||||
|
PostToolUse,
|
||||||
|
BeforeCompacting,
|
||||||
|
AfterCompacting,
|
||||||
|
ContextFailure,
|
||||||
|
AgentStarted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookEvent {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
HookEvent::SessionStart => "SessionStart",
|
||||||
|
HookEvent::PreToolUse => "PreToolUse",
|
||||||
|
HookEvent::PostToolUse => "PostToolUse",
|
||||||
|
HookEvent::BeforeCompacting => "BeforeCompacting",
|
||||||
|
HookEvent::AfterCompacting => "AfterCompacting",
|
||||||
|
HookEvent::ContextFailure => "ContextFailure",
|
||||||
|
HookEvent::AgentStarted => "AgentStarted",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"SessionStart" => Some(HookEvent::SessionStart),
|
||||||
|
"PreToolUse" => Some(HookEvent::PreToolUse),
|
||||||
|
"PostToolUse" => Some(HookEvent::PostToolUse),
|
||||||
|
"BeforeCompacting" => Some(HookEvent::BeforeCompacting),
|
||||||
|
"AfterCompacting" => Some(HookEvent::AfterCompacting),
|
||||||
|
"ContextFailure" => Some(HookEvent::ContextFailure),
|
||||||
|
"AgentStarted" => Some(HookEvent::AgentStarted),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HookConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub event: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HookExecution {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub event: String,
|
||||||
|
pub hook_name: String,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub success: bool,
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook-Manager — haelt Registry und Ausfuehrungs-Log
|
||||||
|
pub struct HookManager {
|
||||||
|
pub hooks: HashMap<String, Vec<HookConfig>>,
|
||||||
|
pub executions: Vec<HookExecution>,
|
||||||
|
pub max_log_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HookManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut mgr = HookManager {
|
||||||
|
hooks: HashMap::new(),
|
||||||
|
executions: Vec::new(),
|
||||||
|
max_log_size: 500,
|
||||||
|
};
|
||||||
|
mgr.register_builtin_hooks();
|
||||||
|
mgr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookManager {
|
||||||
|
/// Eingebaute Hooks registrieren
|
||||||
|
fn register_builtin_hooks(&mut self) {
|
||||||
|
let builtins = vec![
|
||||||
|
HookConfig {
|
||||||
|
name: "load-sticky-context".into(),
|
||||||
|
event: "SessionStart".into(),
|
||||||
|
enabled: true,
|
||||||
|
description: "Laedt Sticky-Context bei Session-Start".into(),
|
||||||
|
},
|
||||||
|
HookConfig {
|
||||||
|
name: "inject-knowledge-hints".into(),
|
||||||
|
event: "PreToolUse".into(),
|
||||||
|
enabled: true,
|
||||||
|
description: "Injiziert relevante KB-Eintraege vor Tool-Ausfuehrung".into(),
|
||||||
|
},
|
||||||
|
HookConfig {
|
||||||
|
name: "save-failure-pattern".into(),
|
||||||
|
event: "PostToolUse".into(),
|
||||||
|
enabled: true,
|
||||||
|
description: "Speichert Fehler-Pattern nach fehlgeschlagenen Tools".into(),
|
||||||
|
},
|
||||||
|
HookConfig {
|
||||||
|
name: "extract-critical-context".into(),
|
||||||
|
event: "BeforeCompacting".into(),
|
||||||
|
enabled: true,
|
||||||
|
description: "Extrahiert kritischen Kontext vor Compacting".into(),
|
||||||
|
},
|
||||||
|
HookConfig {
|
||||||
|
name: "reinject-context".into(),
|
||||||
|
event: "AfterCompacting".into(),
|
||||||
|
enabled: true,
|
||||||
|
description: "Injiziert Sticky+Project-Context nach Compacting".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for hook in builtins {
|
||||||
|
self.hooks
|
||||||
|
.entry(hook.event.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(hook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fire(&mut self, event: &HookEvent, summary: String) -> Vec<String> {
|
||||||
|
let event_name = event.as_str().to_string();
|
||||||
|
let mut fired_names = Vec::new();
|
||||||
|
|
||||||
|
if let Some(hooks) = self.hooks.get(&event_name) {
|
||||||
|
for hook in hooks.iter().filter(|h| h.enabled) {
|
||||||
|
fired_names.push(hook.name.clone());
|
||||||
|
|
||||||
|
let execution = HookExecution {
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
event: event_name.clone(),
|
||||||
|
hook_name: hook.name.clone(),
|
||||||
|
duration_ms: 0,
|
||||||
|
success: true,
|
||||||
|
summary: summary.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.executions.push(execution);
|
||||||
|
if self.executions.len() > self.max_log_size {
|
||||||
|
self.executions.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fired_names
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(&mut self, event: &str, hook_name: &str, enabled: bool) -> bool {
|
||||||
|
if let Some(hooks) = self.hooks.get_mut(event) {
|
||||||
|
for hook in hooks.iter_mut() {
|
||||||
|
if hook.name == hook_name {
|
||||||
|
hook.enabled = enabled;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_all(&self) -> Vec<HookConfig> {
|
||||||
|
self.hooks.values().flatten().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recent_executions(&self, limit: usize) -> Vec<HookExecution> {
|
||||||
|
let start = self.executions.len().saturating_sub(limit);
|
||||||
|
self.executions[start..].to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type HookState = Arc<Mutex<HookManager>>;
|
||||||
|
|
||||||
|
// ============ Tauri Commands ============
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_hooks(state: tauri::State<'_, HookState>) -> Result<Vec<HookConfig>, String> {
|
||||||
|
let mgr = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(mgr.list_all())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_hook_enabled(
|
||||||
|
state: tauri::State<'_, HookState>,
|
||||||
|
event: String,
|
||||||
|
hook_name: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let mut mgr = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(mgr.set_enabled(&event, &hook_name, enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_hook_executions(
|
||||||
|
state: tauri::State<'_, HookState>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<HookExecution>, String> {
|
||||||
|
let mgr = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(mgr.recent_executions(limit.unwrap_or(100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fire_hook(
|
||||||
|
app: AppHandle,
|
||||||
|
state: tauri::State<'_, HookState>,
|
||||||
|
event: String,
|
||||||
|
summary: String,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let hook_event = HookEvent::from_str(&event)
|
||||||
|
.ok_or_else(|| format!("Unbekanntes Hook-Event: {}", event))?;
|
||||||
|
|
||||||
|
let fired = {
|
||||||
|
let mut mgr = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
mgr.fire(&hook_event, summary.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event ans Frontend senden (fuer Live-Log im UI)
|
||||||
|
let _ = app.emit(
|
||||||
|
"hook-fired",
|
||||||
|
serde_json::json!({
|
||||||
|
"event": event,
|
||||||
|
"hooks": fired,
|
||||||
|
"summary": summary,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(fired)
|
||||||
|
}
|
||||||
183
src-tauri/src/ide.rs
Normal file
183
src-tauri/src/ide.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
// Claude Desktop — IDE-Connector (VSCodium/VSCode)
|
||||||
|
// WebSocket-Client zur Claude-Desktop-Bridge Extension
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IdeStatus {
|
||||||
|
pub connected: bool,
|
||||||
|
pub port: u16,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct BridgeRequest {
|
||||||
|
id: String,
|
||||||
|
command: String,
|
||||||
|
args: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BridgeResponse {
|
||||||
|
id: String,
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IdeConnector {
|
||||||
|
pub status: IdeStatus,
|
||||||
|
pub sender: Option<mpsc::UnboundedSender<(String, serde_json::Value, oneshot::Sender<Result<serde_json::Value, String>>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IdeConnector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
status: IdeStatus {
|
||||||
|
connected: false,
|
||||||
|
port: 7890,
|
||||||
|
last_error: None,
|
||||||
|
},
|
||||||
|
sender: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type IdeState = Arc<Mutex<IdeConnector>>;
|
||||||
|
|
||||||
|
async fn run_connection(
|
||||||
|
port: u16,
|
||||||
|
state: IdeState,
|
||||||
|
mut receiver: mpsc::UnboundedReceiver<(String, serde_json::Value, oneshot::Sender<Result<serde_json::Value, String>>)>,
|
||||||
|
) {
|
||||||
|
let url = format!("ws://127.0.0.1:{}", port);
|
||||||
|
let (ws_stream, _) = match connect_async(&url).await {
|
||||||
|
Ok(ok) => ok,
|
||||||
|
Err(err) => {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status.connected = false;
|
||||||
|
s.status.last_error = Some(err.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status.connected = true;
|
||||||
|
s.status.last_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut write, mut read) = ws_stream.split();
|
||||||
|
let pending: Arc<Mutex<HashMap<String, oneshot::Sender<Result<serde_json::Value, String>>>>> =
|
||||||
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let pending_reader = pending.clone();
|
||||||
|
let state_reader = state.clone();
|
||||||
|
let reader_task = tokio::spawn(async move {
|
||||||
|
while let Some(msg) = read.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(Message::Text(txt)) => {
|
||||||
|
if let Ok(resp) = serde_json::from_str::<BridgeResponse>(&txt) {
|
||||||
|
let sender = pending_reader.lock().unwrap().remove(&resp.id);
|
||||||
|
if let Some(sender) = sender {
|
||||||
|
let result = if let Some(err) = resp.error {
|
||||||
|
Err(err)
|
||||||
|
} else {
|
||||||
|
Ok(resp.result.unwrap_or(serde_json::Value::Null))
|
||||||
|
};
|
||||||
|
let _ = sender.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(_)) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut s = state_reader.lock().unwrap();
|
||||||
|
s.status.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
while let Some((command, args, reply)) = receiver.recv().await {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let req = BridgeRequest { id: id.clone(), command, args };
|
||||||
|
let json = match serde_json::to_string(&req) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = reply.send(Err(e.to_string()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pending.lock().unwrap().insert(id, reply);
|
||||||
|
if let Err(err) = write.send(Message::Text(json)).await {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status.connected = false;
|
||||||
|
s.status.last_error = Some(err.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Tauri Commands ============
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ide_connect(state: tauri::State<'_, IdeState>, port: Option<u16>) -> Result<IdeStatus, String> {
|
||||||
|
let port = port.unwrap_or(7890);
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
s.status.port = port;
|
||||||
|
s.sender = Some(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_bg = state.inner().clone();
|
||||||
|
tokio::spawn(run_connection(port, state_bg, rx));
|
||||||
|
|
||||||
|
// Kurz warten auf Connect
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
|
|
||||||
|
let s = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(s.status.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ide_disconnect(state: tauri::State<'_, IdeState>) -> Result<(), String> {
|
||||||
|
let mut s = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
s.sender = None;
|
||||||
|
s.status.connected = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ide_status(state: tauri::State<'_, IdeState>) -> Result<IdeStatus, String> {
|
||||||
|
let s = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(s.status.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ide_call(
|
||||||
|
state: tauri::State<'_, IdeState>,
|
||||||
|
command: String,
|
||||||
|
args: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let sender = {
|
||||||
|
let s = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
s.sender.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sender = sender.ok_or("IDE nicht verbunden. Zuerst ide_connect aufrufen.")?;
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
sender.send((command, args, tx)).map_err(|_| "Kanal geschlossen".to_string())?;
|
||||||
|
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(10), rx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "IDE-Timeout (10s)".to_string())?
|
||||||
|
.map_err(|_| "IDE-Antwort verloren".to_string())?
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,10 @@ use mysql_async::{Pool, prelude::*};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Verbindungskonfiguration
|
/// Verbindungskonfiguration
|
||||||
const MYSQL_HOST: &str = "192.168.155.1";
|
const MYSQL_HOST: &str = "192.168.155.11";
|
||||||
const MYSQL_PORT: u16 = 3306;
|
const MYSQL_PORT: u16 = 3306;
|
||||||
const MYSQL_USER: &str = "claude";
|
const MYSQL_USER: &str = "claude";
|
||||||
const MYSQL_PASS: &str = "claude";
|
const MYSQL_PASS: &str = "8715";
|
||||||
const MYSQL_DB: &str = "claude";
|
const MYSQL_DB: &str = "claude";
|
||||||
|
|
||||||
/// Wissenseintrag aus der knowledge-Tabelle
|
/// Wissenseintrag aus der knowledge-Tabelle
|
||||||
|
|
@ -255,6 +255,147 @@ pub async fn get_recent_knowledge(
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wissens-Hints für ein Tool/Kommando laden
|
||||||
|
/// Sucht relevante Einträge basierend auf Tool-Name und Kommando
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_tool_hints(
|
||||||
|
tool: String,
|
||||||
|
command: Option<String>,
|
||||||
|
context: Option<String>,
|
||||||
|
) -> Result<Vec<KnowledgeEntry>, String> {
|
||||||
|
let pool = create_pool();
|
||||||
|
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Suchbegriffe aus Tool + Command + Context zusammenbauen
|
||||||
|
let mut search_terms = vec![tool.clone()];
|
||||||
|
|
||||||
|
// Tool-spezifische Kategorien mappen
|
||||||
|
let category: Option<&str> = match tool.as_str() {
|
||||||
|
"Bash" => {
|
||||||
|
if let Some(ref cmd) = command {
|
||||||
|
// Relevante Begriffe aus Bash-Kommando extrahieren
|
||||||
|
if cmd.contains("npm") || cmd.contains("node") { search_terms.push("npm".to_string()); }
|
||||||
|
if cmd.contains("git") { search_terms.push("git".to_string()); }
|
||||||
|
if cmd.contains("docker") { search_terms.push("docker".to_string()); }
|
||||||
|
if cmd.contains("cargo") { search_terms.push("cargo".to_string()); search_terms.push("rust".to_string()); }
|
||||||
|
if cmd.contains("dolibarr") { search_terms.push("dolibarr".to_string()); }
|
||||||
|
if cmd.contains("mysql") { search_terms.push("mysql".to_string()); search_terms.push("sql".to_string()); }
|
||||||
|
}
|
||||||
|
None // Keine spezifische Kategorie
|
||||||
|
}
|
||||||
|
"Read" | "Write" | "Edit" => {
|
||||||
|
if let Some(ref cmd) = command {
|
||||||
|
// Aus Dateipfad relevante Begriffe extrahieren
|
||||||
|
if cmd.contains("dolibarr") { search_terms.push("dolibarr".to_string()); }
|
||||||
|
if cmd.contains(".php") { search_terms.push("php".to_string()); }
|
||||||
|
if cmd.contains(".rs") { search_terms.push("rust".to_string()); }
|
||||||
|
if cmd.contains(".ts") || cmd.contains(".svelte") { search_terms.push("svelte".to_string()); }
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional: Context-Begriffe hinzufügen
|
||||||
|
if let Some(ref ctx) = context {
|
||||||
|
// Wichtige Begriffe aus Context extrahieren (max 3)
|
||||||
|
for word in ctx.split_whitespace().take(10) {
|
||||||
|
if word.len() > 4 && !search_terms.contains(&word.to_lowercase()) {
|
||||||
|
search_terms.push(word.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suchquery bauen
|
||||||
|
let query_string = search_terms.join(" ");
|
||||||
|
|
||||||
|
// Suche mit Volltext und optionalem Kategorie-Filter
|
||||||
|
let entries: Vec<KnowledgeEntry> = if let Some(cat) = category {
|
||||||
|
conn.exec_map(
|
||||||
|
r#"SELECT id, category, title, content, tags, priority, status,
|
||||||
|
related_ids, source, created_at, updated_at
|
||||||
|
FROM knowledge
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND category = ?
|
||||||
|
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
||||||
|
ORDER BY priority ASC, updated_at DESC
|
||||||
|
LIMIT 3"#,
|
||||||
|
(&cat, &query_string),
|
||||||
|
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
|
||||||
|
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, String, String)| {
|
||||||
|
KnowledgeEntry {
|
||||||
|
id, category, title, content, tags, priority, status,
|
||||||
|
related_ids, source, created_at, updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
conn.exec_map(
|
||||||
|
r#"SELECT id, category, title, content, tags, priority, status,
|
||||||
|
related_ids, source, created_at, updated_at
|
||||||
|
FROM knowledge
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND MATCH(title, content, tags) AGAINST(? IN NATURAL LANGUAGE MODE)
|
||||||
|
ORDER BY priority ASC, updated_at DESC
|
||||||
|
LIMIT 3"#,
|
||||||
|
(&query_string,),
|
||||||
|
|(id, category, title, content, tags, priority, status, related_ids, source, created_at, updated_at):
|
||||||
|
(i64, String, String, String, Option<String>, i32, String, Option<String>, Option<String>, String, String)| {
|
||||||
|
KnowledgeEntry {
|
||||||
|
id, category, title, content, tags, priority, status,
|
||||||
|
related_ids, source, created_at, updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await.map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(conn);
|
||||||
|
pool.disconnect().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !entries.is_empty() {
|
||||||
|
println!("💡 {} Wissens-Hints geladen für Tool '{}': {:?}",
|
||||||
|
entries.len(),
|
||||||
|
tool,
|
||||||
|
entries.iter().map(|e| &e.title).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wissens-Hints als formatierter Kontext-Block
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn format_tool_hints(
|
||||||
|
tool: String,
|
||||||
|
command: Option<String>,
|
||||||
|
context: Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let entries = get_tool_hints(tool.clone(), command, context).await?;
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hints = Vec::new();
|
||||||
|
hints.push("<knowledge-hints>".to_string());
|
||||||
|
hints.push(format!("Relevante Informationen für {}:", tool));
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
hints.push(format!("\n**{}** ({})", entry.title, entry.category));
|
||||||
|
// Content auf ~300 Zeichen kürzen
|
||||||
|
let content = if entry.content.len() > 300 {
|
||||||
|
format!("{}...", &entry.content[..300])
|
||||||
|
} else {
|
||||||
|
entry.content
|
||||||
|
};
|
||||||
|
hints.push(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
hints.push("</knowledge-hints>".to_string());
|
||||||
|
|
||||||
|
Ok(hints.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Verbindung zur Wissensbasis testen
|
/// Verbindung zur Wissensbasis testen
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_knowledge_connection() -> Result<String, String> {
|
pub async fn test_knowledge_connection() -> Result<String, String> {
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,14 @@ mod claude;
|
||||||
mod context;
|
mod context;
|
||||||
mod db;
|
mod db;
|
||||||
mod guard;
|
mod guard;
|
||||||
|
mod hooks;
|
||||||
|
mod ide;
|
||||||
mod knowledge;
|
mod knowledge;
|
||||||
mod memory;
|
mod memory;
|
||||||
|
mod programs;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod teaching;
|
||||||
|
mod voice;
|
||||||
|
|
||||||
/// Initialisiert die App
|
/// Initialisiert die App
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|
@ -24,6 +29,8 @@ pub fn run() {
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(Arc::new(Mutex::new(claude::ClaudeState::default())))
|
.manage(Arc::new(Mutex::new(claude::ClaudeState::default())))
|
||||||
.manage(guard::GuardState::new(Mutex::new(guard::GuardRails::new())))
|
.manage(guard::GuardState::new(Mutex::new(guard::GuardRails::new())))
|
||||||
|
.manage::<hooks::HookState>(Arc::new(Mutex::new(hooks::HookManager::default())))
|
||||||
|
.manage::<ide::IdeState>(Arc::new(Mutex::new(ide::IdeConnector::default())))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// Claude SDK
|
// Claude SDK
|
||||||
claude::send_message,
|
claude::send_message,
|
||||||
|
|
@ -32,9 +39,12 @@ pub fn run() {
|
||||||
claude::set_model,
|
claude::set_model,
|
||||||
claude::get_available_models,
|
claude::get_available_models,
|
||||||
claude::get_current_model,
|
claude::get_current_model,
|
||||||
|
claude::set_agent_mode,
|
||||||
|
claude::get_agent_mode,
|
||||||
|
claude::init_sticky_context,
|
||||||
// Gedächtnis-System
|
// Gedächtnis-System
|
||||||
memory::load_memory,
|
memory::load_memory,
|
||||||
memory::get_sticky_context,
|
memory::get_sticky_memory_entries,
|
||||||
memory::save_pattern,
|
memory::save_pattern,
|
||||||
memory::detect_issue,
|
memory::detect_issue,
|
||||||
// Audit-Log
|
// Audit-Log
|
||||||
|
|
@ -68,6 +78,12 @@ pub fn run() {
|
||||||
db::load_messages,
|
db::load_messages,
|
||||||
db::clear_messages,
|
db::clear_messages,
|
||||||
db::compact_session,
|
db::compact_session,
|
||||||
|
// Monitor-Events
|
||||||
|
db::save_monitor_event,
|
||||||
|
db::load_monitor_events,
|
||||||
|
db::load_monitor_events_by_type,
|
||||||
|
db::clear_all_monitor_events,
|
||||||
|
db::get_monitor_stats,
|
||||||
// Wissensbasis (claude-db)
|
// Wissensbasis (claude-db)
|
||||||
knowledge::search_knowledge,
|
knowledge::search_knowledge,
|
||||||
knowledge::get_knowledge,
|
knowledge::get_knowledge,
|
||||||
|
|
@ -75,6 +91,8 @@ pub fn run() {
|
||||||
knowledge::get_knowledge_categories,
|
knowledge::get_knowledge_categories,
|
||||||
knowledge::get_recent_knowledge,
|
knowledge::get_recent_knowledge,
|
||||||
knowledge::test_knowledge_connection,
|
knowledge::test_knowledge_connection,
|
||||||
|
knowledge::get_tool_hints,
|
||||||
|
knowledge::format_tool_hints,
|
||||||
// Context-Management
|
// Context-Management
|
||||||
context::get_sticky_context,
|
context::get_sticky_context,
|
||||||
context::set_sticky_context,
|
context::set_sticky_context,
|
||||||
|
|
@ -84,6 +102,34 @@ pub fn run() {
|
||||||
context::log_context_failure,
|
context::log_context_failure,
|
||||||
context::get_full_context,
|
context::get_full_context,
|
||||||
context::list_sticky_context,
|
context::list_sticky_context,
|
||||||
|
// Voice-Interface
|
||||||
|
voice::transcribe_audio,
|
||||||
|
voice::text_to_speech,
|
||||||
|
voice::check_voice_availability,
|
||||||
|
voice::get_tts_voices,
|
||||||
|
// Hook-System
|
||||||
|
hooks::list_hooks,
|
||||||
|
hooks::set_hook_enabled,
|
||||||
|
hooks::get_hook_executions,
|
||||||
|
hooks::fire_hook,
|
||||||
|
// IDE-Connector (VSCodium)
|
||||||
|
ide::ide_connect,
|
||||||
|
ide::ide_disconnect,
|
||||||
|
ide::ide_status,
|
||||||
|
ide::ide_call,
|
||||||
|
// Programm-Steuerung (D-Bus, Xvfb, Playwright-Info)
|
||||||
|
programs::dbus_call,
|
||||||
|
programs::dbus_list_services,
|
||||||
|
programs::xvfb_start,
|
||||||
|
programs::xvfb_stop,
|
||||||
|
programs::xvfb_status,
|
||||||
|
programs::xvfb_screenshot,
|
||||||
|
programs::playwright_info,
|
||||||
|
// Schulungsmodus (Phase 15)
|
||||||
|
teaching::presentation_open,
|
||||||
|
teaching::presentation_close,
|
||||||
|
teaching::presentation_send_slide,
|
||||||
|
teaching::presentation_clear,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
|
|
||||||
|
|
@ -32,67 +32,7 @@ pub struct MemoryEntry {
|
||||||
pub use_count: u32,
|
pub use_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Das Gedächtnis-System
|
// MemorySystem Struct entfernt - Dead Code, Funktionalität läuft über Tauri-Commands
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct MemorySystem {
|
|
||||||
entries: HashMap<String, MemoryEntry>,
|
|
||||||
loaded_from_db: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MemorySystem {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
entries: HashMap::new(),
|
|
||||||
loaded_from_db: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fügt einen Eintrag hinzu
|
|
||||||
pub fn add(&mut self, entry: MemoryEntry) {
|
|
||||||
self.entries.insert(entry.id.clone(), entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holt einen Eintrag
|
|
||||||
pub fn get(&self, id: &str) -> Option<&MemoryEntry> {
|
|
||||||
self.entries.get(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holt alle Einträge einer Kategorie
|
|
||||||
pub fn get_by_category(&self, category: ContextCategory) -> Vec<&MemoryEntry> {
|
|
||||||
self.entries
|
|
||||||
.values()
|
|
||||||
.filter(|e| e.category == category)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holt alle Sticky-Einträge (für Kontext-Injection)
|
|
||||||
pub fn get_sticky_context(&self) -> Vec<&MemoryEntry> {
|
|
||||||
self.entries.values().filter(|e| e.sticky).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holt alle Auto-Load-Einträge
|
|
||||||
pub fn get_auto_load(&self) -> Vec<&MemoryEntry> {
|
|
||||||
self.entries.values().filter(|e| e.auto_load).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Statistiken
|
|
||||||
pub fn stats(&self) -> MemoryStats {
|
|
||||||
MemoryStats {
|
|
||||||
total: self.entries.len(),
|
|
||||||
sticky: self.entries.values().filter(|e| e.sticky).count(),
|
|
||||||
by_category: self.count_by_category(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_by_category(&self) -> HashMap<String, usize> {
|
|
||||||
let mut counts = HashMap::new();
|
|
||||||
for entry in self.entries.values() {
|
|
||||||
let cat = format!("{:?}", entry.category);
|
|
||||||
*counts.entry(cat).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
counts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct MemoryStats {
|
pub struct MemoryStats {
|
||||||
|
|
@ -147,9 +87,9 @@ pub async fn load_memory(app: AppHandle) -> Result<MemoryStats, String> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holt den Sticky-Kontext für Claude
|
/// Holt die Sticky-Memory-Einträge (veraltet, nutze context::get_sticky_context)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_sticky_context(app: AppHandle) -> Result<Vec<MemoryEntry>, String> {
|
pub async fn get_sticky_memory_entries(app: AppHandle) -> Result<Vec<MemoryEntry>, String> {
|
||||||
let state = app.state::<Arc<Mutex<db::Database>>>();
|
let state = app.state::<Arc<Mutex<db::Database>>>();
|
||||||
let db_lock = state.lock().unwrap();
|
let db_lock = state.lock().unwrap();
|
||||||
let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?;
|
let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?;
|
||||||
|
|
|
||||||
230
src-tauri/src/programs.rs
Normal file
230
src-tauri/src/programs.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
// Claude Desktop — Programm-Steuerung
|
||||||
|
// D-Bus: Linux-Apps via dbus-send/qdbus
|
||||||
|
// Xvfb: Virtuelles Display fuer Computer-Use (Scaffold)
|
||||||
|
// Playwright: Tool-Hinweise (eigentliche Steuerung laeuft ueber MCP-Server)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// ============ D-Bus ============
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DbusCallResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
pub exit_code: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn dbus_call(
|
||||||
|
service: String,
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
args: Option<Vec<String>>,
|
||||||
|
session: Option<bool>,
|
||||||
|
) -> Result<DbusCallResult, String> {
|
||||||
|
let bus = if session.unwrap_or(true) { "--session" } else { "--system" };
|
||||||
|
|
||||||
|
let mut cmd = Command::new("dbus-send");
|
||||||
|
cmd.arg("--print-reply")
|
||||||
|
.arg(bus)
|
||||||
|
.arg(format!("--dest={}", service))
|
||||||
|
.arg(&path)
|
||||||
|
.arg(&method);
|
||||||
|
|
||||||
|
if let Some(args) = args {
|
||||||
|
for a in args {
|
||||||
|
cmd.arg(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd.output().map_err(|e| format!("dbus-send Fehler: {}", e))?;
|
||||||
|
|
||||||
|
Ok(DbusCallResult {
|
||||||
|
success: output.status.success(),
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
exit_code: output.status.code().unwrap_or(-1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn dbus_list_services(session: Option<bool>) -> Result<Vec<String>, String> {
|
||||||
|
let bus = if session.unwrap_or(true) { "--session" } else { "--system" };
|
||||||
|
|
||||||
|
let output = Command::new("dbus-send")
|
||||||
|
.arg("--print-reply")
|
||||||
|
.arg(bus)
|
||||||
|
.arg("--dest=org.freedesktop.DBus")
|
||||||
|
.arg("/org/freedesktop/DBus")
|
||||||
|
.arg("org.freedesktop.DBus.ListNames")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("dbus-send ListNames: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(String::from_utf8_lossy(&output.stderr).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut services: Vec<String> = text
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let t = l.trim();
|
||||||
|
if t.starts_with("string \"") && t.ends_with('"') {
|
||||||
|
Some(t[8..t.len() - 1].to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(|s| !s.starts_with(':'))
|
||||||
|
.collect();
|
||||||
|
services.sort();
|
||||||
|
services.dedup();
|
||||||
|
Ok(services)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Xvfb (Virtuelles Display) ============
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct XvfbStatus {
|
||||||
|
pub running: bool,
|
||||||
|
pub display_num: u16,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub resolution: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static XVFB_STATE: std::sync::OnceLock<std::sync::Mutex<XvfbStatus>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
fn xvfb_state() -> &'static std::sync::Mutex<XvfbStatus> {
|
||||||
|
XVFB_STATE.get_or_init(|| {
|
||||||
|
std::sync::Mutex::new(XvfbStatus {
|
||||||
|
running: false,
|
||||||
|
display_num: 1,
|
||||||
|
pid: None,
|
||||||
|
resolution: "1920x1080x24".into(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn xvfb_start(display_num: Option<u16>, resolution: Option<String>) -> Result<XvfbStatus, String> {
|
||||||
|
let display = display_num.unwrap_or(1);
|
||||||
|
let res = resolution.unwrap_or_else(|| "1920x1080x24".into());
|
||||||
|
|
||||||
|
// Pruefen ob Xvfb verfuegbar
|
||||||
|
let check = Command::new("which").arg("Xvfb").output();
|
||||||
|
if check.map(|o| !o.status.success()).unwrap_or(true) {
|
||||||
|
return Err("Xvfb nicht installiert. Auf NixOS: nixpkgs.xorg.xvfb".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = Command::new("Xvfb")
|
||||||
|
.arg(format!(":{}", display))
|
||||||
|
.arg("-screen")
|
||||||
|
.arg("0")
|
||||||
|
.arg(&res)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Xvfb-Start fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
let status = XvfbStatus {
|
||||||
|
running: true,
|
||||||
|
display_num: display,
|
||||||
|
pid: Some(child.id()),
|
||||||
|
resolution: res,
|
||||||
|
};
|
||||||
|
|
||||||
|
*xvfb_state().lock().unwrap() = status.clone();
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn xvfb_stop() -> Result<XvfbStatus, String> {
|
||||||
|
let mut state = xvfb_state().lock().unwrap();
|
||||||
|
if let Some(pid) = state.pid {
|
||||||
|
let _ = Command::new("kill").arg(pid.to_string()).output();
|
||||||
|
}
|
||||||
|
state.running = false;
|
||||||
|
state.pid = None;
|
||||||
|
Ok(state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn xvfb_status() -> Result<XvfbStatus, String> {
|
||||||
|
Ok(xvfb_state().lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn xvfb_screenshot(display_num: Option<u16>) -> Result<String, String> {
|
||||||
|
// Screenshot vom virtuellen Display — probiert mehrere Tools durch
|
||||||
|
let display = display_num.unwrap_or(1);
|
||||||
|
let display_env = format!(":{}", display);
|
||||||
|
let tmp = std::env::temp_dir().join(format!("claude-xvfb-{}.png", uuid::Uuid::new_v4()));
|
||||||
|
|
||||||
|
// Versuchte Kommandos in Reihenfolge
|
||||||
|
let attempts: Vec<(&str, Vec<String>)> = vec![
|
||||||
|
("scrot", vec!["-q".into(), "80".into(), tmp.to_string_lossy().into_owned()]),
|
||||||
|
("import", vec!["-window".into(), "root".into(), tmp.to_string_lossy().into_owned()]),
|
||||||
|
("ffmpeg", vec![
|
||||||
|
"-f".into(), "x11grab".into(),
|
||||||
|
"-i".into(), display_env.clone(),
|
||||||
|
"-frames:v".into(), "1".into(),
|
||||||
|
"-y".into(),
|
||||||
|
tmp.to_string_lossy().into_owned(),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut last_err = String::new();
|
||||||
|
for (cmd, args) in &attempts {
|
||||||
|
// Tool vorhanden?
|
||||||
|
if Command::new("which").arg(cmd).output().map(|o| !o.status.success()).unwrap_or(true) {
|
||||||
|
last_err = format!("'{}' nicht installiert", cmd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = Command::new(cmd)
|
||||||
|
.env("DISPLAY", &display_env)
|
||||||
|
.args(args)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(o) if o.status.success() && tmp.exists() => {
|
||||||
|
let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?;
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
use base64::Engine;
|
||||||
|
return Ok(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||||
|
}
|
||||||
|
Ok(o) => {
|
||||||
|
last_err = format!("{} Exit {}: {}", cmd, o.status, String::from_utf8_lossy(&o.stderr));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_err = format!("{} Fehler: {}", cmd, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Screenshot fehlgeschlagen. Installiere eines: scrot / imagemagick / ffmpeg.\nLetzter Fehler: {}",
|
||||||
|
last_err
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Playwright-Infos ============
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlaywrightInfo {
|
||||||
|
pub available: bool,
|
||||||
|
pub hint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playwright_info() -> Result<PlaywrightInfo, String> {
|
||||||
|
// Playwright laeuft bei Eddy ueber MCP — hier nur Info fuer das UI
|
||||||
|
Ok(PlaywrightInfo {
|
||||||
|
available: true,
|
||||||
|
hint: "Playwright-Steuerung laeuft ueber den MCP-Server. Nutze die Tools \
|
||||||
|
mcp__plugin_playwright_playwright__browser_navigate, \
|
||||||
|
_click, _snapshot etc. im Chat. Für Dolibarr-Automation \
|
||||||
|
empfohlen: Session-Login via MCP-Playwright speichern."
|
||||||
|
.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
64
src-tauri/src/teaching.rs
Normal file
64
src-tauri/src/teaching.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// Claude Desktop — Schulungsmodus (Phase 15)
|
||||||
|
// Oeffnet separates Praesentations-Fenster + sendet Slides
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Slide {
|
||||||
|
pub r#type: String, // "mermaid" | "code" | "text"
|
||||||
|
pub content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn presentation_open(app: AppHandle) -> Result<(), String> {
|
||||||
|
// Falls Fenster bereits existiert, nach vorne holen
|
||||||
|
if let Some(win) = app.get_webview_window("presentation") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
WebviewWindowBuilder::new(&app, "presentation", WebviewUrl::App("/presentation".into()))
|
||||||
|
.title("Claude — Schulungsmodus")
|
||||||
|
.inner_size(1200.0, 800.0)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn presentation_close(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(win) = app.get_webview_window("presentation") {
|
||||||
|
let _ = win.close();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn presentation_send_slide(app: AppHandle, slide: Slide) -> Result<(), String> {
|
||||||
|
// Fenster oeffnen falls noch nicht offen
|
||||||
|
if app.get_webview_window("presentation").is_none() {
|
||||||
|
let _ = presentation_open(app.clone()).await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(win) = app.get_webview_window("presentation") {
|
||||||
|
win.emit("presentation-slide", &slide).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn presentation_clear(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(win) = app.get_webview_window("presentation") {
|
||||||
|
win.emit("presentation-clear", ()).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
173
src-tauri/src/voice.rs
Normal file
173
src-tauri/src/voice.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
// Claude Desktop — Voice Interface
|
||||||
|
// Speech-to-Text mit Whisper API, Text-to-Speech mit OpenAI TTS
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Whisper API Konfiguration
|
||||||
|
const OPENAI_API_URL: &str = "https://api.openai.com/v1/audio/transcriptions";
|
||||||
|
const TTS_API_URL: &str = "https://api.openai.com/v1/audio/speech";
|
||||||
|
|
||||||
|
/// Transkriptions-Ergebnis
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TranscriptionResult {
|
||||||
|
pub text: String,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTS-Stimmen
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum TtsVoice {
|
||||||
|
Alloy,
|
||||||
|
Echo,
|
||||||
|
Fable,
|
||||||
|
Onyx,
|
||||||
|
Nova,
|
||||||
|
Shimmer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TtsVoice {
|
||||||
|
fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
TtsVoice::Alloy => "alloy",
|
||||||
|
TtsVoice::Echo => "echo",
|
||||||
|
TtsVoice::Fable => "fable",
|
||||||
|
TtsVoice::Onyx => "onyx",
|
||||||
|
TtsVoice::Nova => "nova",
|
||||||
|
TtsVoice::Shimmer => "shimmer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt den OpenAI API Key aus Umgebungsvariable oder Settings
|
||||||
|
fn get_openai_key() -> Result<String, String> {
|
||||||
|
// Erst Umgebungsvariable prüfen
|
||||||
|
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||||
|
if !key.is_empty() {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternativ: Aus Settings laden (TODO)
|
||||||
|
Err("OpenAI API Key nicht gefunden. Setze OPENAI_API_KEY Umgebungsvariable.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transkribiert Audio mit OpenAI Whisper API
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn transcribe_audio(
|
||||||
|
audio_base64: String,
|
||||||
|
format: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let api_key = get_openai_key()?;
|
||||||
|
|
||||||
|
// Base64 dekodieren
|
||||||
|
let audio_bytes = BASE64.decode(&audio_base64)
|
||||||
|
.map_err(|e| format!("Base64-Dekodierung fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
// Temporäre Datei erstellen (Whisper API braucht Datei-Upload)
|
||||||
|
// Multipart-Request an Whisper API — direkt aus dem Byte-Buffer
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let file_part = reqwest::multipart::Part::bytes(audio_bytes)
|
||||||
|
.file_name(format!("audio.{}", format))
|
||||||
|
.mime_str(&format!("audio/{}", format))
|
||||||
|
.map_err(|e| format!("MIME-Type fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
let form = reqwest::multipart::Form::new()
|
||||||
|
.part("file", file_part)
|
||||||
|
.text("model", "whisper-1")
|
||||||
|
.text("language", "de") // Deutsch priorisieren
|
||||||
|
.text("response_format", "json");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(OPENAI_API_URL)
|
||||||
|
.bearer_auth(&api_key)
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("API-Request fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Whisper API Fehler: {}", error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response parsen
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WhisperResponse {
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: WhisperResponse = response.json().await
|
||||||
|
.map_err(|e| format!("Response parsen fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
println!("🎤 Transkription: \"{}\"", result.text);
|
||||||
|
|
||||||
|
Ok(result.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-to-Speech mit OpenAI TTS API
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn text_to_speech(
|
||||||
|
text: String,
|
||||||
|
voice: Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let api_key = get_openai_key()?;
|
||||||
|
|
||||||
|
let voice_name = voice.unwrap_or_else(|| "nova".to_string());
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": "tts-1",
|
||||||
|
"input": text,
|
||||||
|
"voice": voice_name,
|
||||||
|
"response_format": "mp3"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(TTS_API_URL)
|
||||||
|
.bearer_auth(&api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("TTS API-Request fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("TTS API Fehler: {}", error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio-Bytes als Base64 zurückgeben
|
||||||
|
let audio_bytes = response.bytes().await
|
||||||
|
.map_err(|e| format!("Audio lesen fehlgeschlagen: {}", e))?;
|
||||||
|
|
||||||
|
let audio_base64 = BASE64.encode(&audio_bytes);
|
||||||
|
|
||||||
|
println!("🔊 TTS generiert: {} Zeichen → {} Bytes Audio", text.len(), audio_bytes.len());
|
||||||
|
|
||||||
|
Ok(audio_base64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft ob Voice-Features verfügbar sind (API Key vorhanden)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_voice_availability() -> Result<bool, String> {
|
||||||
|
match get_openai_key() {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verfügbare TTS-Stimmen
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_tts_voices() -> Result<Vec<serde_json::Value>, String> {
|
||||||
|
Ok(vec![
|
||||||
|
serde_json::json!({ "id": "alloy", "name": "Alloy", "description": "Neutral, ausgewogen" }),
|
||||||
|
serde_json::json!({ "id": "echo", "name": "Echo", "description": "Männlich, warm" }),
|
||||||
|
serde_json::json!({ "id": "fable", "name": "Fable", "description": "Expressiv, britisch" }),
|
||||||
|
serde_json::json!({ "id": "onyx", "name": "Onyx", "description": "Tief, autoritär" }),
|
||||||
|
serde_json::json!({ "id": "nova", "name": "Nova", "description": "Weiblich, freundlich" }),
|
||||||
|
serde_json::json!({ "id": "shimmer", "name": "Shimmer", "description": "Weiblich, sanft" }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,29 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { agents, selectedAgentId, agentCount, agentTree, type AgentTreeNode } from '$lib/stores/app';
|
import { agents, selectedAgentId, agentCount, agentTree, agentMode, type AgentTreeNode } from '$lib/stores/app';
|
||||||
import type { Agent } from '$lib/stores/app';
|
import type { Agent } from '$lib/stores/app';
|
||||||
|
import { derived } from 'svelte/store';
|
||||||
|
|
||||||
|
let onlyActive = $state(false);
|
||||||
|
|
||||||
|
// Gefilterter Tree: wenn onlyActive, dann nur Agents mit status==='active'
|
||||||
|
const filteredTree = derived([agentTree], ([$tree]) => {
|
||||||
|
if (!onlyActive) return $tree;
|
||||||
|
function prune(node: AgentTreeNode): AgentTreeNode | null {
|
||||||
|
const kids = node.children.map(prune).filter((n): n is AgentTreeNode => n !== null);
|
||||||
|
if (node.agent.status === 'active' || kids.length > 0) {
|
||||||
|
return { agent: node.agent, children: kids };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $tree.map(prune).filter((n): n is AgentTreeNode => n !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegations-Badge-Text je nach Agent-Modus
|
||||||
|
const delegationBadges: Record<string, { label: string; cssClass: string }> = {
|
||||||
|
handlanger: { label: '👷 Handlanger-Auftrag', cssClass: 'badge-handlanger' },
|
||||||
|
experten: { label: '🎓 Experten-Auftrag', cssClass: 'badge-experten' },
|
||||||
|
auto: { label: '🤖 Auto', cssClass: 'badge-auto' },
|
||||||
|
};
|
||||||
|
|
||||||
// Status-Icons
|
// Status-Icons
|
||||||
const statusIcons: Record<Agent['status'], string> = {
|
const statusIcons: Record<Agent['status'], string> = {
|
||||||
|
|
@ -108,9 +131,17 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-task">{agent.task}</div>
|
<div class="agent-task">{agent.task}</div>
|
||||||
<div class="agent-meta">
|
<div class="agent-meta">
|
||||||
|
{#if agent.toolCalls.length > 0}
|
||||||
<span class="agent-tools">🔧 {agent.toolCalls.length}</span>
|
<span class="agent-tools">🔧 {agent.toolCalls.length}</span>
|
||||||
|
{/if}
|
||||||
{#if depth > 0}
|
{#if depth > 0}
|
||||||
<span class="agent-depth">Ebene {depth}</span>
|
<span class="agent-depth">Ebene {depth}</span>
|
||||||
|
{#if $agentMode && $agentMode !== 'solo' && delegationBadges[$agentMode]}
|
||||||
|
<span class="delegation-badge {delegationBadges[$agentMode].cssClass}"
|
||||||
|
title="Delegiert im Modus: {$agentMode}">
|
||||||
|
{delegationBadges[$agentMode].label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,6 +167,10 @@
|
||||||
{$agentCount.subAgents} Sub |
|
{$agentCount.subAgents} Sub |
|
||||||
{$agentCount.active} aktiv
|
{$agentCount.active} aktiv
|
||||||
</div>
|
</div>
|
||||||
|
<label class="filter-toggle">
|
||||||
|
<input type="checkbox" bind:checked={onlyActive} />
|
||||||
|
Nur aktive
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $agents.length === 0}
|
{#if $agents.length === 0}
|
||||||
|
|
@ -145,7 +180,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="agent-tree">
|
<div class="agent-tree">
|
||||||
{#each $agentTree as rootNode}
|
{#each $filteredTree as rootNode}
|
||||||
{@render agentNode(rootNode, 0)}
|
{@render agentNode(rootNode, 0)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,6 +270,20 @@
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -383,6 +432,27 @@
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delegation-badge {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-badge.badge-handlanger {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-badge.badge-experten {
|
||||||
|
color: #a855f7;
|
||||||
|
background: rgba(168, 85, 247, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegation-badge.badge-auto {
|
||||||
|
color: #06b6d4;
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-children {
|
.agent-children {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
src/lib/components/AnimatedCode.svelte
Normal file
153
src/lib/components/AnimatedCode.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
language?: string;
|
||||||
|
wpm?: number;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { code, language = 'text', wpm = 180, autoStart = true }: Props = $props();
|
||||||
|
|
||||||
|
let displayed = $state('');
|
||||||
|
let playing = $state(false);
|
||||||
|
let index = $state(0);
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
function charDelay(): number {
|
||||||
|
// ~5 Zeichen pro Wort
|
||||||
|
return 60000 / (wpm * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (playing) return;
|
||||||
|
playing = true;
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
playing = false;
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
pause();
|
||||||
|
displayed = '';
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
if (!playing || index >= code.length) {
|
||||||
|
playing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
displayed += code[index];
|
||||||
|
index++;
|
||||||
|
timer = window.setTimeout(step, charDelay());
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipToEnd() {
|
||||||
|
pause();
|
||||||
|
displayed = code;
|
||||||
|
index = code.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (autoStart) play();
|
||||||
|
return () => {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="animated-code">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="lang">{language}</span>
|
||||||
|
<div class="controls">
|
||||||
|
{#if playing}
|
||||||
|
<button onclick={pause} title="Pause">⏸</button>
|
||||||
|
{:else}
|
||||||
|
<button onclick={play} title="Abspielen">▶</button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={reset} title="Zurücksetzen">↺</button>
|
||||||
|
<button onclick={skipToEnd} title="Zum Ende">⏭</button>
|
||||||
|
<span class="speed">{wpm} WPM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="code-block"><code>{displayed}{#if playing}<span class="cursor">|</span>{/if}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.animated-code {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang {
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
font-family: 'JetBrains Mono', 'Cascadia Code', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -98,6 +98,22 @@
|
||||||
const TOKEN_WARNING_THRESHOLD = 40000; // ~40k Token = Warnung zeigen
|
const TOKEN_WARNING_THRESHOLD = 40000; // ~40k Token = Warnung zeigen
|
||||||
const KEEP_LAST_MESSAGES = 30;
|
const KEEP_LAST_MESSAGES = 30;
|
||||||
|
|
||||||
|
// Voice-Interface State
|
||||||
|
let isRecording = $state(false);
|
||||||
|
let audioLevel = $state(0);
|
||||||
|
let liveTranscript = $state('');
|
||||||
|
let mediaRecorder: MediaRecorder | null = null;
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let analyser: AnalyserNode | null = null;
|
||||||
|
let audioChunks: Blob[] = [];
|
||||||
|
let levelAnimationFrame: number | null = null;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let silenceStartTime: number | null = null;
|
||||||
|
let vadEnabled = $state(true); // VAD ein/aus
|
||||||
|
|
||||||
async function scrollToBottom() {
|
async function scrollToBottom() {
|
||||||
await tick();
|
await tick();
|
||||||
if (messagesContainer) {
|
if (messagesContainer) {
|
||||||
|
|
@ -105,13 +121,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($messages.length) scrollToBottom();
|
$effect(() => {
|
||||||
|
if ($messages.length) scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
// Bei Session-Wechsel: Compacting-Flag zurücksetzen
|
// Bei Session-Wechsel: Compacting-Flag zurücksetzen
|
||||||
$: if ($currentSessionId) {
|
$effect(() => {
|
||||||
|
if ($currentSessionId) {
|
||||||
compactingWarningShown = false;
|
compactingWarningShown = false;
|
||||||
showCompactingDialog = false;
|
showCompactingDialog = false;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Token-Schätzung: ~4 Zeichen pro Token
|
// Token-Schätzung: ~4 Zeichen pro Token
|
||||||
function estimateTokensForMessages(msgs: Message[]): number {
|
function estimateTokensForMessages(msgs: Message[]): number {
|
||||||
|
|
@ -163,13 +183,31 @@
|
||||||
showCompactingDialog = false;
|
showCompactingDialog = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Zuerst: Kritischen Kontext extrahieren und archivieren
|
||||||
|
const currentMessages = get(messages);
|
||||||
|
const messagesJson = JSON.stringify(currentMessages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content
|
||||||
|
})));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const extracted = await invoke('extract_context_before_compacting', {
|
||||||
|
sessionId,
|
||||||
|
messagesJson
|
||||||
|
});
|
||||||
|
console.log('📦 Kontext extrahiert vor Compacting:', extracted);
|
||||||
|
} catch (extractErr) {
|
||||||
|
console.warn('Context-Extraction fehlgeschlagen (nicht kritisch):', extractErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dann: Compacting durchführen
|
||||||
const compacted: number = await invoke('compact_session', {
|
const compacted: number = await invoke('compact_session', {
|
||||||
sessionId,
|
sessionId,
|
||||||
keepLast: KEEP_LAST_MESSAGES
|
keepLast: KEEP_LAST_MESSAGES
|
||||||
});
|
});
|
||||||
|
|
||||||
if (compacted > 0) {
|
if (compacted > 0) {
|
||||||
addMessage('system', `📦 Compacting: ${compacted} ältere Nachrichten wurden zusammengefasst. Die letzten ${KEEP_LAST_MESSAGES} bleiben erhalten.`);
|
addMessage('system', `📦 Compacting: ${compacted} ältere Nachrichten wurden zusammengefasst. Die letzten ${KEEP_LAST_MESSAGES} bleiben erhalten. Kritischer Kontext wurde archiviert.`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Compacting fehlgeschlagen:', err);
|
console.error('Compacting fehlgeschlagen:', err);
|
||||||
|
|
@ -182,8 +220,149 @@
|
||||||
// Warnung für diese Session nicht erneut zeigen
|
// Warnung für diese Session nicht erneut zeigen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Voice Interface ============
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
// Audio-Analyse für Pegel-Anzeige
|
||||||
|
audioContext = new AudioContext();
|
||||||
|
analyser = audioContext.createAnalyser();
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
source.connect(analyser);
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
|
||||||
|
// Pegel-Animation starten
|
||||||
|
updateAudioLevel();
|
||||||
|
|
||||||
|
// MediaRecorder für Aufnahme
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
// Aufnahme beendet — Audio an Whisper senden
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||||
|
await transcribeAudio(audioBlob);
|
||||||
|
|
||||||
|
// Stream stoppen
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start(100); // Chunks alle 100ms
|
||||||
|
isRecording = true;
|
||||||
|
liveTranscript = '';
|
||||||
|
silenceStartTime = null; // VAD-Timer zurücksetzen
|
||||||
|
console.log('🎤 Aufnahme gestartet' + (vadEnabled ? ' (VAD aktiv)' : ''));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Mikrofon-Zugriff fehlgeschlagen:', err);
|
||||||
|
addMessage('system', `⚠️ Mikrofon-Zugriff fehlgeschlagen: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pegel-Animation stoppen
|
||||||
|
if (levelAnimationFrame) {
|
||||||
|
cancelAnimationFrame(levelAnimationFrame);
|
||||||
|
levelAnimationFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio-Context schließen
|
||||||
|
if (audioContext) {
|
||||||
|
audioContext.close();
|
||||||
|
audioContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecording = false;
|
||||||
|
audioLevel = 0;
|
||||||
|
console.log('🎤 Aufnahme gestoppt');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAudioLevel() {
|
||||||
|
if (!analyser || !isRecording) return;
|
||||||
|
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
// Durchschnittspegel berechnen
|
||||||
|
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||||
|
audioLevel = Math.min(100, average * 1.5); // Normalisieren auf 0-100
|
||||||
|
|
||||||
|
// VAD: Stille erkennen und nach Pause automatisch stoppen
|
||||||
|
if (vadEnabled && audioChunks.length > 0) {
|
||||||
|
if (audioLevel < VAD_SILENCE_THRESHOLD) {
|
||||||
|
// Stille beginnt oder dauert an
|
||||||
|
if (silenceStartTime === null) {
|
||||||
|
silenceStartTime = Date.now();
|
||||||
|
} else if (Date.now() - silenceStartTime > VAD_SILENCE_DURATION) {
|
||||||
|
// Lange genug still — Aufnahme automatisch stoppen
|
||||||
|
console.log('🔇 VAD: Stille erkannt, stoppe Aufnahme');
|
||||||
|
stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sprache erkannt — Stille-Timer zurücksetzen
|
||||||
|
silenceStartTime = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
levelAnimationFrame = requestAnimationFrame(updateAudioLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribeAudio(audioBlob: Blob) {
|
||||||
|
liveTranscript = 'Transkribiere...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Audio als Base64 für Tauri-Command
|
||||||
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||||
|
|
||||||
|
// An Backend senden für Whisper-Transkription
|
||||||
|
const transcript: string = await invoke('transcribe_audio', {
|
||||||
|
audioBase64: base64,
|
||||||
|
format: 'webm'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transcript && transcript.trim()) {
|
||||||
|
// Transkript in Input-Feld einfügen
|
||||||
|
$currentInput = ($currentInput + ' ' + transcript).trim();
|
||||||
|
liveTranscript = '';
|
||||||
|
console.log('📝 Transkript:', transcript);
|
||||||
|
} else {
|
||||||
|
liveTranscript = '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Transkription fehlgeschlagen:', err);
|
||||||
|
liveTranscript = `Fehler: ${err}`;
|
||||||
|
// Nach 3s ausblenden
|
||||||
|
setTimeout(() => { liveTranscript = ''; }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecording() {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
// Voice-Aufnahme stoppen falls aktiv
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Globale Keyboard Shortcuts
|
// Globale Keyboard Shortcuts
|
||||||
|
|
@ -376,6 +555,20 @@
|
||||||
{ id: 'ids', label: 'IDs/Referenzen' },
|
{ id: 'ids', label: 'IDs/Referenzen' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let copyFeedback = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function copyMessage(message: Message) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(message.content);
|
||||||
|
copyFeedback = message.id;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copyFeedback === message.id) copyFeedback = null;
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Kopieren fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openRememberDialog(message: Message) {
|
function openRememberDialog(message: Message) {
|
||||||
rememberContent = message.content;
|
rememberContent = message.content;
|
||||||
rememberEntry = {
|
rememberEntry = {
|
||||||
|
|
@ -463,6 +656,9 @@
|
||||||
<button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button>
|
<button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.content && message.role !== 'system'}
|
{#if message.content && message.role !== 'system'}
|
||||||
|
<button class="action-btn" onclick={() => copyMessage(message)} title="Nachricht kopieren">
|
||||||
|
{copyFeedback === message.id ? '✓' : '📋'}
|
||||||
|
</button>
|
||||||
<button class="action-btn" onclick={() => openRememberDialog(message)} title="Das merken">💡</button>
|
<button class="action-btn" onclick={() => openRememberDialog(message)} title="Das merken">💡</button>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="message-time">
|
<span class="message-time">
|
||||||
|
|
@ -485,7 +681,15 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if message.role === 'assistant'}
|
{:else if message.role === 'assistant'}
|
||||||
|
{#if message.content}
|
||||||
{@html renderMarkdown(message.content)}
|
{@html renderMarkdown(message.content)}
|
||||||
|
{:else if $isProcessing}
|
||||||
|
<span class="typing">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{message.content}
|
{message.content}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -496,7 +700,8 @@
|
||||||
|
|
||||||
{#if $isProcessing}
|
{#if $isProcessing}
|
||||||
{@const lastMsg = $messages.at(-1)}
|
{@const lastMsg = $messages.at(-1)}
|
||||||
{#if !lastMsg || lastMsg.role !== 'assistant' || lastMsg.content === ''}
|
{#if !lastMsg || lastMsg.role !== 'assistant'}
|
||||||
|
<!-- Nur zeigen wenn noch gar keine assistant-message da ist -->
|
||||||
<div class="message assistant typing-msg">
|
<div class="message assistant typing-msg">
|
||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
<span class="message-role">🤖 Claude</span>
|
<span class="message-role">🤖 Claude</span>
|
||||||
|
|
@ -512,18 +717,39 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-input">
|
<div class="chat-input">
|
||||||
|
{#if liveTranscript}
|
||||||
|
<div class="live-transcript">
|
||||||
|
<span class="transcript-icon">🎤</span>
|
||||||
|
<span class="transcript-text">{liveTranscript}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={inputTextarea}
|
bind:this={inputTextarea}
|
||||||
bind:value={$currentInput}
|
bind:value={$currentInput}
|
||||||
on:keydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
|
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
|
||||||
disabled={$isProcessing}
|
disabled={$isProcessing || isRecording}
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div class="input-buttons">
|
||||||
|
<button
|
||||||
|
class="mic-button"
|
||||||
|
class:recording={isRecording}
|
||||||
|
onclick={toggleRecording}
|
||||||
|
disabled={$isProcessing}
|
||||||
|
title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'}
|
||||||
|
>
|
||||||
|
{#if isRecording}
|
||||||
|
<span class="mic-icon recording">⏹</span>
|
||||||
|
<div class="audio-level" style="height: {audioLevel}%"></div>
|
||||||
|
{:else}
|
||||||
|
<span class="mic-icon">🎤</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="send-button"
|
class="send-button"
|
||||||
on:click={sendMessage}
|
onclick={sendMessage}
|
||||||
disabled={!$currentInput.trim() || $isProcessing}
|
disabled={!$currentInput.trim() || $isProcessing || isRecording}
|
||||||
>
|
>
|
||||||
{#if $isProcessing}
|
{#if $isProcessing}
|
||||||
⏳
|
⏳
|
||||||
|
|
@ -533,6 +759,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- "Das merken" Dialog -->
|
<!-- "Das merken" Dialog -->
|
||||||
{#if rememberDialogOpen}
|
{#if rememberDialogOpen}
|
||||||
|
|
@ -1048,6 +1275,7 @@
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--bg-tertiary);
|
border-top: 1px solid var(--bg-tertiary);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input textarea {
|
.chat-input textarea {
|
||||||
|
|
@ -1084,6 +1312,100 @@
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Voice Interface */
|
||||||
|
.input-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-button.recording {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: #ef4444;
|
||||||
|
animation: pulse-recording 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-recording {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-icon {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-icon.recording {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-level {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to top, rgba(239, 68, 68, 0.4), rgba(239, 68, 68, 0.1));
|
||||||
|
transition: height 0.05s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-transcript {
|
||||||
|
position: absolute;
|
||||||
|
top: -32px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-icon {
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
258
src/lib/components/HooksPanel.svelte
Normal file
258
src/lib/components/HooksPanel.svelte
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
interface HookConfig {
|
||||||
|
name: string;
|
||||||
|
event: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookExecution {
|
||||||
|
timestamp: string;
|
||||||
|
event: string;
|
||||||
|
hook_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
success: boolean;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hooks = $state<HookConfig[]>([]);
|
||||||
|
let executions = $state<HookExecution[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function loadHooks() {
|
||||||
|
try {
|
||||||
|
hooks = await invoke<HookConfig[]>('list_hooks');
|
||||||
|
executions = await invoke<HookExecution[]>('get_hook_executions', { limit: 50 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Hooks laden fehlgeschlagen:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(hook: HookConfig) {
|
||||||
|
try {
|
||||||
|
await invoke('set_hook_enabled', {
|
||||||
|
event: hook.event,
|
||||||
|
hookName: hook.name,
|
||||||
|
enabled: !hook.enabled
|
||||||
|
});
|
||||||
|
await loadHooks();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Hook toggle fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadHooks();
|
||||||
|
|
||||||
|
// Live-Updates bei Hook-Ausführung
|
||||||
|
const unlisten = listen<{ event: string; hooks: string[]; summary: string }>('hook-fired', () => {
|
||||||
|
loadHooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then(fn => fn());
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventLabels: Record<string, string> = {
|
||||||
|
SessionStart: '🚀 Session-Start',
|
||||||
|
PreToolUse: '🔧 Vor Tool-Aufruf',
|
||||||
|
PostToolUse: '✅ Nach Tool-Aufruf',
|
||||||
|
BeforeCompacting: '📦 Vor Compacting',
|
||||||
|
AfterCompacting: '📦 Nach Compacting',
|
||||||
|
ContextFailure: '❌ Context-Fehler',
|
||||||
|
AgentStarted: '🤖 Agent-Start'
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupByEvent(items: HookConfig[]): Record<string, HookConfig[]> {
|
||||||
|
const out: Record<string, HookConfig[]> = {};
|
||||||
|
for (const h of items) {
|
||||||
|
(out[h.event] ||= []).push(h);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="hooks-panel">
|
||||||
|
<h3>🪝 Hook-System</h3>
|
||||||
|
<p class="hint">
|
||||||
|
Hooks laufen automatisch bei bestimmten Events. Deaktiviere einzelne Hooks, wenn sie stören.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Lade Hooks...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="hook-groups">
|
||||||
|
{#each Object.entries(groupByEvent(hooks)) as [event, hookList]}
|
||||||
|
<div class="hook-group">
|
||||||
|
<h4>{eventLabels[event] ?? event}</h4>
|
||||||
|
{#each hookList as hook}
|
||||||
|
<label class="hook-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hook.enabled}
|
||||||
|
onchange={() => toggle(hook)}
|
||||||
|
/>
|
||||||
|
<div class="hook-info">
|
||||||
|
<div class="hook-name">{hook.name}</div>
|
||||||
|
<div class="hook-desc">{hook.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="executions">
|
||||||
|
<h4>Letzte Ausführungen ({executions.length})</h4>
|
||||||
|
{#if executions.length === 0}
|
||||||
|
<div class="empty">Noch keine Hooks ausgeführt.</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="execution-list">
|
||||||
|
{#each executions.slice().reverse() as exec}
|
||||||
|
<li class="execution" class:failure={!exec.success}>
|
||||||
|
<span class="exec-time">{new Date(exec.timestamp).toLocaleTimeString()}</span>
|
||||||
|
<span class="exec-event">{eventLabels[exec.event] ?? exec.event}</span>
|
||||||
|
<span class="exec-name">{exec.hook_name}</span>
|
||||||
|
<span class="exec-summary">{exec.summary}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hooks-panel {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-group {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-group h4 {
|
||||||
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions h4 {
|
||||||
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 140px 160px 1fr;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: 2px var(--spacing-xs);
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution.failure {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exec-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exec-event {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exec-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exec-summary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
222
src/lib/components/IdePanel.svelte
Normal file
222
src/lib/components/IdePanel.svelte
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
interface IdeStatus {
|
||||||
|
connected: boolean;
|
||||||
|
port: number;
|
||||||
|
last_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = $state<IdeStatus>({ connected: false, port: 7890, last_error: null });
|
||||||
|
let activeFile = $state<string | null>(null);
|
||||||
|
let cursorLine = $state<number | null>(null);
|
||||||
|
let pollTimer: number | null = null;
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
try {
|
||||||
|
status = await invoke<IdeStatus>('ide_connect', { port: status.port });
|
||||||
|
if (status.connected) {
|
||||||
|
await refreshState();
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('IDE connect:', err);
|
||||||
|
status.last_error = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
try {
|
||||||
|
await invoke('ide_disconnect');
|
||||||
|
status = await invoke<IdeStatus>('ide_status');
|
||||||
|
activeFile = null;
|
||||||
|
cursorLine = null;
|
||||||
|
stopPolling();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('IDE disconnect:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshState() {
|
||||||
|
if (!status.connected) return;
|
||||||
|
try {
|
||||||
|
const result = await invoke<{ openFile?: string; cursorLine?: number }>('ide_call', {
|
||||||
|
command: 'getStatus',
|
||||||
|
args: {}
|
||||||
|
});
|
||||||
|
activeFile = result.openFile ?? null;
|
||||||
|
cursorLine = result.cursorLine ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('IDE getStatus:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
pollTimer = window.setInterval(refreshState, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPing() {
|
||||||
|
try {
|
||||||
|
const r = await invoke('ide_call', { command: 'ping', args: {} });
|
||||||
|
alert(`Pong: ${JSON.stringify(r)}`);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Fehler: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
invoke<IdeStatus>('ide_status').then((s) => {
|
||||||
|
status = s;
|
||||||
|
if (status.connected) {
|
||||||
|
startPolling();
|
||||||
|
refreshState();
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return stopPolling;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ide-panel">
|
||||||
|
<h3>🧩 VSCodium-Bridge</h3>
|
||||||
|
|
||||||
|
<div class="status" class:connected={status.connected}>
|
||||||
|
{#if status.connected}
|
||||||
|
✅ Verbunden auf Port {status.port}
|
||||||
|
{:else}
|
||||||
|
⚠️ Nicht verbunden
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if status.last_error}
|
||||||
|
<div class="error">{status.last_error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
Port:
|
||||||
|
<input type="number" bind:value={status.port} disabled={status.connected} />
|
||||||
|
</label>
|
||||||
|
{#if status.connected}
|
||||||
|
<button onclick={disconnect}>Trennen</button>
|
||||||
|
<button onclick={testPing}>Ping-Test</button>
|
||||||
|
{:else}
|
||||||
|
<button onclick={connect} class="primary">Verbinden</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if status.connected && activeFile}
|
||||||
|
<div class="active-info">
|
||||||
|
<div class="label">Aktive Datei:</div>
|
||||||
|
<div class="value">{activeFile}</div>
|
||||||
|
{#if cursorLine !== null}
|
||||||
|
<div class="label">Zeile:</div>
|
||||||
|
<div class="value">{cursorLine}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
<strong>Setup:</strong> Extension unter <code>vscode-extension/</code> in VSCodium laden
|
||||||
|
(F5 im Extension-Dev-Host, oder als .vsix paketieren). Extension startet automatisch
|
||||||
|
einen WebSocket-Server auf Port 7890.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ide-panel {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input {
|
||||||
|
width: 80px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { activeKnowledgeHints } from '$lib/stores/app';
|
||||||
|
|
||||||
// Typen für Wissensbasis
|
// Typen für Wissensbasis
|
||||||
interface KnowledgeEntry {
|
interface KnowledgeEntry {
|
||||||
|
|
@ -242,6 +243,23 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Aktive Wissens-Hints (bei Tool-Aufrufen) -->
|
||||||
|
{#if $activeKnowledgeHints.length > 0}
|
||||||
|
<div class="active-hints">
|
||||||
|
<div class="hints-header">
|
||||||
|
<span class="hints-icon">💡</span>
|
||||||
|
<span class="hints-title">Aktive Hints</span>
|
||||||
|
<button class="btn-clear-hints" onclick={() => activeKnowledgeHints.set([])}>✕</button>
|
||||||
|
</div>
|
||||||
|
{#each $activeKnowledgeHints as hint}
|
||||||
|
<div class="hint-item">
|
||||||
|
<div class="hint-title">{categoryIcons[hint.category] || '📦'} {hint.title}</div>
|
||||||
|
<div class="hint-preview">{truncate(hint.content, 150)}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Ergebnisliste -->
|
<!-- Ergebnisliste -->
|
||||||
<div class="results-list">
|
<div class="results-list">
|
||||||
{#if results.length === 0}
|
{#if results.length === 0}
|
||||||
|
|
@ -790,4 +808,79 @@
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Aktive Wissens-Hints */
|
||||||
|
.active-hints {
|
||||||
|
background: linear-gradient(135deg, rgba(250, 204, 21, 0.1) 0%, rgba(234, 179, 8, 0.05) 100%);
|
||||||
|
border: 1px solid rgba(250, 204, 21, 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hints-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hints-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hints-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear-hints {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear-hints:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-preview {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .hint-item {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
70
src/lib/components/MermaidDiagram.svelte
Normal file
70
src/lib/components/MermaidDiagram.svelte
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { code, id = `mermaid-${Math.random().toString(36).slice(2)}` }: Props = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!container) return;
|
||||||
|
try {
|
||||||
|
// @ts-expect-error - mermaid wird zur Laufzeit geladen (npm install mermaid erforderlich)
|
||||||
|
const mermaidModule = await import('mermaid');
|
||||||
|
const mermaid = mermaidModule.default;
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
||||||
|
const { svg } = await mermaid.render(id, code);
|
||||||
|
container.innerHTML = svg;
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = String(err);
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
code;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(render);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mermaid-container">
|
||||||
|
{#if error}
|
||||||
|
<pre class="error">{error}</pre>
|
||||||
|
{/if}
|
||||||
|
<div bind:this={container} class="diagram"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mermaid-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram :global(svg) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #f87171;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -288,10 +288,22 @@
|
||||||
.filter-select {
|
.filter-select {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--bg-tertiary);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:hover,
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-scroll-toggle {
|
.auto-scroll-toggle {
|
||||||
|
|
|
||||||
467
src/lib/components/PerformancePanel.svelte
Normal file
467
src/lib/components/PerformancePanel.svelte
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
<script lang="ts">
|
||||||
|
// Performance-Panel — Kosten, Token, Latenz-Statistiken
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { sessionStats, monitorStats, monitorEvents } from '$lib/stores/app';
|
||||||
|
|
||||||
|
// Aggregierte Statistiken
|
||||||
|
interface AggregatedStats {
|
||||||
|
totalSessions: number;
|
||||||
|
totalCost: number;
|
||||||
|
totalTokensIn: number;
|
||||||
|
totalTokensOut: number;
|
||||||
|
totalMessages: number;
|
||||||
|
todayCost: number;
|
||||||
|
todaySessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
cost_usd: number;
|
||||||
|
token_input: number;
|
||||||
|
token_output: number;
|
||||||
|
message_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let aggregated = $state<AggregatedStats>({
|
||||||
|
totalSessions: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
totalTokensIn: 0,
|
||||||
|
totalTokensOut: 0,
|
||||||
|
totalMessages: 0,
|
||||||
|
todayCost: 0,
|
||||||
|
todaySessions: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let recentSessions = $state<SessionSummary[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
// Latenz-Verteilung aus Monitor-Events berechnen
|
||||||
|
let latencyStats = $derived(() => {
|
||||||
|
const apiEvents = $monitorEvents.filter(e => e.type === 'api' && e.durationMs);
|
||||||
|
if (apiEvents.length === 0) return null;
|
||||||
|
|
||||||
|
const durations = apiEvents.map(e => e.durationMs!).sort((a, b) => a - b);
|
||||||
|
const sum = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: durations.length,
|
||||||
|
min: durations[0],
|
||||||
|
max: durations[durations.length - 1],
|
||||||
|
avg: Math.round(sum / durations.length),
|
||||||
|
p50: durations[Math.floor(durations.length * 0.5)],
|
||||||
|
p95: durations[Math.floor(durations.length * 0.95)] || durations[durations.length - 1],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fehlerrate berechnen
|
||||||
|
let errorRate = $derived(() => {
|
||||||
|
const total = $monitorEvents.length;
|
||||||
|
if (total === 0) return 0;
|
||||||
|
const errors = $monitorEvents.filter(e => e.type === 'error').length;
|
||||||
|
return Math.round((errors / total) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
// Sessions aus DB laden
|
||||||
|
const sessions = await invoke<SessionSummary[]>('list_sessions', { limit: 100 });
|
||||||
|
|
||||||
|
// Heute-Datum für Filter
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Aggregieren
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalTokensIn = 0;
|
||||||
|
let totalTokensOut = 0;
|
||||||
|
let totalMessages = 0;
|
||||||
|
let todayCost = 0;
|
||||||
|
let todaySessions = 0;
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
totalCost += s.cost_usd || 0;
|
||||||
|
totalTokensIn += s.token_input || 0;
|
||||||
|
totalTokensOut += s.token_output || 0;
|
||||||
|
totalMessages += s.message_count || 0;
|
||||||
|
|
||||||
|
if (s.created_at?.startsWith(today)) {
|
||||||
|
todayCost += s.cost_usd || 0;
|
||||||
|
todaySessions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated = {
|
||||||
|
totalSessions: sessions.length,
|
||||||
|
totalCost,
|
||||||
|
totalTokensIn,
|
||||||
|
totalTokensOut,
|
||||||
|
totalMessages,
|
||||||
|
todayCost,
|
||||||
|
todaySessions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Letzte 5 Sessions für Übersicht
|
||||||
|
recentSessions = sessions.slice(0, 5);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Statistiken:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatierung
|
||||||
|
function formatCost(cost: number): string {
|
||||||
|
if (cost < 0.01) return `$${(cost * 100).toFixed(2)}¢`;
|
||||||
|
return `$${cost.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(tokens: number): string {
|
||||||
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||||||
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
|
||||||
|
return tokens.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatency(ms: number): string {
|
||||||
|
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${ms}ms`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="performance-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>📈 Performance</h2>
|
||||||
|
<button class="refresh-btn" onclick={loadStats} disabled={loading} title="Aktualisieren">
|
||||||
|
{loading ? '⏳' : '🔄'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Lade Statistiken...</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Kosten-Übersicht -->
|
||||||
|
<section class="stats-section">
|
||||||
|
<h3>💰 Kosten</h3>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card accent">
|
||||||
|
<span class="stat-value">{formatCost($sessionStats.totalCost)}</span>
|
||||||
|
<span class="stat-label">Aktuelle Session</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{formatCost(aggregated.todayCost)}</span>
|
||||||
|
<span class="stat-label">Heute ({aggregated.todaySessions} Sessions)</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{formatCost(aggregated.totalCost)}</span>
|
||||||
|
<span class="stat-label">Gesamt ({aggregated.totalSessions} Sessions)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Token-Statistiken -->
|
||||||
|
<section class="stats-section">
|
||||||
|
<h3>🔢 Token</h3>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{formatTokens($sessionStats.totalTokensIn)}</span>
|
||||||
|
<span class="stat-label">Input (Session)</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{formatTokens($sessionStats.totalTokensOut)}</span>
|
||||||
|
<span class="stat-label">Output (Session)</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{formatTokens(aggregated.totalTokensIn + aggregated.totalTokensOut)}</span>
|
||||||
|
<span class="stat-label">Gesamt (alle)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Ratio Balken -->
|
||||||
|
{#if $sessionStats.totalTokensIn > 0 || $sessionStats.totalTokensOut > 0}
|
||||||
|
{@const total = $sessionStats.totalTokensIn + $sessionStats.totalTokensOut}
|
||||||
|
{@const inPercent = Math.round(($sessionStats.totalTokensIn / total) * 100)}
|
||||||
|
<div class="ratio-bar">
|
||||||
|
<div class="ratio-segment input" style="width: {inPercent}%">
|
||||||
|
<span>In {inPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="ratio-segment output" style="width: {100 - inPercent}%">
|
||||||
|
<span>Out {100 - inPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Performance-Indikatoren -->
|
||||||
|
<section class="stats-section">
|
||||||
|
<h3>⚡ Leistung</h3>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$monitorStats.apiCalls}</span>
|
||||||
|
<span class="stat-label">API-Calls</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" class:error={errorRate() > 5}>
|
||||||
|
<span class="stat-value">{errorRate()}%</span>
|
||||||
|
<span class="stat-label">Fehlerrate</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$monitorStats.avgLatencyMs}ms</span>
|
||||||
|
<span class="stat-label">Ø Latenz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Latenz-Details -->
|
||||||
|
{#if latencyStats()}
|
||||||
|
{@const stats = latencyStats()}
|
||||||
|
<div class="latency-details">
|
||||||
|
<div class="latency-row">
|
||||||
|
<span>Min:</span>
|
||||||
|
<span class="value">{formatLatency(stats.min)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="latency-row">
|
||||||
|
<span>P50:</span>
|
||||||
|
<span class="value">{formatLatency(stats.p50)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="latency-row">
|
||||||
|
<span>P95:</span>
|
||||||
|
<span class="value highlight">{formatLatency(stats.p95)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="latency-row">
|
||||||
|
<span>Max:</span>
|
||||||
|
<span class="value">{formatLatency(stats.max)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Letzte Sessions -->
|
||||||
|
{#if recentSessions.length > 1}
|
||||||
|
<section class="stats-section">
|
||||||
|
<h3>📋 Letzte Sessions</h3>
|
||||||
|
<div class="session-list">
|
||||||
|
{#each recentSessions as session}
|
||||||
|
<div class="session-row">
|
||||||
|
<span class="session-title" title={session.title}>
|
||||||
|
{session.title.length > 25 ? session.title.substring(0, 25) + '...' : session.title}
|
||||||
|
</span>
|
||||||
|
<span class="session-cost">{formatCost(session.cost_usd)}</span>
|
||||||
|
<span class="session-tokens">{formatTokens(session.token_input + session.token_output)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.performance-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section h3 {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.accent {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.accent .stat-value {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.error .stat-value {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Token Ratio Bar */
|
||||||
|
.ratio-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio-segment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio-segment.input {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio-segment.output {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio-segment span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Latenz Details */
|
||||||
|
.latency-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-row span:first-child {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-row .value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-row .value.highlight {
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session Liste */
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-cost {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tokens {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
374
src/lib/components/ProgramsPanel.svelte
Normal file
374
src/lib/components/ProgramsPanel.svelte
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import IdePanel from './IdePanel.svelte';
|
||||||
|
|
||||||
|
interface XvfbStatus {
|
||||||
|
running: boolean;
|
||||||
|
display_num: number;
|
||||||
|
pid: number | null;
|
||||||
|
resolution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaywrightInfo {
|
||||||
|
available: boolean;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let section = $state<'ide' | 'playwright' | 'dbus' | 'xvfb'>('ide');
|
||||||
|
|
||||||
|
let xvfb = $state<XvfbStatus>({ running: false, display_num: 1, pid: null, resolution: '1920x1080x24' });
|
||||||
|
let playwright = $state<PlaywrightInfo>({ available: false, hint: '' });
|
||||||
|
let dbusServices = $state<string[]>([]);
|
||||||
|
let dbusLoading = $state(false);
|
||||||
|
let screenshot = $state<string | null>(null);
|
||||||
|
let errorMsg = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function copyError() {
|
||||||
|
if (errorMsg) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(errorMsg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Clipboard:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadXvfb() {
|
||||||
|
try {
|
||||||
|
xvfb = await invoke<XvfbStatus>('xvfb_status');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startXvfb() {
|
||||||
|
try {
|
||||||
|
xvfb = await invoke<XvfbStatus>('xvfb_start', {
|
||||||
|
displayNum: xvfb.display_num,
|
||||||
|
resolution: xvfb.resolution
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopXvfb() {
|
||||||
|
try {
|
||||||
|
xvfb = await invoke<XvfbStatus>('xvfb_stop');
|
||||||
|
screenshot = null;
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeScreenshot() {
|
||||||
|
try {
|
||||||
|
const b64 = await invoke<string>('xvfb_screenshot', { displayNum: xvfb.display_num });
|
||||||
|
screenshot = `data:image/png;base64,${b64}`;
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlaywright() {
|
||||||
|
try {
|
||||||
|
playwright = await invoke<PlaywrightInfo>('playwright_info');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDbus() {
|
||||||
|
dbusLoading = true;
|
||||||
|
try {
|
||||||
|
dbusServices = await invoke<string[]>('dbus_list_services', { session: true });
|
||||||
|
} catch (err) {
|
||||||
|
dbusServices = [];
|
||||||
|
errorMsg = String(err);
|
||||||
|
} finally {
|
||||||
|
dbusLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadXvfb();
|
||||||
|
loadPlaywright();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="programs-panel">
|
||||||
|
<div class="section-tabs">
|
||||||
|
<button class:active={section === 'ide'} onclick={() => (section = 'ide')}>🧩 VSCodium</button>
|
||||||
|
<button class:active={section === 'playwright'} onclick={() => (section = 'playwright')}>🎭 Playwright</button>
|
||||||
|
<button class:active={section === 'dbus'} onclick={() => (section = 'dbus')}>🔌 D-Bus</button>
|
||||||
|
<button class:active={section === 'xvfb'} onclick={() => (section = 'xvfb')}>🖥️ Xvfb</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMsg}
|
||||||
|
<div class="error-banner">
|
||||||
|
<div class="error-header">
|
||||||
|
<span>⚠️ Fehler</span>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button onclick={copyError} title="In Zwischenablage kopieren">📋 Kopieren</button>
|
||||||
|
<button onclick={() => (errorMsg = null)} title="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="error-text">{errorMsg}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="section-body">
|
||||||
|
{#if section === 'ide'}
|
||||||
|
<IdePanel />
|
||||||
|
{:else if section === 'playwright'}
|
||||||
|
<h3>🎭 Playwright (Browser-Automation)</h3>
|
||||||
|
<div class="info-card">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
{playwright.available ? '✅ MCP-Server konfiguriert' : '⚠️ Nicht verfügbar'}
|
||||||
|
</div>
|
||||||
|
<div class="hint">{playwright.hint}</div>
|
||||||
|
{:else if section === 'dbus'}
|
||||||
|
<h3>🔌 D-Bus Services</h3>
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick={loadDbus} disabled={dbusLoading}>
|
||||||
|
{dbusLoading ? 'Lade...' : 'Services laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if dbusServices.length > 0}
|
||||||
|
<ul class="dbus-list">
|
||||||
|
{#each dbusServices as svc}
|
||||||
|
<li>{svc}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">Noch keine Services geladen.</div>
|
||||||
|
{/if}
|
||||||
|
<div class="hint">
|
||||||
|
Aufruf via Chat: Claude kann <code>dbus_call(service, path, method)</code> nutzen.
|
||||||
|
</div>
|
||||||
|
{:else if section === 'xvfb'}
|
||||||
|
<h3>🖥️ Virtuelles Display (Xvfb)</h3>
|
||||||
|
<div class="info-card" class:running={xvfb.running}>
|
||||||
|
{xvfb.running ? `✅ Läuft auf :${xvfb.display_num} (PID ${xvfb.pid})` : '⚠️ Nicht aktiv'}
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
Display:
|
||||||
|
<input type="number" bind:value={xvfb.display_num} disabled={xvfb.running} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Auflösung:
|
||||||
|
<input bind:value={xvfb.resolution} disabled={xvfb.running} />
|
||||||
|
</label>
|
||||||
|
{#if xvfb.running}
|
||||||
|
<button onclick={stopXvfb}>Stopp</button>
|
||||||
|
<button onclick={takeScreenshot}>📸 Screenshot</button>
|
||||||
|
{:else}
|
||||||
|
<button onclick={startXvfb} class="primary">Start</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if screenshot}
|
||||||
|
<img src={screenshot} alt="Xvfb Screenshot" class="screenshot" />
|
||||||
|
{/if}
|
||||||
|
<div class="hint">
|
||||||
|
Starte Programme auf diesem Display via
|
||||||
|
<code>DISPLAY=:1 firefox</code>. Claude kann Screenshots aufnehmen.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.programs-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tabs button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-tabs button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 var(--spacing-sm) 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card.running {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input {
|
||||||
|
width: 140px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button.primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dbus-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dbus-list li {
|
||||||
|
padding: 2px var(--spacing-xs);
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot {
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: var(--spacing-sm);
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px var(--spacing-sm);
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||||
|
color: #f87171;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions button:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { currentModel } from '$lib/stores/app';
|
import { currentModel, agentMode, type AgentMode } from '$lib/stores/app';
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -28,18 +28,68 @@
|
||||||
opus: { input: 15, output: 75 },
|
opus: { input: 15, output: 75 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Agent-Modi
|
||||||
|
interface AgentModeInfo {
|
||||||
|
id: AgentMode;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentModes: AgentModeInfo[] = [
|
||||||
|
{
|
||||||
|
id: 'solo',
|
||||||
|
name: 'Solo',
|
||||||
|
icon: '🎯',
|
||||||
|
description: 'Main Agent macht alles selbst. Schnell für einfache Aufgaben.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'handlanger',
|
||||||
|
name: 'Handlanger',
|
||||||
|
icon: '👷',
|
||||||
|
description: 'Main denkt, Sub-Agents führen exakt aus. Gut für koordinierte Aufgaben.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'experten',
|
||||||
|
name: 'Experten',
|
||||||
|
icon: '🧠',
|
||||||
|
description: 'Jeder Agent denkt selbst. Ideal für komplexe, parallelisierbare Aufgaben.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto',
|
||||||
|
name: 'Auto',
|
||||||
|
icon: '🔄',
|
||||||
|
description: 'Modus wird automatisch basierend auf Aufgaben-Komplexität gewählt.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
availableModels = await invoke('get_available_models');
|
availableModels = await invoke('get_available_models');
|
||||||
const current: string = await invoke('get_current_model');
|
const current: string = await invoke('get_current_model');
|
||||||
selectedModel = current;
|
selectedModel = current;
|
||||||
$currentModel = current;
|
$currentModel = current;
|
||||||
|
|
||||||
|
// Agent-Modus laden
|
||||||
|
const currentMode: string = await invoke('get_agent_mode');
|
||||||
|
$agentMode = currentMode as AgentMode;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden:', err);
|
console.error('Fehler beim Laden:', err);
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeMode(modeId: AgentMode) {
|
||||||
|
if (modeId === $agentMode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('set_agent_mode', { mode: modeId });
|
||||||
|
$agentMode = modeId;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Modus-Wechsel:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function changeModel(modelId: string) {
|
async function changeModel(modelId: string) {
|
||||||
if (modelId === selectedModel) return;
|
if (modelId === selectedModel) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
|
|
@ -106,7 +156,44 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Weitere Einstellungen (Platzhalter) -->
|
<!-- Agent-Modus -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>🤝 Agent-Modus</h3>
|
||||||
|
<p class="section-hint">Wie sollen komplexe Aufgaben bearbeitet werden?</p>
|
||||||
|
|
||||||
|
<div class="mode-list">
|
||||||
|
{#each agentModes as mode}
|
||||||
|
<button
|
||||||
|
class="mode-card"
|
||||||
|
class:selected={$agentMode === mode.id}
|
||||||
|
on:click={() => changeMode(mode.id)}
|
||||||
|
>
|
||||||
|
<div class="mode-header">
|
||||||
|
<span class="mode-icon">{mode.icon}</span>
|
||||||
|
<span class="mode-name">{mode.name}</span>
|
||||||
|
{#if $agentMode === mode.id}
|
||||||
|
<span class="mode-active">✓ Aktiv</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mode-description">{mode.description}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mode-info">
|
||||||
|
{#if $agentMode === 'solo'}
|
||||||
|
<p>💡 <strong>Solo</strong> ist ideal für schnelle, einfache Aufgaben wie Typo-Fixes oder Code-Erklärungen.</p>
|
||||||
|
{:else if $agentMode === 'handlanger'}
|
||||||
|
<p>💡 <strong>Handlanger</strong> spart Context: Sub-Agents bekommen nur die nötigen Infos und liefern kompakte Zusammenfassungen.</p>
|
||||||
|
{:else if $agentMode === 'experten'}
|
||||||
|
<p>💡 <strong>Experten</strong> für komplexe Features: Research, Implement, Test und Review arbeiten parallel.</p>
|
||||||
|
{:else}
|
||||||
|
<p>💡 <strong>Auto</strong> analysiert die Aufgabe und wählt den passenden Modus automatisch.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Weitere Einstellungen -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>🎨 Darstellung</h3>
|
<h3>🎨 Darstellung</h3>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
|
|
@ -274,4 +361,80 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent-Modus Karten */
|
||||||
|
.mode-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(233, 69, 96, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-active {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-description {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-info {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-info p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-info strong {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Claude Desktop — App-State
|
// Claude Desktop — App-State
|
||||||
|
|
||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
// Typen
|
// Typen
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
|
|
@ -57,6 +58,10 @@ export const selectedAgentId = writable<string | null>(null);
|
||||||
export const currentModel = writable('');
|
export const currentModel = writable('');
|
||||||
export const currentSessionId = writable<string | null>(null);
|
export const currentSessionId = writable<string | null>(null);
|
||||||
|
|
||||||
|
// Agent-Modus für Multi-Agent-Architektur
|
||||||
|
export type AgentMode = 'solo' | 'handlanger' | 'experten' | 'auto';
|
||||||
|
export const agentMode = writable<AgentMode>('solo');
|
||||||
|
|
||||||
// Session-Statistiken (kumuliert)
|
// Session-Statistiken (kumuliert)
|
||||||
export const sessionStats = writable({
|
export const sessionStats = writable({
|
||||||
totalTokensIn: 0,
|
totalTokensIn: 0,
|
||||||
|
|
@ -65,6 +70,31 @@ export const sessionStats = writable({
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sticky Context Status (beim App-Start geladen)
|
||||||
|
export interface StickyContextInfo {
|
||||||
|
loaded: boolean;
|
||||||
|
entries: number;
|
||||||
|
estimatedTokens: number;
|
||||||
|
hasUserInfo: boolean;
|
||||||
|
hasProject: boolean;
|
||||||
|
credentialsCount: number;
|
||||||
|
rulesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stickyContextInfo = writable<StickyContextInfo | null>(null);
|
||||||
|
|
||||||
|
// Wissens-Hints (aus claude-db)
|
||||||
|
export interface KnowledgeHint {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags?: string;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activeKnowledgeHints = writable<KnowledgeHint[]>([]);
|
||||||
|
|
||||||
// Abgeleitete Stores
|
// Abgeleitete Stores
|
||||||
export const activeAgents = derived(agents, ($agents) =>
|
export const activeAgents = derived(agents, ($agents) =>
|
||||||
$agents.filter((a) => a.status === 'active')
|
$agents.filter((a) => a.status === 'active')
|
||||||
|
|
@ -327,7 +357,7 @@ export const monitorStats = derived(monitorEvents, ($events) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monitor-Event hinzufügen
|
// Monitor-Event hinzufügen (mit Persistierung)
|
||||||
export function addMonitorEvent(
|
export function addMonitorEvent(
|
||||||
type: MonitorEventType,
|
type: MonitorEventType,
|
||||||
summary: string,
|
summary: string,
|
||||||
|
|
@ -352,12 +382,78 @@ export function addMonitorEvent(
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Asynchron in DB speichern (fire-and-forget)
|
||||||
|
saveMonitorEventToDb(event).catch((err) => {
|
||||||
|
console.warn('Monitor-Event konnte nicht gespeichert werden:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return event.id;
|
return event.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitor leeren
|
// Monitor-Event in DB speichern
|
||||||
export function clearMonitorEvents() {
|
async function saveMonitorEventToDb(event: MonitorEvent): Promise<void> {
|
||||||
|
// Für DB-Speicherung: Timestamp als ISO-String, Details als JSON-String
|
||||||
|
const dbEvent = {
|
||||||
|
id: event.id,
|
||||||
|
timestamp: event.timestamp.toISOString(),
|
||||||
|
event_type: event.type,
|
||||||
|
summary: event.summary,
|
||||||
|
details: JSON.stringify(event.details),
|
||||||
|
agent_id: event.agentId ?? null,
|
||||||
|
session_id: null, // TODO: Aktuelle Session-ID übergeben
|
||||||
|
duration_ms: event.durationMs ?? null,
|
||||||
|
error: event.error ?? null,
|
||||||
|
};
|
||||||
|
await invoke('save_monitor_event', { event: dbEvent });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor-Events aus DB laden
|
||||||
|
export async function loadMonitorEventsFromDb(limit = 500): Promise<void> {
|
||||||
|
try {
|
||||||
|
interface DbMonitorEvent {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
event_type: string;
|
||||||
|
summary: string;
|
||||||
|
details: string | null;
|
||||||
|
agent_id: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbEvents = await invoke<DbMonitorEvent[]>('load_monitor_events', { limit });
|
||||||
|
|
||||||
|
// DB-Events in Frontend-Format umwandeln (neueste zuerst → umkehren für chronologische Reihenfolge)
|
||||||
|
const events: MonitorEvent[] = dbEvents.reverse().map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
timestamp: new Date(e.timestamp),
|
||||||
|
type: e.event_type as MonitorEventType,
|
||||||
|
summary: e.summary,
|
||||||
|
details: e.details ? JSON.parse(e.details) : {},
|
||||||
|
agentId: e.agent_id ?? undefined,
|
||||||
|
durationMs: e.duration_ms ?? undefined,
|
||||||
|
error: e.error ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
monitorEvents.set(events);
|
||||||
|
console.log(`📊 ${events.length} Monitor-Events aus DB geladen`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Monitor-Events:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor leeren (auch in DB)
|
||||||
|
export async function clearMonitorEvents(): Promise<void> {
|
||||||
monitorEvents.set([]);
|
monitorEvents.set([]);
|
||||||
|
selectedMonitorEventId.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await invoke<number>('clear_all_monitor_events');
|
||||||
|
console.log(`🗑️ ${count} Monitor-Events aus DB gelöscht`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Monitor-Events konnten nicht aus DB gelöscht werden:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sensitive Daten maskieren
|
// Sensitive Daten maskieren
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,14 @@ import {
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
messageToDb,
|
messageToDb,
|
||||||
addMonitorEvent,
|
addMonitorEvent,
|
||||||
|
loadMonitorEventsFromDb,
|
||||||
|
activeKnowledgeHints,
|
||||||
|
agentMode,
|
||||||
type Message,
|
type Message,
|
||||||
type Agent,
|
type Agent,
|
||||||
type MonitorEventType
|
type MonitorEventType,
|
||||||
|
type KnowledgeHint,
|
||||||
|
type AgentMode
|
||||||
} from './app';
|
} from './app';
|
||||||
|
|
||||||
// Event-Typen vom Backend
|
// Event-Typen vom Backend
|
||||||
|
|
@ -66,6 +71,7 @@ interface ResultEvent {
|
||||||
};
|
};
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MonitorEventPayload {
|
interface MonitorEventPayload {
|
||||||
|
|
@ -102,6 +108,9 @@ export async function initEventListeners(): Promise<void> {
|
||||||
console.log('🎧 Initialisiere Event-Listener...');
|
console.log('🎧 Initialisiere Event-Listener...');
|
||||||
await cleanupEventListeners();
|
await cleanupEventListeners();
|
||||||
|
|
||||||
|
// Monitor-Events aus DB laden (letzte Session)
|
||||||
|
await loadMonitorEventsFromDb(500);
|
||||||
|
|
||||||
// Bridge bereit
|
// Bridge bereit
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen('bridge-ready', () => {
|
await listen('bridge-ready', () => {
|
||||||
|
|
@ -112,10 +121,11 @@ export async function initEventListeners(): Promise<void> {
|
||||||
// Agent gestartet
|
// Agent gestartet
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen<AgentEvent>('agent-started', (event) => {
|
await listen<AgentEvent>('agent-started', (event) => {
|
||||||
const { id, type, task } = event.payload;
|
const { id, type, task, model } = event.payload;
|
||||||
console.log('🤖 Agent gestartet:', id, type);
|
console.log('🤖 Agent gestartet:', id, type);
|
||||||
|
|
||||||
addAgent(mapAgentType(type || 'main'), task || 'Verarbeite...');
|
// WICHTIG: id mitgeben, sonst stimmt parentAgentId der Sub-Agents nicht!
|
||||||
|
addAgent(mapAgentType(type || 'main'), task || 'Verarbeite...', { id, model });
|
||||||
isProcessing.set(true);
|
isProcessing.set(true);
|
||||||
|
|
||||||
// Leere Streaming-Nachricht anlegen
|
// Leere Streaming-Nachricht anlegen
|
||||||
|
|
@ -188,7 +198,7 @@ export async function initEventListeners(): Promise<void> {
|
||||||
|
|
||||||
// Tool Start
|
// Tool Start
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen<ToolEvent>('tool-start', (event) => {
|
await listen<ToolEvent>('tool-start', async (event) => {
|
||||||
const { tool, input } = event.payload;
|
const { tool, input } = event.payload;
|
||||||
console.log('🔧 Tool Start:', tool);
|
console.log('🔧 Tool Start:', tool);
|
||||||
|
|
||||||
|
|
@ -199,6 +209,32 @@ export async function initEventListeners(): Promise<void> {
|
||||||
}
|
}
|
||||||
return ags;
|
return ags;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wissens-Hints aus claude-db laden
|
||||||
|
try {
|
||||||
|
// Command aus Input extrahieren (je nach Tool)
|
||||||
|
let command: string | undefined;
|
||||||
|
if (input && typeof input === 'object') {
|
||||||
|
// Bash: command, Read/Write/Edit: file_path
|
||||||
|
command = (input as Record<string, unknown>).command as string
|
||||||
|
|| (input as Record<string, unknown>).file_path as string
|
||||||
|
|| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hints = await invoke<KnowledgeHint[]>('get_tool_hints', {
|
||||||
|
tool: tool || 'unknown',
|
||||||
|
command,
|
||||||
|
context: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hints && hints.length > 0) {
|
||||||
|
activeKnowledgeHints.set(hints);
|
||||||
|
console.log('💡 Wissens-Hints geladen:', hints.map(h => h.title));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Fehler beim Laden ignorieren — Hints sind optional
|
||||||
|
console.debug('Wissens-Hints nicht verfügbar:', err);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -230,8 +266,8 @@ export async function initEventListeners(): Promise<void> {
|
||||||
// Ergebnis (Kosten, Token, Modell)
|
// Ergebnis (Kosten, Token, Modell)
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen<ResultEvent>('claude-result', async (event) => {
|
await listen<ResultEvent>('claude-result', async (event) => {
|
||||||
const { cost, tokens, session_id, model } = event.payload;
|
const { cost, tokens, session_id, model, text } = event.payload;
|
||||||
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model });
|
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model, session_id });
|
||||||
|
|
||||||
// Modell an die Streaming-Nachricht anhängen und speichern
|
// Modell an die Streaming-Nachricht anhängen und speichern
|
||||||
if (streamingMessageId) {
|
if (streamingMessageId) {
|
||||||
|
|
@ -240,7 +276,9 @@ export async function initEventListeners(): Promise<void> {
|
||||||
messages.update((msgs) => {
|
messages.update((msgs) => {
|
||||||
return msgs.map((m) => {
|
return msgs.map((m) => {
|
||||||
if (m.id === streamingMessageId) {
|
if (m.id === streamingMessageId) {
|
||||||
finalMessage = { ...m, model: model || m.model };
|
// Fallback: wenn kein Streaming-Text kam, result.text nutzen
|
||||||
|
const content = m.content && m.content.trim() ? m.content : (text || '');
|
||||||
|
finalMessage = { ...m, content, model: model || m.model };
|
||||||
return finalMessage;
|
return finalMessage;
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
|
|
@ -257,6 +295,22 @@ export async function initEventListeners(): Promise<void> {
|
||||||
currentModel.set(model);
|
currentModel.set(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Session-ID speichern für Fortsetzung
|
||||||
|
if (session_id) {
|
||||||
|
const appSessionId = get(currentSessionId);
|
||||||
|
if (appSessionId) {
|
||||||
|
try {
|
||||||
|
await invoke('set_claude_session_id', {
|
||||||
|
sessionId: appSessionId,
|
||||||
|
claudeSessionId: session_id,
|
||||||
|
});
|
||||||
|
console.log('🔗 Claude Session-ID gespeichert:', session_id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Claude Session-ID konnte nicht gespeichert werden:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Session-Statistiken aktualisieren
|
// Session-Statistiken aktualisieren
|
||||||
if (tokens || cost) {
|
if (tokens || cost) {
|
||||||
sessionStats.update((s) => ({
|
sessionStats.update((s) => ({
|
||||||
|
|
@ -278,6 +332,15 @@ export async function initEventListeners(): Promise<void> {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Agent-Modus geändert (von Bridge bestätigt)
|
||||||
|
listeners.push(
|
||||||
|
await listen<{ mode: AgentMode }>('mode-changed', (event) => {
|
||||||
|
const { mode } = event.payload;
|
||||||
|
console.log('🔄 Agent-Modus geändert:', mode);
|
||||||
|
agentMode.set(mode);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Monitor-Events — für System-Monitor Panel
|
// Monitor-Events — für System-Monitor Panel
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen<MonitorEventPayload>('monitor', (event) => {
|
await listen<MonitorEventPayload>('monitor', (event) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
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 { isProcessing, agentCount, currentModel, sessionStats, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, type DbMessage } from '$lib/stores';
|
import { isProcessing, agentCount, currentModel, sessionStats, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores';
|
||||||
import StopButton from '$lib/components/StopButton.svelte';
|
import StopButton from '$lib/components/StopButton.svelte';
|
||||||
|
|
||||||
// Session-Typ vom Backend
|
// Session-Typ vom Backend
|
||||||
|
|
@ -21,6 +21,17 @@
|
||||||
last_message: string | null;
|
last_message: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backend-Response für Sticky Context
|
||||||
|
interface StickyContextResponse {
|
||||||
|
loaded: boolean;
|
||||||
|
entries: number;
|
||||||
|
estimated_tokens: number;
|
||||||
|
has_user_info: boolean;
|
||||||
|
has_project: boolean;
|
||||||
|
credentials_count: number;
|
||||||
|
rules_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await initEventListeners();
|
await initEventListeners();
|
||||||
|
|
||||||
|
|
@ -34,6 +45,35 @@
|
||||||
console.warn('Modell konnte nicht geladen werden:', err);
|
console.warn('Modell konnte nicht geladen werden:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent-Modus aus Settings laden (sonst Badge nicht sofort sichtbar)
|
||||||
|
try {
|
||||||
|
const mode: string = await invoke('get_agent_mode');
|
||||||
|
if (mode) {
|
||||||
|
$agentMode = mode as AgentMode;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Agent-Modus konnte nicht geladen werden:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky Context beim Start laden und an Bridge senden
|
||||||
|
try {
|
||||||
|
const ctx: StickyContextResponse = await invoke('init_sticky_context');
|
||||||
|
$stickyContextInfo = {
|
||||||
|
loaded: ctx.loaded,
|
||||||
|
entries: ctx.entries,
|
||||||
|
estimatedTokens: ctx.estimated_tokens,
|
||||||
|
hasUserInfo: ctx.has_user_info,
|
||||||
|
hasProject: ctx.has_project,
|
||||||
|
credentialsCount: ctx.credentials_count,
|
||||||
|
rulesCount: ctx.rules_count,
|
||||||
|
};
|
||||||
|
if (ctx.loaded) {
|
||||||
|
console.log(`📌 Sticky Context geladen: ${ctx.entries} Einträge, ~${ctx.estimated_tokens} Token`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Sticky Context konnte nicht geladen werden:', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Aktive Session automatisch laden (falls vorhanden)
|
// Aktive Session automatisch laden (falls vorhanden)
|
||||||
try {
|
try {
|
||||||
const activeSession: Session | null = await invoke('get_active_session');
|
const activeSession: Session | null = await invoke('get_active_session');
|
||||||
|
|
@ -111,6 +151,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="titlebar-right">
|
<div class="titlebar-right">
|
||||||
|
<button
|
||||||
|
class="teach-btn"
|
||||||
|
title="Schulungsmodus (Präsentations-Fenster)"
|
||||||
|
onclick={() => invoke('presentation_open').catch(e => console.error(e))}
|
||||||
|
>
|
||||||
|
🎓
|
||||||
|
</button>
|
||||||
{#if $currentModel}
|
{#if $currentModel}
|
||||||
<span class="model-badge">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
|
<span class="model-badge">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -125,6 +172,12 @@
|
||||||
<footer class="footer" class:active={$isProcessing}>
|
<footer class="footer" class:active={$isProcessing}>
|
||||||
<StopButton on:click={handleStop} disabled={!$isProcessing} />
|
<StopButton on:click={handleStop} disabled={!$isProcessing} />
|
||||||
<div class="footer-stats">
|
<div class="footer-stats">
|
||||||
|
{#if $stickyContextInfo?.loaded}
|
||||||
|
<span class="context-badge" title="Sticky Context: {$stickyContextInfo.entries} Einträge, ~{$stickyContextInfo.estimatedTokens} Token">
|
||||||
|
📌 +{$stickyContextInfo.estimatedTokens}ctx
|
||||||
|
</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
{/if}
|
||||||
<span>Token: {formatTokens($sessionStats.totalTokensIn)} in / {formatTokens($sessionStats.totalTokensOut)} out</span>
|
<span>Token: {formatTokens($sessionStats.totalTokensIn)} in / {formatTokens($sessionStats.totalTokensOut)} out</span>
|
||||||
<span class="sep">|</span>
|
<span class="sep">|</span>
|
||||||
<span>Kosten: {formatCost($sessionStats.totalCost)}</span>
|
<span>Kosten: {formatCost($sessionStats.totalCost)}</span>
|
||||||
|
|
@ -134,6 +187,15 @@
|
||||||
<span class="sep">|</span>
|
<span class="sep">|</span>
|
||||||
<span class="model">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
|
<span class="model">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $agentMode && $agentMode !== 'solo'}
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="mode-badge mode-{$agentMode}" title="Agent-Modus: {$agentMode}">
|
||||||
|
{#if $agentMode === 'handlanger'}👷 Handlanger
|
||||||
|
{:else if $agentMode === 'experten'}🎓 Experten
|
||||||
|
{:else if $agentMode === 'auto'}🤖 Auto
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -242,4 +304,47 @@
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-stats .context-badge {
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats .mode-badge {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats .mode-handlanger {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats .mode-experten {
|
||||||
|
color: #a855f7;
|
||||||
|
background: rgba(168, 85, 247, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats .mode-auto {
|
||||||
|
color: #06b6d4;
|
||||||
|
background: rgba(6, 182, 212, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teach-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teach-btn:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
import GuardRailsPanel from '$lib/components/GuardRailsPanel.svelte';
|
import GuardRailsPanel from '$lib/components/GuardRailsPanel.svelte';
|
||||||
import SettingsPanel from '$lib/components/SettingsPanel.svelte';
|
import SettingsPanel from '$lib/components/SettingsPanel.svelte';
|
||||||
import MonitorPanel from '$lib/components/MonitorPanel.svelte';
|
import MonitorPanel from '$lib/components/MonitorPanel.svelte';
|
||||||
|
import PerformancePanel from '$lib/components/PerformancePanel.svelte';
|
||||||
|
import HooksPanel from '$lib/components/HooksPanel.svelte';
|
||||||
|
import ProgramsPanel from '$lib/components/ProgramsPanel.svelte';
|
||||||
|
|
||||||
let activeMiddleTab = 'activity';
|
let activeMiddleTab = 'activity';
|
||||||
let activeRightTab = 'agents';
|
let activeRightTab = 'agents';
|
||||||
|
|
@ -18,14 +21,17 @@
|
||||||
const middleTabs = [
|
const middleTabs = [
|
||||||
{ id: 'activity', label: 'Aktivität', icon: '📋' },
|
{ id: 'activity', label: 'Aktivität', icon: '📋' },
|
||||||
{ id: 'monitor', label: 'Monitor', icon: '📊' },
|
{ id: 'monitor', label: 'Monitor', icon: '📊' },
|
||||||
|
{ id: 'perf', label: 'Kosten', icon: '📈' },
|
||||||
{ id: 'knowledge', label: 'Wissen', icon: '📚' },
|
{ id: 'knowledge', label: 'Wissen', icon: '📚' },
|
||||||
{ id: 'memory', label: 'Gedächtnis', icon: '🧠' },
|
{ id: 'memory', label: 'Gedächtnis', icon: '🧠' },
|
||||||
{ id: 'audit', label: 'Historie', icon: '📝' },
|
{ id: 'audit', label: 'Historie', icon: '📝' },
|
||||||
|
{ id: 'programs', label: 'Programme', icon: '🖥️' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rightTabs = [
|
const rightTabs = [
|
||||||
{ id: 'agents', label: 'Agents', icon: '🤖' },
|
{ id: 'agents', label: 'Agents', icon: '🤖' },
|
||||||
{ id: 'context', label: 'Context', icon: '📌' },
|
{ id: 'context', label: 'Context', icon: '📌' },
|
||||||
|
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
|
||||||
{ id: 'guards', label: 'Guard-Rails', icon: '🛡️' },
|
{ id: 'guards', label: 'Guard-Rails', icon: '🛡️' },
|
||||||
{ id: 'settings', label: 'Settings', icon: '⚙️' },
|
{ id: 'settings', label: 'Settings', icon: '⚙️' },
|
||||||
];
|
];
|
||||||
|
|
@ -68,12 +74,16 @@
|
||||||
<ActivityPanel />
|
<ActivityPanel />
|
||||||
{:else if activeMiddleTab === 'monitor'}
|
{:else if activeMiddleTab === 'monitor'}
|
||||||
<MonitorPanel />
|
<MonitorPanel />
|
||||||
|
{:else if activeMiddleTab === 'perf'}
|
||||||
|
<PerformancePanel />
|
||||||
{:else if activeMiddleTab === 'knowledge'}
|
{:else if activeMiddleTab === 'knowledge'}
|
||||||
<KnowledgePanel />
|
<KnowledgePanel />
|
||||||
{:else if activeMiddleTab === 'memory'}
|
{:else if activeMiddleTab === 'memory'}
|
||||||
<MemoryPanel />
|
<MemoryPanel />
|
||||||
{:else if activeMiddleTab === 'audit'}
|
{:else if activeMiddleTab === 'audit'}
|
||||||
<AuditLog />
|
<AuditLog />
|
||||||
|
{:else if activeMiddleTab === 'programs'}
|
||||||
|
<ProgramsPanel />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Pane>
|
</Pane>
|
||||||
|
|
@ -100,6 +110,8 @@
|
||||||
<AgentView />
|
<AgentView />
|
||||||
{:else if activeRightTab === 'context'}
|
{:else if activeRightTab === 'context'}
|
||||||
<ContextPanel />
|
<ContextPanel />
|
||||||
|
{:else if activeRightTab === 'hooks'}
|
||||||
|
<HooksPanel />
|
||||||
{:else if activeRightTab === 'guards'}
|
{:else if activeRightTab === 'guards'}
|
||||||
<GuardRailsPanel />
|
<GuardRailsPanel />
|
||||||
{:else if activeRightTab === 'settings'}
|
{:else if activeRightTab === 'settings'}
|
||||||
|
|
|
||||||
179
src/routes/presentation/+page.svelte
Normal file
179
src/routes/presentation/+page.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import MermaidDiagram from '$lib/components/MermaidDiagram.svelte';
|
||||||
|
import AnimatedCode from '$lib/components/AnimatedCode.svelte';
|
||||||
|
|
||||||
|
interface Slide {
|
||||||
|
type: 'mermaid' | 'code' | 'text';
|
||||||
|
content: string;
|
||||||
|
language?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slides = $state<Slide[]>([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
title: 'Willkommen',
|
||||||
|
content: '🎓 Schulungsmodus bereit.\n\nClaude schickt dir hier Mindmaps, Flowcharts und animierten Code.'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
let currentIndex = $state(0);
|
||||||
|
let wpm = $state(180);
|
||||||
|
let paused = $state(false);
|
||||||
|
|
||||||
|
const current = $derived(slides[currentIndex]);
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (currentIndex < slides.length - 1) currentIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (currentIndex > 0) currentIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSlide(slide: Slide) {
|
||||||
|
slides = [...slides, slide];
|
||||||
|
currentIndex = slides.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unlistenSlide = listen<Slide>('presentation-slide', (event) => {
|
||||||
|
addSlide(event.payload);
|
||||||
|
});
|
||||||
|
const unlistenClear = listen('presentation-clear', () => {
|
||||||
|
slides = [];
|
||||||
|
currentIndex = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === ' ') next();
|
||||||
|
else if (e.key === 'ArrowLeft') prev();
|
||||||
|
else if (e.key === 'p') paused = !paused;
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', keyHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenSlide.then(fn => fn());
|
||||||
|
unlistenClear.then(fn => fn());
|
||||||
|
window.removeEventListener('keydown', keyHandler);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="presentation">
|
||||||
|
<div class="content">
|
||||||
|
{#if current}
|
||||||
|
{#if current.title}
|
||||||
|
<h1>{current.title}</h1>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if current.type === 'mermaid'}
|
||||||
|
<MermaidDiagram code={current.content} />
|
||||||
|
{:else if current.type === 'code'}
|
||||||
|
<AnimatedCode
|
||||||
|
code={current.content}
|
||||||
|
language={current.language ?? 'text'}
|
||||||
|
{wpm}
|
||||||
|
autoStart={!paused}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<pre class="text-slide">{current.content}</pre>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="controls">
|
||||||
|
<button onclick={prev} disabled={currentIndex === 0}>◀◀</button>
|
||||||
|
<button onclick={() => paused = !paused}>{paused ? '▶' : '⏸'}</button>
|
||||||
|
<button onclick={next} disabled={currentIndex >= slides.length - 1}>▶▶</button>
|
||||||
|
<label>
|
||||||
|
Tempo:
|
||||||
|
<input type="range" min="60" max="400" bind:value={wpm} />
|
||||||
|
<span>{wpm} WPM</span>
|
||||||
|
</label>
|
||||||
|
<span class="counter">{currentIndex + 1} / {slides.length}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(html, body) {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-slide {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 70ch;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: #334155;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover:not(:disabled) {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
vscode-extension/README.md
Normal file
37
vscode-extension/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Claude Desktop Bridge — VSCode Extension
|
||||||
|
|
||||||
|
Ermöglicht Claude Desktop, VSCodium/VSCode zu steuern — ohne Maus-Simulation.
|
||||||
|
|
||||||
|
## Installation (Entwicklung)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vscode-extension
|
||||||
|
npm install
|
||||||
|
npm run compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in VSCodium:
|
||||||
|
- `F5` für Extension Development Host, oder
|
||||||
|
- `vsce package` → `.vsix` → Installieren
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- WebSocket-Server auf Port 7890 (konfigurierbar)
|
||||||
|
- Commands: openFile, goToLine, formatDocument, findInFiles, openTerminal, getStatus, executeCommand
|
||||||
|
- Status-Bar-Anzeige des Verbindungsstatus
|
||||||
|
|
||||||
|
## Protokoll
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{ "id": "uuid", "command": "openFile", "args": { "path": "/path/to/file" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "id": "uuid", "result": { "opened": "/path/to/file" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Server lauscht nur auf `127.0.0.1` — kein Zugriff von außen.
|
||||||
54
vscode-extension/package.json
Normal file
54
vscode-extension/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"name": "claude-desktop-bridge",
|
||||||
|
"displayName": "Claude Desktop Bridge",
|
||||||
|
"description": "Steuert VSCodium von Claude Desktop aus",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"publisher": "data-it",
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.85.0"
|
||||||
|
},
|
||||||
|
"categories": ["Other"],
|
||||||
|
"activationEvents": ["onStartupFinished"],
|
||||||
|
"main": "./out/extension.js",
|
||||||
|
"contributes": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "claude-desktop.connect",
|
||||||
|
"title": "Claude Desktop: Verbindung starten"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "claude-desktop.disconnect",
|
||||||
|
"title": "Claude Desktop: Verbindung beenden"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration": {
|
||||||
|
"title": "Claude Desktop",
|
||||||
|
"properties": {
|
||||||
|
"claudeDesktop.port": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 7890,
|
||||||
|
"description": "WebSocket-Port fuer Claude Desktop Verbindung"
|
||||||
|
},
|
||||||
|
"claudeDesktop.autoConnect": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatisch beim Start verbinden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"vscode:prepublish": "npm run compile",
|
||||||
|
"compile": "tsc -p ./",
|
||||||
|
"watch": "tsc -watch -p ./"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/vscode": "^1.85.0",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
185
vscode-extension/src/extension.ts
Normal file
185
vscode-extension/src/extension.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// Claude Desktop Bridge - VSCode Extension
|
||||||
|
// WebSocket-Server, der Commands von Claude Desktop empfaengt
|
||||||
|
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
|
||||||
|
interface BridgeRequest {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BridgeResponse {
|
||||||
|
id: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wss: WebSocketServer | null = null;
|
||||||
|
let statusBar: vscode.StatusBarItem;
|
||||||
|
let connectedClients = 0;
|
||||||
|
|
||||||
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
|
console.log('[Claude Desktop Bridge] aktiviert');
|
||||||
|
|
||||||
|
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||||
|
statusBar.text = '$(broadcast) Claude: aus';
|
||||||
|
statusBar.command = 'claude-desktop.connect';
|
||||||
|
statusBar.show();
|
||||||
|
context.subscriptions.push(statusBar);
|
||||||
|
|
||||||
|
const connectCmd = vscode.commands.registerCommand('claude-desktop.connect', () => {
|
||||||
|
startServer(context);
|
||||||
|
});
|
||||||
|
const disconnectCmd = vscode.commands.registerCommand('claude-desktop.disconnect', () => {
|
||||||
|
stopServer();
|
||||||
|
});
|
||||||
|
context.subscriptions.push(connectCmd, disconnectCmd);
|
||||||
|
|
||||||
|
const cfg = vscode.workspace.getConfiguration('claudeDesktop');
|
||||||
|
if (cfg.get<boolean>('autoConnect')) {
|
||||||
|
startServer(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(context: vscode.ExtensionContext) {
|
||||||
|
if (wss) {
|
||||||
|
vscode.window.showInformationMessage('Claude Bridge laeuft bereits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = vscode.workspace.getConfiguration('claudeDesktop').get<number>('port', 7890);
|
||||||
|
|
||||||
|
try {
|
||||||
|
wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
||||||
|
} catch (err) {
|
||||||
|
vscode.window.showErrorMessage(`Claude Bridge Port ${port} blockiert: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
|
connectedClients++;
|
||||||
|
updateStatus();
|
||||||
|
console.log('[Claude Desktop Bridge] Client verbunden');
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
let req: BridgeRequest;
|
||||||
|
try {
|
||||||
|
req = JSON.parse(data.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: BridgeResponse = { id: req.id };
|
||||||
|
try {
|
||||||
|
response.result = await handleCommand(req.command, req.args ?? {});
|
||||||
|
} catch (err: any) {
|
||||||
|
response.error = err?.message ?? String(err);
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify(response));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
connectedClients--;
|
||||||
|
updateStatus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('error', (err) => {
|
||||||
|
vscode.window.showErrorMessage(`Claude Bridge Fehler: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateStatus();
|
||||||
|
vscode.window.showInformationMessage(`Claude Bridge auf Port ${port} gestartet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer() {
|
||||||
|
if (!wss) return;
|
||||||
|
wss.close();
|
||||||
|
wss = null;
|
||||||
|
connectedClients = 0;
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
if (!wss) {
|
||||||
|
statusBar.text = '$(broadcast) Claude: aus';
|
||||||
|
statusBar.backgroundColor = undefined;
|
||||||
|
} else if (connectedClients > 0) {
|
||||||
|
statusBar.text = `$(check) Claude: verbunden (${connectedClients})`;
|
||||||
|
statusBar.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||||
|
} else {
|
||||||
|
statusBar.text = '$(broadcast) Claude: wartet';
|
||||||
|
statusBar.backgroundColor = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command-Handler fuer Claude Desktop
|
||||||
|
async function handleCommand(command: string, args: Record<string, unknown>): Promise<unknown> {
|
||||||
|
switch (command) {
|
||||||
|
case 'ping':
|
||||||
|
return { pong: true, version: vscode.version };
|
||||||
|
|
||||||
|
case 'openFile': {
|
||||||
|
const path = args.path as string;
|
||||||
|
const uri = vscode.Uri.file(path);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
return { opened: path };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'goToLine': {
|
||||||
|
const line = args.line as number;
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor) throw new Error('Kein aktiver Editor');
|
||||||
|
const pos = new vscode.Position(Math.max(0, line - 1), 0);
|
||||||
|
editor.selection = new vscode.Selection(pos, pos);
|
||||||
|
editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter);
|
||||||
|
return { line };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'formatDocument': {
|
||||||
|
await vscode.commands.executeCommand('editor.action.formatDocument');
|
||||||
|
return { formatted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'findInFiles': {
|
||||||
|
const query = args.query as string;
|
||||||
|
await vscode.commands.executeCommand('workbench.action.findInFiles', { query });
|
||||||
|
return { query };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'openTerminal': {
|
||||||
|
const terminal = vscode.window.createTerminal(args.name as string ?? 'Claude');
|
||||||
|
terminal.show();
|
||||||
|
if (args.command) {
|
||||||
|
terminal.sendText(args.command as string);
|
||||||
|
}
|
||||||
|
return { created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'getStatus': {
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
return {
|
||||||
|
openFile: editor?.document.fileName,
|
||||||
|
cursorLine: editor ? editor.selection.active.line + 1 : null,
|
||||||
|
language: editor?.document.languageId,
|
||||||
|
workspaceFolders: vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'executeCommand': {
|
||||||
|
const cmd = args.command as string;
|
||||||
|
const cmdArgs = (args.args as unknown[]) ?? [];
|
||||||
|
return await vscode.commands.executeCommand(cmd, ...cmdArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unbekannter Command: ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivate() {
|
||||||
|
stopServer();
|
||||||
|
}
|
||||||
16
vscode-extension/tsconfig.json
Normal file
16
vscode-extension/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "ES2021",
|
||||||
|
"lib": ["ES2021"],
|
||||||
|
"outDir": "out",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["node", "vscode", "ws"],
|
||||||
|
"typeRoots": ["./node_modules/@types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", ".vscode-test", "../node_modules"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue