diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4e9c5d1..f322829 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md – Gott -**Gott** ist eine selbst-gehostete AI-Chat-Plattform, gebaut auf **.NET 10** mit **Anthropic Claude API** als Haupt-Provider sowie **OpenAI-kompatiblen Providern** (Nvidia, Pollinations, Gemini). Sie bietet Multi-User-Support, persistente Agent-Memory, Datei-/Shell-Tools, Telegram-Integration, Cronjobs und eine Krea-Bildgenerierung – alles über ein Vanilla-JavaScript-Frontend gesteuert. +**Gott** ist eine selbst-gehostete AI-Chat-Plattform, gebaut auf **.NET 10** mit **Anthropic Claude API** als Haupt-Provider sowie **OpenAI-kompatiblen Providern** (Nvidia, Pollinations, Gemini). Sie bietet Multi-User-Support, persistente Agent-Memory, 16 Tools (Dateien, Shell, Suche, Memory, Remote-Execution), semantische Suche via lokalen Embeddings, Telegram-Integration, Cronjobs, Remote-Verbindungen und eine Krea-Bildgenerierung – alles über ein Vanilla-JavaScript-Frontend gesteuert. --- @@ -38,9 +38,13 @@ 24. [Avatar-System](#avatar-system) 25. [LaTeX/Math-Rendering](#latexmath-rendering) 26. [Windows Desktop App](#windows-desktop-app) -27. [Deployment](#deployment) -28. [Konfiguration](#konfiguration) -29. [Datenbank-Migrationen](#datenbank-migrationen) +27. [OpenClaw Import](#openclaw-import) +28. [Remote-Verbindungen](#remote-verbindungen) +29. [Semantische Suche (Embedding)](#semantische-suche-embedding) +30. [API-Key-Verwaltung](#api-key-verwaltung) +31. [Deployment](#deployment) +32. [Konfiguration](#konfiguration) +33. [Datenbank-Migrationen](#datenbank-migrationen) --- @@ -56,13 +60,17 @@ ├──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬────┤ │ Auth │ Chat │ Hist │ Sess │Agent │ Tele │ Cron │ Krea │Adm │ │ │ SSE │ │ │ │ gram │ jobs │ Img │ in │ +│ │ │ │ │ │ │ │ │Keys│ ├──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴────┤ │ Services Layer │ │ AnthropicService · OpenAiService · ChatService · ToolService│ -│ DatabaseService · MemoryService · SkillService │ -│ TelegramService · CronjobService · KreaService │ +│ DatabaseService · MemoryService · SkillService · SseHelper │ +│ EmbeddingService · VectorSearchService │ +│ RemoteConnectionManager · TelegramService │ +│ CronjobService · KreaService │ ├─────────────────────────────────────────────────────────────┤ │ SQLite (data/gott.db) via Dapper │ +│ ONNX Runtime (Embeddings) │ │ Filesystem (data/agents/, data/skills/) │ ├─────────────────────────────────────────────────────────────┤ │ Anthropic Claude API (claude-sonnet/opus/haiku) │ @@ -88,6 +96,7 @@ | **Markdown** | marked.js (Client-side Rendering) | | **LaTeX** | KaTeX 0.16.21 (Math-Rendering, self-hosted mit woff2-Fonts) | | **Editor** | CodeMirror 5 + EasyMDE | +| **Embeddings**| Microsoft.ML.OnnxRuntime + all-MiniLM-L6-v2 (384-dim Sentence-Embeddings) | | **Icons** | Font Awesome | | **Fonts** | Inter, JetBrains Mono (self-hosted) | | **Desktop** | WPF + WebView2 (.NET 10, Windows-only) | @@ -115,18 +124,28 @@ gott/ │ ├── SharedMessageRecord.cs # Geteilte Nachrichten (Share-Token) │ ├── TelegramBotRecord.cs # Telegram Bot Entität │ ├── ChatModels.cs # ChatMessage, ChatRequest, AttachmentInfo/Block +│ ├── ApiKeyRecord.cs # API-Key-Verwaltung (Provider, Name, Key, IsActive) │ └── ChatContext.cs # Runtime-Kontext für einen Chat + ReviewContext │ ├── Services/ # Business-Logic -│ ├── DatabaseService.cs # SQLite CRUD, Schema, Migrationen +│ ├── DatabaseService.cs # SQLite Schema, Migrationen, Core (partial class) +│ ├── DatabaseService.Sessions.cs # Sessions, Archives, Chains, Messages, Attachments +│ ├── DatabaseService.Agents.cs # Agents, Agent Skills +│ ├── DatabaseService.Users.cs # Users, Shared Messages +│ ├── DatabaseService.Integrations.cs # Telegram, Cronjobs, API Keys, OpenClaw Import │ ├── AuthService.cs # Token-basierte Authentifizierung │ ├── ChatService.cs # Kontext-Aufbau, History-Builder │ ├── AnthropicService.cs # Claude API (Streaming + Non-Streaming) │ ├── OpenAiService.cs # OpenAI-kompatible Provider (Nvidia, Pollinations, Gemini) -│ ├── ToolService.cs # Tool-Definitionen + Ausführung +│ ├── SseHelper.cs # Shared SSE-Utilities (WriteSseEvent, TryWriteSse, FinalizeChain) +│ ├── ToolService.cs # Tool-Definitionen + Ausführung (16 Tools) │ ├── SystemPromptBuilder.cs # Modularer System-Prompt aus Blöcken │ ├── MemoryService.cs # Agent-Gedächtnis (Dateisystem) │ ├── SkillService.cs # Skill-Verwaltung (Dateisystem) +│ ├── EmbeddingService.cs # Lokales Sentence-Embedding (all-MiniLM-L6-v2, ONNX) +│ ├── VectorSearchService.cs # Semantische Suche über Message-Embeddings + Memory-Files +│ ├── EmbeddingBackgroundService.cs # Backfill + Live-Embedding neuer Messages (BackgroundService) +│ ├── RemoteConnectionManager.cs # WebSocket-basierte Remote-Verbindungen (Command-Execution) │ ├── TelegramService.cs # Telegram Bot API Integration │ ├── CronjobService.cs # BackgroundService für Cron-Tasks │ ├── KreaService.cs # Krea Image Generation API @@ -149,11 +168,13 @@ gott/ │ ├── ModelsEndpoint.cs # Provider-Modelle (nvidia, pollinations, gemini, refresh) │ ├── SharedEndpoint.cs # Nachrichten teilen (öffentliche Links) │ ├── AdminEndpoint.cs # Admin: User-CRUD, Impersonate (Admin-only) +│ ├── ApiKeysEndpoint.cs # Admin: API-Key-CRUD, Activate/Deactivate, Check (Admin-only) +│ ├── RemoteEndpoint.cs # Remote-Verbindungen: WebSocket, Assign/Unassign, PowerShell-Script │ ├── ServerSettingsEndpoint.cs # Admin: Server-Settings lesen/schreiben (Admin-only) │ ├── ProfileEndpoint.cs # Profil, Persona, Passwort │ ├── SettingsEndpoint.cs # User-Settings (JSON blob) │ ├── BackupEndpoint.cs # Backup als tar.gz Download -│ └── InfoEndpoint.cs # Build-Zeitstempel +│ └── InfoEndpoint.cs # Build-Zeitstempel + Embedding-Status │ ├── Middleware/ │ └── AuthMiddleware.cs # Cookie-basierte Token-Validierung @@ -195,7 +216,7 @@ gott/ │ │ ├── cards.js # Card-Builder (buildSessionCard, buildAgentCard) │ │ ├── msg-actions.js # Nachrichten-Aktionen (Delete, Mark, Share, Reply, Timestamp) │ │ ├── tool-ui.js # Tool-Boxes, Diffs, Todo-Liste, Visibility-Logik -│ │ ├── modals.js # Modale Dialoge (System-Prompt, Command-Output, Krea-Image) +│ │ ├── modals.js # Modale Dialoge (System-Prompt, Command-Output, Krea-Image, Remote) │ │ ├── stream-ui.js # Stream-Bubble, Review-Streaming │ │ ├── bubbles.js # Message Bubbles (User, Assistant, Error, Warning, Choice, Review) │ │ ├── chat.js # Chat-Logic, SSE-Streaming, Plan-Mode @@ -210,16 +231,17 @@ gott/ │ │ ├── emoji.js # Emoji-Picker │ │ ├── emoji.userdata.js # Häufig genutzte Emojis │ │ ├── tooltip.js # Tooltip-System -│ │ ├── title.js # Tab-Titel-Management +│ │ ├── title.js # Tab-Titel-Management + Embedding-Warnung │ │ ├── pages/ # Seiten-Komponenten -│ │ │ ├── chat-page.js # Haupt-Chat-Ansicht -│ │ │ ├── dashboard.js # Dashboard / Home +│ │ │ ├── chat-page.js # Haupt-Chat-Ansicht (inkl. Remote-Button) +│ │ │ ├── dashboard.js # Dashboard / Home (inkl. API-Keys-Link) │ │ │ ├── session-page.js # Session-Einstellungen │ │ │ ├── agent-page.js # Agent-Editor │ │ │ ├── telegram-bot-page.js # Telegram-Bot-Editor │ │ │ ├── cronjob-page.js # Cronjob-Verwaltung │ │ │ ├── profile-page.js # Profil-Bearbeitung │ │ │ ├── admin-page.js # Admin: Benutzerverwaltung +│ │ │ ├── api-keys-page.js # Admin: API-Key-Verwaltung + Heartbeat-Check │ │ │ ├── settings-page.js # App-Einstellungen │ │ │ ├── login-page.js # Login-Formular │ │ │ └── shared-page.js # Öffentliche geteilte Nachricht @@ -230,6 +252,7 @@ gott/ │ │ ├── easymde.min.js # Markdown Editor │ │ ├── katex.min.js # KaTeX Math-Rendering Engine │ │ └── cm-mode-*.min.js # CodeMirror Syntax-Modes +│ ├── remote.ps1 # PowerShell-Script für Remote-Client-Verbindung │ ├── fonts/ # Self-hosted Fonts (Inter, JetBrains Mono) │ └── lib/font-awesome/ # Font Awesome Icons │ @@ -258,6 +281,8 @@ gott/ │ ├── PROMPT.md │ └── output/ ├── images/ # Krea-generierte Bilder + ├── models/ # ONNX-Modelle (Embedding) + │ └── all-MiniLM-L6-v2/ # Sentence-Embedding-Modell (model.onnx + vocab.txt) ├── models_cache.json # Gecachte Provider-Modelle (Nvidia, Pollinations, Gemini) ├── logs/ # Payload-Logs (optional) └── backup/ # Backup-Archive @@ -304,6 +329,9 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id) | `cronjobs` | Geplante Tasks (cron expression, prompt, notify_session_id, is_enabled, last_run, next_run) | | `shared_messages` | Geteilte Nachrichten (Share-Token, Ablaufdatum) | | `telegram_bots` | Telegram Bots (bot_token, webhook_secret, name, is_public) – eigenständige Entität | +| `api_keys` | API-Keys pro Provider (key_id, provider, name, api_key, is_active) – nur ein Key pro Provider aktiv | +| `message_embeddings` | Sentence-Embeddings für semantische Suche (message_id, vector BLOB, session_id, agent_id) | +| `memory_file_embeddings` | Embeddings von Memory-Dateien (agent_id, file_path, chunk_index, vector BLOB) | ### Zentrale Records @@ -364,11 +392,13 @@ Die `Program.cs` ist der zentrale Einstiegspunkt und konfiguriert: Initialisierungsreihenfolge: ``` -AuthService → DatabaseService → EnsureDefaultUser +RemoteConnectionManager → AuthService → DatabaseService → EnsureDefaultUser → ImageService, StreamingTracker, MemoryService, SkillService +→ EmbeddingService → VectorSearchService → ToolService → ChatService -→ AnthropicService → OpenAiService (+ LoadModelsAsync) → TelegramService → KreaService -→ CronjobService (BackgroundService) +→ AnthropicService (Token aus DB: api_keys) → OpenAiService (+ LoadModelsAsync) +→ TelegramService → KreaService +→ CronjobService (BackgroundService) + EmbeddingBackgroundService (BackgroundService) ``` ### Services @@ -387,6 +417,11 @@ AuthService → DatabaseService → EnsureDefaultUser | `SkillService` | Singleton | Skill-Discovery und -Management aus Dateisystem | | `TelegramService` | Singleton | Telegram Bot API, Webhook-Handling, Nachrichten senden | | `CronjobService` | Background | Minütlicher Check auf fällige Cronjobs, Ausführung via Claude | +| `SseHelper` | Static | Shared SSE-Utilities: `WriteSseEvent`, `TryWriteSse`, `FinalizeChain`, `TruncateToolResult` | +| `EmbeddingService` | Singleton | Lokales Sentence-Embedding via all-MiniLM-L6-v2 (ONNX, 384 Dimensionen), WordPiece-Tokenisierung | +| `VectorSearchService` | Singleton | Semantische Suche über Message-Embeddings + Memory-File-Embeddings (Cosine Similarity) | +| `EmbeddingBackgroundService` | Background | Backfill bestehender Messages + Live-Embedding neuer Messages/Memory-Files | +| `RemoteConnectionManager` | Singleton | WebSocket-basierte Remote-Verbindungen, Command-Execution auf verbundenen Clients | | `KreaService` | Singleton | Krea API für Bildgenerierung, Job-Polling, Image-Download | | `ImageService` | Singleton | Bild-Resize + WebP-Konvertierung (512px + 256px Thumbnail) für Avatare und Session-Bilder | | `StreamingTracker` | Singleton | Trackt aktive Streams + CancellationTokenSource pro Session für Stop-Button | @@ -414,7 +449,9 @@ Alle Endpoints folgen dem **Minimal API**-Pattern mit Extension Methods (`MapXxx | **Backup** | `/api/backup` | `POST` (Admin only, tar.gz Download) | | **Shared** | `/api/shared/`, `/api/messages/{id}/share` | Share erstellen, öffentlich abrufen, auflisten, löschen | | **Models** | `/api/models/` | `GET nvidia`, `GET pollinations`, `GET gemini`, `POST refresh` | -| **Info** | `/api/info` | `GET` (Build-Zeitstempel) | +| **API Keys** | `/api/admin/api-keys/` | CRUD + `PUT activate/deactivate`, `POST check` (Heartbeat-Check für Anthropic-Keys) (Admin-only) | +| **Remote** | `/api/remote` | WebSocket-Endpoint für Remote-Clients, `GET connections`, `POST assign/unassign`, `GET /remote.ps1` | +| **Info** | `/api/info` | `GET` (Build-Zeitstempel + Embedding-Verfügbarkeit) | | **ServerSettings** | `/api/admin/server-settings` | `GET`, `PUT` (Admin-only, liest/schreibt `appsettings.Production.json`) | ### Middleware @@ -427,6 +464,7 @@ Ausnahmen (kein Auth nötig): - `GET /api/auth/autologin` (Token-basierter Autologin via Link) - `POST /api/telegram/webhook/{botId}` (authentifiziert via Secret-Header) - `GET /api/shared/{token}` (öffentlicher Zugriff auf geteilte Nachrichten) +- `/api/remote` (WebSocket-Endpoint für Remote-Clients) - `GET /api/images/file/*` (Krea-Bilder und Thumbnails, öffentlich) **API-Key Auth** (ohne User-Kontext): @@ -665,7 +703,8 @@ Der Agent hat Zugriff auf folgende Tools, abhängig von Konfiguration: | `edit_file` | Exakten Textabschnitt in Datei ersetzen | | `list_files` | Verzeichnis-Listing | | `delete_file` | Datei oder leeres Verzeichnis löschen | -| `execute` | Shell-Befehl ausführen (bash, 180s Timeout) | +| `execute` | Shell-Befehl ausführen (bash, konfigurierbares Timeout) | +| `background_execute` | Shell-Befehl als Hintergrund-Prozess starten (gibt PID zurück, wartet nicht auf Ende) | | `glob` | Dateien via Glob-Pattern finden | | `grep` | Dateien nach Regex/Text durchsuchen | | `todo_write` | Todo-Liste schreiben (als Datei im Workspace) | @@ -677,6 +716,19 @@ Der Agent hat Zugriff auf folgende Tools, abhängig von Konfiguration: | `memory_read` | Memory-Datei des Agents lesen | | `memory_write` | Memory-Datei schreiben (überschreiben/append) | | `memory_list` | Alle Memory-Dateien des Agents auflisten | +| `memory_search` | Semantische Suche über vergangene Chat-Nachrichten und Memory-Dateien (benötigt Embedding-Modell) | + +### Remote-Execution (wenn Remote-Verbindung zugeordnet) + +| Tool | Beschreibung | +|--------------------|-----------------------------------------------------| +| `remote_execute` | Shell-Befehl auf einem verbundenen Remote-Client ausführen (via WebSocket) | + +### Cross-Session Messaging (wenn konfiguriert) + +| Tool | Beschreibung | +|--------------------|-----------------------------------------------------| +| `send_to_session` | Nachricht an eine andere Session senden und dort eine Agent-Antwort auslösen | ### Interaktive Rückfragen (wenn AllowUserChoice aktiviert) @@ -692,7 +744,7 @@ Nutzt Anthropics eingebaute Web-Suche als Server-Side-Tool. ### Plan-Modus: Read-only Tools -Im Plan-Modus werden nur folgende Tools bereitgestellt: `read_file`, `list_files`, `glob`, `grep`, `execute`, `todo_write`, `memory_*`, `ask_user_choice`. Write-Tools (`write_file`, `edit_file`, `delete_file`) werden gefiltert. +Im Plan-Modus werden nur folgende Tools bereitgestellt: `read_file`, `list_files`, `glob`, `grep`, `execute`, `todo_write`, `memory_*`, `memory_search`, `remote_execute`, `ask_user_choice`. Write-Tools (`write_file`, `edit_file`, `delete_file`) werden gefiltert. ### Tool-Choice (automatisch) @@ -1607,6 +1659,99 @@ Alle History-Queries (initial load, before/after-Paging, Polling) sortieren nach --- +## Remote-Verbindungen + +Remote-Clients (z.B. Windows-PCs) können sich via **WebSocket** mit dem Server verbinden. Über die Verbindung kann der AI-Agent Shell-Befehle auf dem Remote-Client ausführen. + +### Architektur + +``` +┌──────────────┐ WebSocket ┌───────────────┐ Tool-Call ┌───────────────┐ +│ Remote-Client│◄────────────►│ Gott Server │◄───────────►│ AI-Agent │ +│ (PowerShell) │ (bidirekt.) │ RemoteEndpoint│ │ remote_execute│ +└──────────────┘ └───────────────┘ └───────────────┘ +``` + +### Verbindungsaufbau + +1. Remote-Client führt `irm https://server/remote | iex` aus → lädt `remote.ps1` +2. Script baut WebSocket-Verbindung zu `/api/remote` auf +3. Sendet `hello`-Message mit Hostname, Username, OS-Version +4. `RemoteConnectionManager` registriert die Verbindung + +### Session-Zuweisung + +- Im Chat: Remote-Button → Modal zeigt aktive Verbindungen +- Admin weist Verbindung einer Session zu (`POST /api/remote/assign`) +- AI-Agent bekommt `remote_execute`-Tool → führt Befehle auf dem Remote-Client aus +- Ergebnis wird synchron via WebSocket zurückgeliefert (Timeout: konfigurierbarer `ExecuteTimeout`) + +### Protokoll (JSON über WebSocket) + +| Richtung | Type | Payload | +|----------|------|---------| +| Client → Server | `hello` | `hostname`, `username`, `os` | +| Server → Client | `execute` | `command` | +| Client → Server | `result` | `exitCode`, `output`, `error` | +| Client → Server | `ping` | – | +| Server → Client | `pong` | – | + +--- + +## Semantische Suche (Embedding) + +Lokale Sentence-Embeddings ermöglichen semantische Suche über Chat-Nachrichten und Memory-Dateien. + +### Technologie + +- **Modell:** all-MiniLM-L6-v2 (ONNX Runtime, 384-dimensionale Embeddings) +- **Tokenisierung:** WordPiece (BERT-kompatibel, eigene Implementierung) +- **Speicherung:** SQLite-Tabellen `message_embeddings` + `memory_file_embeddings` (Vektor als BLOB) +- **Suche:** Brute-Force Cosine Similarity (ausreichend für <100k Embeddings) + +### Komponenten + +| Service | Aufgabe | +|---------|---------| +| `EmbeddingService` | ONNX-Modell laden, Embeddings generieren (thread-safe via SemaphoreSlim) | +| `VectorSearchService` | Schema-Management, Embedding-Storage, Similarity-Search | +| `EmbeddingBackgroundService` | Backfill beim Start + Live-Queue für neue Messages/Memory-Files | + +### Funktionsweise + +1. **Backfill beim Start:** Alle bestehenden Text-Messages (user + assistant, ≥10 Zeichen) werden embedded +2. **Memory-Backfill:** Alle `.md`-Dateien in Agent-Verzeichnissen werden in 500-Zeichen-Chunks embedded +3. **Live-Embedding:** Neue Messages/Memory-Writes werden via Queue sofort embedded +4. **Suche (`memory_search` Tool):** Query wird embedded, Cosine Similarity gegen alle Embeddings berechnet, Top-N Ergebnisse zurückgegeben + +### Konfiguration + +Das ONNX-Modell muss unter `data/models/all-MiniLM-L6-v2/` liegen (`model.onnx` + `vocab.txt`). Wenn nicht vorhanden, wird der Service deaktiviert und das Frontend zeigt eine Warnung. + +--- + +## API-Key-Verwaltung + +API-Keys werden in der Datenbank verwaltet (Tabelle `api_keys`) statt in der Konfigurationsdatei. + +### Funktionsweise + +- **Mehrere Keys** pro Provider (z.B. `anthropic`) können gespeichert werden +- **Nur ein Key pro Provider** kann gleichzeitig aktiv sein +- **Aktivierung** deaktiviert automatisch alle anderen Keys desselben Providers +- **Live-Aktualisierung:** Bei Aktivierung/Deaktivierung wird der Token sofort im laufenden Service aktualisiert (kein Neustart nötig) +- **Migration:** Bestehender `AnthropicToken` aus `appsettings.Production.json` wird beim ersten Start automatisch migriert + +### Heartbeat-Check (nur Anthropic) + +`POST /api/admin/api-keys/{id}/check` sendet eine minimale Anfrage an die Anthropic API und liest die Rate-Limit-Headers aus: +- **Gültig/Ungültig** – HTTP 200 vs. 401 +- **Subscription vs. API Console** – Anhand der `unified-*` Headers +- **Budget-Auslastung** – 5h und 7d Prozentwerte +- **Plan-Erkennung** – `Max` (overage > 0), `Pro` (kein fallback-Header), `Pro/Max` (sonst) + +--- + ## Deployment **WICHTIG:** Das Deployment ist ein **manueller Vorgang**, der durch den Benutzer auf dem Server durchgeführt werden muss. Das System führt kein automatisches Deployment durch. @@ -1640,8 +1785,10 @@ Die Anwendung ist als systemd-Service konfiguriert und läuft standardmäßig au ```json { "Port": 19000, // Server-Port (Default 19000, erfordert Neustart) - "AnthropicToken": "", // Anthropic API Key (REQUIRED) "MaxIterations": 100, // Max Tool-Loop Iterationen pro Chat + "MaxHistory": 120, // Max Messages in Chat-History + "ReminderInterval": 30, // System-Prompt-Reminder alle N Messages + "ExecuteTimeout": 180, // Timeout für Shell-Befehle in Sekunden "PayloadLogging": false, // API-Payloads loggen (Debug) "PayloadLoggingGzip": false, // Payload-Logs als .gz komprimieren "KreaToken": "", // Krea API Token (optional) @@ -1650,6 +1797,8 @@ Die Anwendung ist als systemd-Service konfiguriert und läuft standardmäßig au } ``` +> **Hinweis:** `AnthropicToken` wird nicht mehr über `appsettings.json` konfiguriert, sondern über die **API-Key-Verwaltung** im Admin-Bereich (DB-Tabelle `api_keys`). Bestehende Tokens werden beim ersten Start automatisch aus `appsettings.Production.json` migriert. + ### appsettings.Production.json Überschreibt Werte aus `appsettings.json` im Production-Environment. Wird **nicht** beim Publish mitkopiert (liegt nur im `Build//publish/`-Verzeichnis). Verwaltung über die **Admin-UI** (Servereinstellungen, `#/server-settings`). @@ -1668,7 +1817,8 @@ Die `DatabaseService.InitializeDatabase()` handhabt Schema-Erstellung und Migrat 1. **Tabellen-Erstellung** via `CREATE TABLE IF NOT EXISTS` 2. **Spalten-Migration** via `TryAddColumn()` – fängt "column already exists" ab 3. **Daten-Migration** (z.B. `auto_commit` von INTEGER zu TEXT, `use_web_search` zu `web_search_version`) -4. **Bild-Migration** – Konvertierung bestehender PNG/JPEG-Avatare zu WebP + Generierung von Thumbnails (`avatar_small`, `image_small`) +4. **Token-Migration** – Bestehender `AnthropicToken` aus `appsettings.Production.json` wird automatisch in `api_keys`-Tabelle migriert +5. **Bild-Migration** – Konvertierung bestehender PNG/JPEG-Avatare zu WebP + Generierung von Thumbnails (`avatar_small`, `image_small`) Dieses Pattern ermöglicht: - Idempotente Ausführung bei jedem App-Start diff --git a/ARCHITECTURE_QUICK.md b/ARCHITECTURE_QUICK.md index 7c953e8..3017c6e 100644 --- a/ARCHITECTURE_QUICK.md +++ b/ARCHITECTURE_QUICK.md @@ -6,7 +6,7 @@ ## Tech-Stack -**.NET 10** Minimal API · **SQLite** + Dapper · **Anthropic Claude API** (SSE Streaming) · **OpenAI-kompatible APIs** (Nvidia, Pollinations, Gemini) · **Vanilla JS** SPA · **KaTeX** (Math) · **Kestrel** auf Port 19000 +**.NET 10** Minimal API · **SQLite** + Dapper · **Anthropic Claude API** (SSE Streaming) · **OpenAI-kompatible APIs** (Nvidia, Pollinations, Gemini) · **ONNX Runtime** (Sentence-Embeddings) · **Vanilla JS** SPA · **KaTeX** (Math) · **Kestrel** auf Port 19000 --- @@ -16,8 +16,13 @@ gott/ ├── Program.cs # Startup, DI, Middleware, Routing ├── appsettings.json # Tokens, API-Keys, Limits -├── Models/ # Records (reine Daten) -├── Services/ # Business-Logic (alle Singletons + WorkspaceHelper + OpenAiService) +├── Models/ # Records (reine Daten, inkl. ApiKeyRecord) +├── Services/ # Business-Logic (Singletons + Static Helpers) +│ ├── DatabaseService*.cs # Partial Classes (Core, Sessions, Agents, Users, Integrations) +│ ├── SseHelper.cs # Shared SSE-Utilities +│ ├── EmbeddingService.cs # Lokales Sentence-Embedding (ONNX) +│ ├── VectorSearchService.cs # Semantische Suche +│ └── RemoteConnectionManager.cs # WebSocket Remote-Verbindungen ├── Endpoints/ # HTTP API (Minimal API MapXxx-Pattern) ├── Middleware/ # AuthMiddleware ├── Templates/ # Vorlagen für neue Agents (IDENTITY, SOUL, USER) @@ -38,7 +43,7 @@ gott/ │ ├── js/archive.js # Archiv-Loading │ ├── js/reply.js # Reply/Quote-Handling │ ├── js/files.js # File-Upload, Workspace-Refs -│ ├── js/pages/ # Seiten-Komponenten (inkl. telegram-bot-page.js) +│ ├── js/pages/ # Seiten-Komponenten (inkl. api-keys-page.js, telegram-bot-page.js) │ └── css/ # Styles (theme.css = CSS Variables) ├── WindowsApp/ # WPF + WebView2 Desktop Client └── Build/ # ⛔ NICHT editieren! @@ -80,6 +85,9 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id) | `agent_skills` | N:M Agent ↔ Skill | | `shared_messages` | Geteilte Nachrichten (öffentliche Links via Share-Token) | | `telegram_bots` | Telegram Bots (BotToken, WebhookSecret, Name, IsPublic) – eigenständige Entität, 1 Bot → N Sessions | +| `api_keys` | API-Keys pro Provider (key_id, provider, name, api_key, is_active) – nur ein Key pro Provider aktiv | +| `message_embeddings` | Sentence-Embeddings für semantische Suche (message_id, vector BLOB) | +| `memory_file_embeddings` | Embeddings von Memory-Dateien (agent_id, file_path, chunk_index, vector BLOB) | --- @@ -104,7 +112,9 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id) | Backup | `/api/backup` | POST → tar.gz download (Admin) | | Shared | `/api/shared/`, `/api/messages/{id}/share` | Share erstellen, öffentlich abrufen, löschen | | Models | `/api/models/` | `GET nvidia`, `GET pollinations`, `GET gemini`, `POST refresh` | -| Info | `/api/info` | GET → Build-Zeitstempel | +| API Keys | `/api/admin/api-keys/` | CRUD + activate/deactivate, `POST check` (Anthropic Heartbeat) (Admin-only) | +| Remote | `/api/remote` | WebSocket für Remote-Clients, `GET connections`, `POST assign/unassign` | +| Info | `/api/info` | GET → Build-Zeitstempel + Embedding-Verfügbarkeit | | ServerSettings | `/api/admin/server-settings` | `GET`, `PUT` (Admin-only, liest/schreibt `appsettings.Production.json`) | --- @@ -118,7 +128,8 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id) | **ChatService** | Chat-Kontext aufbauen, History aus DB laden | | **AnthropicService** | Claude API: SSE Streaming + Non-Streaming | | **OpenAiService** | OpenAI-kompatible Provider (Nvidia, Pollinations, Gemini) mit Tool-Calling, Streaming, Modell-Caching | -| **ToolService** | Tool-Definitionen + Ausführung (13 Tools) | +| **SseHelper** | Shared SSE-Utilities: WriteSseEvent, TryWriteSse, FinalizeChain, TruncateToolResult (static) | +| **ToolService** | Tool-Definitionen + Ausführung (16 Tools) | | **WorkspaceHelper** | Workspace-Pfad-Auflösung (relativ → absolut), Path-Traversal-Schutz (static) | | **SystemPromptBuilder** | System-Prompt aus modularen Blöcken zusammenbauen + Reminder-Generierung (static) | | **MemoryService** | Agent-Gedächtnis auf Dateisystem | @@ -127,6 +138,10 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id) | **CronjobService** | BackgroundService, minütlicher Cron-Check | | **KreaService** | Krea API Bildgenerierung + Job-Polling | | **ImageService** | Bild-Resize + WebP-Konvertierung (512px + 256px Thumbnail) | +| **EmbeddingService** | Lokales Sentence-Embedding via all-MiniLM-L6-v2 (ONNX, 384 Dimensionen) | +| **VectorSearchService** | Semantische Suche über Message-Embeddings + Memory-File-Embeddings | +| **EmbeddingBackgroundService** | Backfill bestehender Messages + Live-Embedding neuer Messages/Memory-Files | +| **RemoteConnectionManager** | WebSocket-basierte Remote-Verbindungen, Command-Execution auf Remote-Clients | | **StreamingTracker** | Tracking aktiver Streams + CancellationTokenSource pro Session für Stop | --- @@ -176,13 +191,17 @@ User-Input → POST /api/chat (SSE) | `edit_file` | Exakten Textabschnitt ersetzen | | `list_files` | Verzeichnisinhalt auflisten | | `delete_file` | Datei/leeres Verzeichnis löschen | -| `execute` | Shell-Befehl ausführen (bash, 180s Timeout) | +| `execute` | Shell-Befehl ausführen (bash, konfigurierbares Timeout) | +| `background_execute` | Shell-Befehl als Hintergrund-Prozess starten (gibt PID zurück) | | `glob` | Dateien per Glob-Pattern finden | | `grep` | Dateien per Regex/Text durchsuchen | | `todo_write` | Aufgabenliste schreiben (wird im Prompt angezeigt) | | `memory_read` | Memory-Datei lesen | | `memory_write` | Memory-Datei schreiben/appenden | | `memory_list` | Memory-Dateien auflisten | +| `memory_search` | Semantische Suche über vergangene Chat-Nachrichten und Memory-Dateien | +| `remote_execute` | Shell-Befehl auf verbundenem Remote-Client ausführen (via WebSocket) | +| `send_to_session` | Nachricht an eine andere Session senden und Agent-Antwort auslösen | | `ask_user_choice` | Interaktive Rückfrage mit 2-5 Auswahlmöglichkeiten (wenn AllowUserChoice aktiv) | **Sicherheit:** Alle Pfade werden auf das Workspace-Verzeichnis beschränkt (`..` wird blockiert). Tools sind pro Agent konfigurierbar (Feld `tools` als komma-separierte Liste). @@ -226,6 +245,7 @@ User-Input → POST /api/chat (SSE) | `#/profile` | `profile-page.js` | Profil | | `#/settings` | `settings-page.js` | Einstellungen | | `#/admin` | `admin-page.js` | Benutzerverwaltung (Admin) | +| `#/api-keys` | `api-keys-page.js` | API-Key-Verwaltung (Admin) | | `#/server-settings` | `server-settings-page.js` | Servereinstellungen (Admin) | | `#/login` | `login-page.js` | Login | | `#/shared/{token}` | `shared-page.js` | Geteilte Nachricht (öffentlich, kein Auth) | @@ -236,7 +256,7 @@ User-Input → POST /api/chat (SSE) 1. `POST /api/auth/login` → Token generiert, als `gott_token` HttpOnly Cookie gesetzt 2. Jeder `/api/`-Request → `AuthMiddleware` prüft Cookie → setzt `UserId` + `IsAdmin` -3. Ausnahmen: Login, Autologin, Telegram-Webhook (`/api/telegram/webhook/{botId}`), `GET /api/shared/{token}`, `GET /api/images/file/*` brauchen kein Auth +3. Ausnahmen: Login, Autologin, Telegram-Webhook (`/api/telegram/webhook/{botId}`), `GET /api/shared/{token}`, `GET /api/images/file/*`, `/api/remote` (WebSocket) brauchen kein Auth 4. **Krea API-Key**: `/api/images/`-Routen akzeptieren `X-Api-Key`-Header (Config: `KreaApiKey`) für Automation ohne User-Kontext 5. **Impersonate**: Admin kann via `POST /api/admin/users/{id}/impersonate` als anderer User einloggen 6. **Login-Link**: Admin generiert via `POST /api/admin/users/{id}/login-link` einen Autologin-Link (Token-basiert, `GET /api/auth/autologin?token=xxx`) @@ -249,15 +269,20 @@ User-Input → POST /api/chat (SSE) ```json { "Port": 19000, // Server-Port (erfordert Neustart) - "AnthropicToken": "", // Anthropic API Key (REQUIRED) "MaxIterations": 100, // Max Tool-Loop Iterationen pro Chat + "MaxHistory": 120, // Max Messages in Chat-History + "ReminderInterval": 30, // System-Prompt-Reminder alle N Messages + "ExecuteTimeout": 180, // Timeout für Shell-Befehle in Sekunden "PayloadLogging": false, // API-Payloads loggen (Debug) "PayloadLoggingGzip": false, // Payload-Logs als .gz komprimieren "KreaToken": "", // Krea API Token (optional) - "KreaApiKey": "" // API-Key für Krea-Endpoints ohne User-Auth (optional) + "KreaApiKey": "", // API-Key für Krea-Endpoints ohne User-Auth (optional) + "GeminiApiKey": "" // Google Gemini API Key (optional, kostenlos) } ``` +> **Hinweis:** `AnthropicToken` wird über die **API-Key-Verwaltung** im Admin-Bereich verwaltet (DB-Tabelle `api_keys`), nicht mehr über `appsettings.json`. + Verwaltung über **Admin-UI** (`#/server-settings`): Liest `appsettings.Production.json` (Fallback: `appsettings.json` → Default). Schreibt immer in `appsettings.Production.json`. --- diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 03324ac..b52a25b 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -148,7 +148,7 @@ public static class ChatEndpoint { // Preparer hat eine Rückfrage → Antwort als Text streamen, Flow beenden // Preparer-Antwort wird schon in RunAgentChat in DB gespeichert - await AnthropicService.WriteSseEvent(res, "done", new + await SseHelper.WriteSseEvent(res, "done", new { chainId = ctx.ChainId, stopReason = "preparer_query", diff --git a/README.md b/README.md index a936c07..ae8e758 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Self-hosted AI-Coding-Agent mit **Anthropic Claude API** als Haupt-Provider sowi - **Plan-Modus** – Read-only-Modus zum Analysieren und Planen ohne Änderungen (Toggle im Chat) - **Web-Search** – Integrierte Web-Suche mit Quellenangaben (URLs + Titel) in Chat und geteilten Nachrichten - **Multi-Provider** – Anthropic Claude (Haupt-Provider), Nvidia (g4f.space), Pollinations (kostenlos), Google Gemini (kostenloser Free Tier) -- **13 Tools** – `read_file`, `write_file`, `edit_file`, `list_files`, `delete_file`, `execute`, `glob`, `grep`, `todo_write`, `memory_read`, `memory_write`, `memory_list`, `ask_user_choice` +- **16 Tools** – `read_file`, `write_file`, `edit_file`, `list_files`, `delete_file`, `execute`, `background_execute`, `glob`, `grep`, `todo_write`, `memory_read`, `memory_write`, `memory_list`, `memory_search`, `remote_execute`, `send_to_session`, `ask_user_choice` - **Streaming** – Echtzeit-Antworten via Server-Sent Events (SSE) - **Stop-Button** – Laufende Requests zuverlässig abbrechen (inkl. laufender Shell-Befehle) - **Skill-System** – Wiederverwendbare Prompt-Bausteine, zuordenbar pro Agent @@ -25,6 +25,9 @@ Self-hosted AI-Coding-Agent mit **Anthropic Claude API** als Haupt-Provider sowi - **Telegram-Integration** – Bots als eigenständige Entität, mehrere Sessions pro Bot, Public-Bots - **Cronjobs** – Zeitgesteuerte Prompts mit Cron-Expressions - **Bildgenerierung** – Krea AI Integration für Bildgenerierung +- **Semantische Suche** – Lokale Sentence-Embeddings (all-MiniLM-L6-v2 ONNX) für `memory_search` über vergangene Nachrichten und Memory-Dateien +- **Remote-Verbindungen** – WebSocket-basierte Remote-Clients (PowerShell), AI-Agent kann Befehle auf entfernten Rechnern ausführen +- **API-Key-Verwaltung** – Admin-UI zum Verwalten von API-Keys (Anthropic) inkl. Heartbeat-Check, DB-basiert statt Config-Datei - **Nachrichten teilen** – Einzelne Nachrichten per öffentlichem Link teilen - **Archivierung** – Chat-Verläufe archivieren und durchsuchen - **OpenClaw Import** – Chatverlauf aus OpenClaw-Instanzen importieren @@ -40,6 +43,7 @@ Self-hosted AI-Coding-Agent mit **Anthropic Claude API** als Haupt-Provider sowi | **Backend** | .NET 10 Minimal API, C# | | **Datenbank** | SQLite + Dapper | | **AI** | Anthropic Claude API + OpenAI-kompatible APIs (Nvidia, Pollinations, Gemini) | +| **Embeddings** | Microsoft.ML.OnnxRuntime + all-MiniLM-L6-v2 (Sentence-Embeddings) | | **Frontend** | Vanilla JS SPA (kein Framework), CSS Variables | | **Mathe** | KaTeX (LaTeX-Rendering) | | **Bilder** | SixLabors.ImageSharp | @@ -51,8 +55,9 @@ Self-hosted AI-Coding-Agent mit **Anthropic Claude API** als Haupt-Provider sowi ## Voraussetzungen - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) -- Anthropic API Key ([console.anthropic.com](https://console.anthropic.com/)) +- Anthropic API Key ([console.anthropic.com](https://console.anthropic.com/)) – wird über Admin-UI verwaltet - Optional: Gemini API Key ([aistudio.google.com](https://aistudio.google.com/apikey)) – kostenlos, kein Billing nötig +- Optional: ONNX-Modell `all-MiniLM-L6-v2` für semantische Suche (`data/models/all-MiniLM-L6-v2/`) - Linux/macOS/Windows (Produktiv: Linux empfohlen) ## Konfiguration @@ -62,8 +67,10 @@ Beim ersten Start wird `appsettings.json` mit Standardwerten verwendet. Für eig ```json { "Port": 19000, - "AnthropicToken": "sk-ant-...", "MaxIterations": 100, + "MaxHistory": 120, + "ReminderInterval": 30, + "ExecuteTimeout": 180, "PayloadLogging": false, "PayloadLoggingGzip": false, "KreaToken": "", @@ -75,14 +82,18 @@ Beim ersten Start wird `appsettings.json` mit Standardwerten verwendet. Für eig | Setting | Beschreibung | Default | |---------|-------------|---------| | `Port` | Server-Port (erfordert Neustart) | `19000` | -| `AnthropicToken` | Anthropic API Key (**erforderlich**) | – | | `MaxIterations` | Max Tool-Loop Iterationen pro Chat | `100` | +| `MaxHistory` | Max Messages in Chat-History | `120` | +| `ReminderInterval` | System-Prompt-Reminder alle N Messages | `30` | +| `ExecuteTimeout` | Timeout für Shell-Befehle in Sekunden | `180` | | `PayloadLogging` | API-Payloads in Konsole loggen | `false` | | `PayloadLoggingGzip` | Payload-Logs als .gz komprimieren | `false` | | `KreaToken` | Krea AI API Token (optional) | – | | `KreaApiKey` | API-Key für Krea-Endpoints ohne User-Auth (optional) | – | | `GeminiApiKey` | Google Gemini API Key (kostenlos via [aistudio.google.com](https://aistudio.google.com/apikey)) | – | +> **Hinweis:** `AnthropicToken` wird über die **API-Key-Verwaltung** im Admin-Bereich verwaltet (DB-Tabelle `api_keys`), nicht mehr über `appsettings.json`. Bestehende Tokens werden beim ersten Start automatisch migriert. + > **Hinweis:** Die `appsettings.Production.json` wird **nicht** beim `dotnet publish` mitkopiert. Sie lebt ausschließlich im `Build/Linux/publish/`-Verzeichnis und kann über die Admin-UI (`#/server-settings`) verwaltet werden. ## Starten diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 7ff739d..e33fc87 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -190,7 +190,7 @@ public class AnthropicService trimCount = TrimMessagesCleanly(messages, trimCount); if (trimCount > 0) { - await TryWriteSse(response, "info", new { message = $"History zu lang – {trimCount} ältere Messages entfernt, Retry ({promptTrimRetries}/5)..." }, sseCt); + await SseHelper.TryWriteSse(response, "info", new { message = $"History zu lang – {trimCount} ältere Messages entfernt, Retry ({promptTrimRetries}/5)..." }, sseCt); iterations--; // Iteration nicht zählen, war ja kein echter Versuch continue; // → nächste while-Iteration mit kürzerer History } @@ -202,9 +202,9 @@ public class AnthropicService Role = "system", Type = "error", Content = errMsg, IsError = true }); - await TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); await FinalizeChain(chain, usage, iterations, "error", sw); - await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); return; } @@ -323,7 +323,7 @@ public class AnthropicService var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason; await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo); - await TryWriteSse(response, "done", new + await SseHelper.TryWriteSse(response, "done", new { model = modelId, inputTokens = usage.InputTokens, @@ -359,8 +359,8 @@ public class AnthropicService } await FinalizeChain(chain, usage, iterations, "max_iterations", sw); - await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); - await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); } catch (OperationCanceledException) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested) { @@ -368,7 +368,7 @@ public class AnthropicService await FinalizeChain(chain, usage, iterations, "stopped", sw); // sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None // Der SSE-Stream zum Client ist noch offen – nur der Stop-Button hat ausgelöst - await TryWriteSse(response, "done", new + await SseHelper.TryWriteSse(response, "done", new { model = modelId, inputTokens = usage.InputTokens, @@ -389,7 +389,7 @@ public class AnthropicService // Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop. await FinalizeChain(chain, usage, iterations, "stopped", sw); // sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None - await TryWriteSse(response, "done", new + await SseHelper.TryWriteSse(response, "done", new { model = modelId, inputTokens = usage.InputTokens, @@ -421,8 +421,8 @@ public class AnthropicService Content = errMsg, IsError = true }); await FinalizeChain(chain, usage, iterations, "error", sw); - await TryWriteSse(response, "error", new { error = errMsg }, sseCt); - await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt); } } @@ -455,7 +455,7 @@ public class AnthropicService // da der Worker noch nicht gestartet hat). cancellationToken.ThrowIfCancellationRequested(); - await TryWriteSse(response, "preparer_start", new + await SseHelper.TryWriteSse(response, "preparer_start", new { agentId = preparer.AgentId, agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent" @@ -484,7 +484,7 @@ public class AnthropicService result.ShouldForward = false; result.ResponseText = questionText; - await TryWriteSse(response, "preparer_done", new + await SseHelper.TryWriteSse(response, "preparer_done", new { agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent", forwarded = false, @@ -504,7 +504,7 @@ public class AnthropicService result.ShouldForward = true; result.ForwardedPrompt = forwardedText; - await TryWriteSse(response, "preparer_done", new + await SseHelper.TryWriteSse(response, "preparer_done", new { agentName = preparer.Agent?.Name ?? "Vorbereiter-Agent", forwarded = true @@ -545,7 +545,7 @@ public class AnthropicService workCt.ThrowIfCancellationRequested(); // SSE: Review-Phase Start - await TryWriteSse(response, "review_start", new + await SseHelper.TryWriteSse(response, "review_start", new { round = reviewRound + 1, maxRounds = review.MaxReviewIterations, @@ -576,7 +576,7 @@ public class AnthropicService result.HasFeedback = true; result.Feedback = $"[Review-Feedback von {review.Agent?.Name ?? "Review-Agent"} (Runde {reviewRound + 1}/{review.MaxReviewIterations})]:\n{feedbackText}"; - await TryWriteSse(response, "review_feedback", new + await SseHelper.TryWriteSse(response, "review_feedback", new { round = reviewRound + 1, maxRounds = review.MaxReviewIterations, @@ -587,7 +587,7 @@ public class AnthropicService else { // Default: Approved (kein [FEEDBACK]-Tag → alles OK) - await TryWriteSse(response, "review_approved", new + await SseHelper.TryWriteSse(response, "review_approved", new { round = reviewRound + 1, agentName = review.Agent?.Name ?? "Review-Agent" @@ -616,7 +616,7 @@ public class AnthropicService CancellationToken workCt, CancellationToken sseCt) { - await TryWriteSse(response, "finalizer_start", new + await SseHelper.TryWriteSse(response, "finalizer_start", new { agentId = finalizer.AgentId, agentName = finalizer.Agent?.Name ?? "Finalizer-Agent" @@ -632,7 +632,7 @@ public class AnthropicService baseIterations, "finalizer", finalizer.Agent?.Name ?? "Finalizer-Agent", workCt, sseCt); - await TryWriteSse(response, "finalizer_done", new + await SseHelper.TryWriteSse(response, "finalizer_done", new { agentName = finalizer.Agent?.Name ?? "Finalizer-Agent" }, sseCt); @@ -828,7 +828,7 @@ public class AnthropicService trimCount = TrimMessagesCleanly(messages, trimCount); if (trimCount > 0) { - await TryWriteSse(response, "info", new { message = $"{tag}: History zu lang – {trimCount} Messages entfernt, Retry..." }, sseCt); + await SseHelper.TryWriteSse(response, "info", new { message = $"{tag}: History zu lang – {trimCount} Messages entfernt, Retry..." }, sseCt); iterations--; continue; } @@ -857,7 +857,7 @@ public class AnthropicService assistantContent.Add(new { type = "text", text }); finalText.Append(text); - await TryWriteSse(response, $"{agentType}_text", new + await SseHelper.TryWriteSse(response, $"{agentType}_text", new { text, agentName, @@ -882,7 +882,7 @@ public class AnthropicService assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput }); - await TryWriteSse(response, $"{agentType}_tool_use", new + await SseHelper.TryWriteSse(response, $"{agentType}_tool_use", new { toolName, toolId, @@ -916,7 +916,7 @@ public class AnthropicService is_error = isError }); - await TryWriteSse(response, $"{agentType}_tool_end", new + await SseHelper.TryWriteSse(response, $"{agentType}_tool_end", new { toolName, toolId, @@ -1207,18 +1207,8 @@ public class AnthropicService catch { /* Logging darf nie die App crashen */ } } - private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null) - { - chain.InputTokens = usage.InputTokens; - chain.OutputTokens = usage.OutputTokens; - chain.CacheCreationTokens = usage.CacheCreationTokens; - chain.CacheReadTokens = usage.CacheReadTokens; - chain.Iterations = iterations; - chain.StopReason = stopReason; - chain.StreamErrorInfo = streamErrorInfo; - chain.DurationMs = sw.ElapsedMilliseconds; - await _db.UpdateChain(chain); - } + private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null) + => SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo); private async Task<(string StopReason, List Content, List Tools, string StreamErrorInfo)> ProcessStream( HttpResponse response, @@ -1287,7 +1277,7 @@ public class AnthropicService if (cbType == "text") { currentTextBlock.Clear(); - await TryWriteSse(response, "text_start", new { index = idx }, sseCt); + await SseHelper.TryWriteSse(response, "text_start", new { index = idx }, sseCt); } else if (cbType == "tool_use") { @@ -1299,7 +1289,7 @@ public class AnthropicService currentToolId = cb.GetProperty("id").GetString() ?? ""; currentToolName = cb.GetProperty("name").GetString() ?? ""; currentToolInputJson.Clear(); - await TryWriteSse(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt); + await SseHelper.TryWriteSse(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt); } else if (cbType == "server_tool_use") { @@ -1311,7 +1301,7 @@ public class AnthropicService currentToolId = cb.GetProperty("id").GetString() ?? ""; currentToolName = cb.GetProperty("name").GetString() ?? ""; currentToolInputJson.Clear(); - await TryWriteSse(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt); + await SseHelper.TryWriteSse(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt); } else if (cbType == "web_search_tool_result") { @@ -1336,7 +1326,7 @@ public class AnthropicService { var text = delta.GetProperty("text").GetString() ?? ""; currentTextBlock.Append(text); - await TryWriteSse(response, "text_delta", new { text }, sseCt); + await SseHelper.TryWriteSse(response, "text_delta", new { text }, sseCt); } else if (deltaType == "input_json_delta") { @@ -1357,7 +1347,7 @@ public class AnthropicService if (text.Length > 0) { assistantContent.Add(new { type = "text", text }); - await TryWriteSse(response, "text_end", new { }, sseCt); + await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt); } currentTextBlock.Clear(); } @@ -1394,7 +1384,7 @@ public class AnthropicService } } - await TryWriteSse(response, "user_choice", new + await SseHelper.TryWriteSse(response, "user_choice", new { toolCallId = currentToolId, question, @@ -1419,7 +1409,7 @@ public class AnthropicService workCt.ThrowIfCancellationRequested(); executedTools.Add(new ToolResult(currentToolId, toolResult, isError)); - await TryWriteSse(response, "tool_end", new + await SseHelper.TryWriteSse(response, "tool_end", new { name = currentToolName, input = currentToolInputJson.ToString(), @@ -1447,7 +1437,7 @@ public class AnthropicService // Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen assistantContent.Add(webSearchResultBlocks[idx]); var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List(); - await TryWriteSse(response, "web_search_end", new { id = currentToolId, sources }, sseCt); + await SseHelper.TryWriteSse(response, "web_search_end", new { id = currentToolId, sources }, sseCt); } else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock)) { @@ -1526,14 +1516,8 @@ public class AnthropicService // ── Shared Helpers ── - internal const int MaxToolResultChars = 20_000; - - internal static string TruncateToolResult(string result) - { - return result.Length > MaxToolResultChars - ? result[..MaxToolResultChars] + $"\n\n[... gekürzt, {result.Length - MaxToolResultChars} weitere Zeichen ...]" - : result; - } + // TruncateToolResult → SseHelper.cs + internal static string TruncateToolResult(string result) => SseHelper.TruncateToolResult(result); /// /// Extrahiert den Text der letzten User-Message aus der Message-Liste. @@ -1697,34 +1681,5 @@ public class AnthropicService return client; } - public static async Task WriteSseEvent(HttpResponse response, string eventType, object data, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - var json = JsonSerializer.Serialize(data); - await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n", ct); - await response.Body.FlushAsync(ct); - } - - private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct) - { - try - { - if (!ct.IsCancellationRequested) - await WriteSseEvent(response, eventType, data, ct); - } - catch - { - // Client bereits disconnected – ignorieren - } - } -} - -public record ToolResult(string Id, string Result, bool IsError); - -public class TokenUsage -{ - public long InputTokens { get; set; } - public long OutputTokens { get; set; } - public long CacheCreationTokens { get; set; } - public long CacheReadTokens { get; set; } + // WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs } diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 8c551a7..ad703c4 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -465,7 +465,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? dict.TryGetValue("role", out var role) && role.ToString() == "user" && dict.TryGetValue("content", out var existingContent) && existingContent is List contentList) { - contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = AnthropicService.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }); + contentList.Add(new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError }); } else { @@ -474,7 +474,7 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop? ["role"] = "user", ["content"] = new List { - new { type = "tool_result", tool_use_id = msg.ToolCallId, content = AnthropicService.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError } + new { type = "tool_result", tool_use_id = msg.ToolCallId, content = SseHelper.TruncateToolResult(msg.Content ?? ""), is_error = msg.IsError } } }); } diff --git a/Services/DatabaseService.Agents.cs b/Services/DatabaseService.Agents.cs new file mode 100644 index 0000000..5fd151b --- /dev/null +++ b/Services/DatabaseService.Agents.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Gott.Models; + +namespace Gott.Services; + +public partial class DatabaseService +{ + // ── Agents ── + + public async Task CreateAgent(AgentRecord agent) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + agent.AgentId = Guid.NewGuid().ToString("N"); + agent.CreatedAt = now; + agent.UpdatedAt = now; + await conn.ExecuteAsync(@" + INSERT INTO agents (agent_id, user_id, name, avatar, avatar_small, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_auto_context, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at) + VALUES (@AgentId, @UserId, @Name, @Avatar, @AvatarSmall, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseAutoContext, @UseWebSearch, @WebSearchVersion, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) + ", agent); + return agent; + } + + public async Task> GetAgents(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM agents WHERE user_id = @userId AND is_deleted = 0 ORDER BY name", new { userId }); + } + + public async Task> GetAvailableAgents(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM agents WHERE (user_id = @userId OR is_public = 1) AND is_deleted = 0 ORDER BY name", new { userId }); + } + + public async Task GetAgent(string agentId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM agents WHERE agent_id = @agentId AND is_deleted = 0", new { agentId }); + } + + public async Task UpdateAgent(AgentRecord agent) + { + using var conn = CreateConnection(); + agent.UpdatedAt = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + UPDATE agents SET name = @Name, avatar = @Avatar, avatar_small = @AvatarSmall, description = @Description, + model = @Model, tool_choice = @ToolChoice, + custom_commands = @CustomCommands, auto_commit = @AutoCommit, + use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_auto_context = @UseAutoContext, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion, + allow_user_choice = @AllowUserChoice, task_instruction = @TaskInstruction, + avatar_scale = @AvatarScale, avatar_offset_x = @AvatarOffsetX, avatar_offset_y = @AvatarOffsetY, + is_public = @IsPublic, updated_at = @UpdatedAt + WHERE agent_id = @AgentId AND user_id = @UserId + ", agent); + } + + public async Task DeleteAgent(string agentId, string userId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE agents SET is_deleted = 1, updated_at = @now WHERE agent_id = @agentId AND user_id = @userId", + new { agentId, userId, now = DateTime.UtcNow.ToString("o") }); + } + + + // ── Agent Skills ── + + public async Task> GetAgentSkills(string agentId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug", + new { agentId }); + } + + public async Task SetAgentSkills(string agentId, IEnumerable skillSlugs) + { + using var conn = CreateConnection(); + using var tx = conn.BeginTransaction(); + + await conn.ExecuteAsync("DELETE FROM agent_skills WHERE agent_id = @agentId", new { agentId }, tx); + + var now = DateTime.UtcNow.ToString("o"); + foreach (var slug in skillSlugs) + { + await conn.ExecuteAsync(@" + INSERT INTO agent_skills (agent_id, skill_slug, created_at) + VALUES (@agentId, @slug, @now)", + new { agentId, slug, now }, tx); + } + + tx.Commit(); + } + + public async Task AddAgentSkill(string agentId, string skillSlug) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + INSERT OR IGNORE INTO agent_skills (agent_id, skill_slug, created_at) + VALUES (@agentId, @skillSlug, @now)", + new { agentId, skillSlug, now }); + } + + public async Task RemoveAgentSkill(string agentId, string skillSlug) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug", + new { agentId, skillSlug }); + } + +} diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs new file mode 100644 index 0000000..9980a4c --- /dev/null +++ b/Services/DatabaseService.Integrations.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Gott.Models; +using Microsoft.Data.Sqlite; + +namespace Gott.Services; + +public partial class DatabaseService +{ + // ── OpenClaw Import ── + + public async Task CreateChainForImport(string chainId, string sessionId, string agentId, string model, int inputTokens, int outputTokens, int cacheCreationTokens, int cacheReadTokens, string createdAt) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO chains (chain_id, session_id, agent_id, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at) + VALUES (@chainId, @sessionId, @agentId, @model, @inputTokens, @outputTokens, @cacheCreationTokens, @cacheReadTokens, 0, 'end_turn', 0, 0, @createdAt) + ", new { chainId, sessionId, agentId, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, createdAt }); + } + + public async Task CreateMessageForImport(string chainId, string role, string type, string content, string createdAt, string tag) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO messages (chain_id, role, type, content, is_error, iteration, attachment_count, tag, created_at) + VALUES (@chainId, @role, @type, @content, 0, 0, 0, @tag, @createdAt) + ", new { chainId, role, type, content, tag, createdAt }); + } + + public async Task DeleteOpenClawImport(string sessionId) + { + using var conn = CreateConnection(); + using var tx = conn.BeginTransaction(); + + // Get all chain_ids that belong to this session and have imported messages + var importedChainIds = (await conn.QueryAsync(@" + SELECT DISTINCT c.chain_id + FROM chains c + INNER JOIN messages m ON m.chain_id = c.chain_id + WHERE c.session_id = @sessionId AND m.tag = 'openclaw-import' + ", new { sessionId }, tx)).ToList(); + + if (importedChainIds.Count > 0) + { + // Delete messages for these chains + await conn.ExecuteAsync( + "DELETE FROM messages WHERE chain_id IN @chainIds", + new { chainIds = importedChainIds }, tx); + + // Delete the chains themselves + await conn.ExecuteAsync( + "DELETE FROM chains WHERE chain_id IN @chainIds", + new { chainIds = importedChainIds }, tx); + } + + tx.Commit(); + } + + + // ── Telegram Bots ── + + private void MigrateTelegramBotsFromSessions(SqliteConnection conn) + { + // Prüfe ob es Sessions mit bot_token gibt, die noch kein telegram_bot_id haben + var sessions = conn.Query( + "SELECT session_id, user_id, name, telegram_bot_token, telegram_webhook_secret FROM sessions WHERE telegram_bot_token IS NOT NULL AND telegram_bot_token != '' AND (telegram_bot_id IS NULL OR telegram_bot_id = '')").ToList(); + + if (sessions.Count == 0) return; + + var now = DateTime.UtcNow.ToString("o"); + foreach (var s in sessions) + { + string token = s.telegram_bot_token; + string userId = s.user_id ?? ""; + string sessionName = s.name ?? "Unbenannt"; + string secret = s.telegram_webhook_secret ?? Guid.NewGuid().ToString("N"); + + // Prüfe ob für diesen Token bereits ein Bot existiert + var existing = conn.QuerySingleOrDefault( + "SELECT bot_id FROM telegram_bots WHERE bot_token = @token", new { token }); + + string botId; + if (existing != null) + { + botId = existing.bot_id; + } + else + { + botId = Guid.NewGuid().ToString(); + conn.Execute(@" + INSERT INTO telegram_bots (bot_id, user_id, name, bot_token, webhook_secret, created_at, updated_at) + VALUES (@botId, @userId, @name, @token, @secret, @now, @now)", + new { botId, userId, name = $"Bot von {sessionName}", token, secret, now }); + } + + conn.Execute( + "UPDATE sessions SET telegram_bot_id = @botId, updated_at = @now WHERE session_id = @sessionId", + new { botId, sessionId = (string)s.session_id, now }); + } + } + + public async Task> GetTelegramBots(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM telegram_bots WHERE user_id = @userId ORDER BY created_at DESC", + new { userId }); + } + + public async Task> GetAvailableTelegramBots(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM telegram_bots WHERE user_id = @userId OR is_public = 1 ORDER BY created_at DESC", + new { userId }); + } + + public async Task GetTelegramBot(string botId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM telegram_bots WHERE bot_id = @botId", + new { botId }); + } + + public async Task CreateTelegramBot(string userId, string name, string botToken) + { + using var conn = CreateConnection(); + var botId = Guid.NewGuid().ToString(); + var secret = Guid.NewGuid().ToString("N"); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + INSERT INTO telegram_bots (bot_id, user_id, name, bot_token, webhook_secret, created_at, updated_at) + VALUES (@botId, @userId, @name, @botToken, @secret, @now, @now)", + new { botId, userId, name, botToken, secret, now }); + return await GetTelegramBot(botId); + } + + public async Task UpdateTelegramBot(string botId, string name, string botToken, bool isPublic) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + UPDATE telegram_bots SET name = @name, bot_token = @botToken, is_public = @isPublic, updated_at = @now + WHERE bot_id = @botId", + new { botId, name, botToken, isPublic, now }); + } + + public async Task DeleteTelegramBot(string botId) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + // Erst alle Session-Verknüpfungen entfernen + await conn.ExecuteAsync( + "UPDATE sessions SET telegram_bot_id = NULL, updated_at = @now WHERE telegram_bot_id = @botId", + new { botId, now }); + await conn.ExecuteAsync("DELETE FROM telegram_bots WHERE bot_id = @botId", new { botId }); + } + + public async Task GetTelegramBotSessionCount(string botId) + { + using var conn = CreateConnection(); + return await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0", + new { botId }); + } + + public async Task SetSessionTelegramBot(string sessionId, string botId) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync( + "UPDATE sessions SET telegram_bot_id = @botId, updated_at = @now WHERE session_id = @sessionId", + new { sessionId, botId = (object)botId ?? DBNull.Value, now }); + } + + public async Task UpdateSessionTelegramChat(string sessionId, string chatId) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + UPDATE sessions SET telegram_chat_id = @chatId, updated_at = @now + WHERE session_id = @sessionId", + new { sessionId, chatId, now }); + } + + public async Task> GetSessionsByTelegramBot(string botId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0", + new { botId }); + } + + public async Task FindSessionForTelegramChat(string botId, long chatId) + { + using var conn = CreateConnection(); + var chatIdStr = chatId.ToString(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM sessions WHERE telegram_bot_id = @botId AND telegram_chat_id = @chatIdStr AND is_deleted = 0", + new { botId, chatIdStr }); + } + + public async Task> FindUnlinkedSessionsForBot(string botId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM sessions WHERE telegram_bot_id = @botId AND (telegram_chat_id IS NULL OR telegram_chat_id = '') AND is_deleted = 0", + new { botId }); + } + + // ── Cronjobs ── + + public async Task CreateCronjob(CronjobRecord cronjob) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + cronjob.CronjobId = Guid.NewGuid().ToString("N"); + cronjob.CreatedAt = now; + cronjob.UpdatedAt = now; + await conn.ExecuteAsync(@" + INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at) + VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt) + ", cronjob); + return cronjob; + } + + public async Task> GetCronjobs(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync(@" + SELECT c.*, s.name AS session_name, ns.name AS notify_session_name + FROM cronjobs c + LEFT JOIN sessions s ON c.session_id = s.session_id + LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id + WHERE c.user_id = @userId + ORDER BY c.name", + new { userId }); + } + + public async Task GetCronjob(string cronjobId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync(@" + SELECT c.*, s.name AS session_name, ns.name AS notify_session_name + FROM cronjobs c + LEFT JOIN sessions s ON c.session_id = s.session_id + LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id + WHERE c.cronjob_id = @cronjobId", + new { cronjobId }); + } + + public async Task UpdateCronjob(CronjobRecord cronjob) + { + using var conn = CreateConnection(); + cronjob.UpdatedAt = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression, + prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt + WHERE cronjob_id = @CronjobId AND user_id = @UserId", + cronjob); + } + + public async Task DeleteCronjob(string cronjobId, string userId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId", + new { cronjobId, userId }); + } + + public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + UPDATE cronjobs SET last_run_at = @now, last_run_status = @status, + last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now + WHERE cronjob_id = @cronjobId", + new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task> GetDueCronjobs() + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + return await conn.QueryAsync(@" + SELECT c.*, s.name AS session_name, ns.name AS notify_session_name + FROM cronjobs c + LEFT JOIN sessions s ON c.session_id = s.session_id + LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id + WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now", + new { now }); + } + + // ── API Keys ── + + private void MigrateAnthropicTokenFromConfig(SqliteConnection conn) + { + // Nur migrieren wenn noch keine Anthropic-Keys existieren + var count = conn.ExecuteScalar("SELECT COUNT(*) FROM api_keys WHERE provider = 'anthropic'"); + if (count > 0) return; + + // Token aus appsettings.Production.json lesen + var configPath = Path.Combine(Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")) ?? "", "..", "appsettings.Production.json"); + // Normaler Pfad: neben der DB-Datei im übergeordneten Ordner + var basePath = Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")); + if (basePath != null) + { + var prodPath = Path.Combine(basePath, "..", "appsettings.Production.json"); + if (File.Exists(prodPath)) + { + try + { + var json = File.ReadAllText(prodPath); + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("AnthropicToken", out var tokenEl)) + { + var token = tokenEl.GetString(); + if (!string.IsNullOrWhiteSpace(token)) + { + var now = DateTime.UtcNow.ToString("o"); + conn.Execute(@" + INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) + VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)", + new { keyId = Guid.NewGuid().ToString("N"), token, now }); + Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert"); + } + } + } + catch { /* Config nicht lesbar – ignorieren */ } + } + } + } + + public async Task> GetApiKeys() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM api_keys ORDER BY provider, name"); + } + + public async Task> GetApiKeysByProvider(string provider) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM api_keys WHERE provider = @provider ORDER BY name", + new { provider }); + } + + public async Task GetActiveApiKey(string provider) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1", + new { provider }); + } + + public async Task GetApiKey(string keyId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM api_keys WHERE key_id = @keyId", + new { keyId }); + } + + public async Task CreateApiKey(ApiKeyRecord record) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) + VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @CreatedAt, @UpdatedAt)", + record); + } + + public async Task UpdateApiKey(string keyId, string name, string apiKey = null) + { + using var conn = CreateConnection(); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + await conn.ExecuteAsync( + "UPDATE api_keys SET name = @name, api_key = @apiKey, updated_at = @now WHERE key_id = @keyId", + new { keyId, name, apiKey, now = DateTime.UtcNow.ToString("o") }); + } + else + { + await conn.ExecuteAsync( + "UPDATE api_keys SET name = @name, updated_at = @now WHERE key_id = @keyId", + new { keyId, name, now = DateTime.UtcNow.ToString("o") }); + } + } + + public async Task SetActiveApiKey(string keyId, string provider) + { + using var conn = CreateConnection(); + // Erst alle deaktivieren + await conn.ExecuteAsync( + "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider", + new { provider, now = DateTime.UtcNow.ToString("o") }); + // Dann den gewählten aktivieren + await conn.ExecuteAsync( + "UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId", + new { keyId, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task DeactivateApiKey(string keyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId", + new { keyId, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task DeleteApiKey(string keyId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId }); + } +} diff --git a/Services/DatabaseService.Sessions.cs b/Services/DatabaseService.Sessions.cs new file mode 100644 index 0000000..f480548 --- /dev/null +++ b/Services/DatabaseService.Sessions.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Dapper; +using Gott.Models; + +namespace Gott.Services; + +public partial class DatabaseService +{ + // ── Sessions ── + + public async Task CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + var session = new SessionRecord + { + SessionId = Guid.NewGuid().ToString("N"), + UserId = userId, + Name = name, + Description = description, + Image = image, + ImageSmall = imageSmall, + AgentId = agentId, + Workspace = workspace, + Context = context, + CustomCommands = customCommands, + AvatarScale = avatarScale, + AvatarOffsetX = avatarOffsetX, + AvatarOffsetY = avatarOffsetY, + ReviewAgentId = reviewAgentId, + MaxReviewIterations = maxReviewIterations, + FinalizerAgentId = finalizerAgentId, + PreparerAgentId = preparerAgentId, + CreatedAt = now, + UpdatedAt = now + }; + await conn.ExecuteAsync(@" + INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, created_at, updated_at) + VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @CreatedAt, @UpdatedAt) + ", session); + return session; + } + + public async Task GetSessionByName(string name) + { + using var conn = CreateConnection(); + var sessions = await conn.QueryAsync( + "SELECT * FROM sessions WHERE is_deleted = 0 AND LOWER(name) = LOWER(@name) ORDER BY updated_at DESC LIMIT 1", + new { name }); + return sessions.FirstOrDefault(); + } + + public async Task> GetSessionsWithStats(string userId) + { + using var conn = CreateConnection(); + var sessions = await conn.QueryAsync( + "SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId }); + + var results = new List(); + foreach (var s in sessions) + { + var activeCount = await conn.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE c.session_id = @sessionId AND c.archive_id IS NULL AND m.type = 'text'", + new { sessionId = s.SessionId }); + + var totalCount = await conn.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE c.session_id = @sessionId AND m.type = 'text'", + new { sessionId = s.SessionId }); + + results.Add(new SessionWithStats + { + Session = s, + MessageCount = activeCount, + ArchiveMessageCount = totalCount - activeCount, + TotalMessageCount = totalCount, + TelegramConfigured = !string.IsNullOrEmpty(s.TelegramBotId), + TelegramLinked = !string.IsNullOrEmpty(s.TelegramChatId) + }); + } + return results; + } + + public async Task GetSession(string sessionId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM sessions WHERE session_id = @sessionId AND is_deleted = 0", new { sessionId }); + } + + public async Task UpdateSessionTimestamp(string sessionId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE sessions SET updated_at = @now WHERE session_id = @sessionId", + new { sessionId, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null) + { + using var conn = CreateConnection(); + var sets = new List { "updated_at = @now" }; + var p = new Dapper.DynamicParameters(); + p.Add("sessionId", sessionId); + p.Add("now", DateTime.UtcNow.ToString("o")); + + if (body.TryGetProperty("name", out var n)) { sets.Add("name = @name"); p.Add("name", n.GetString()); } + if (body.TryGetProperty("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); } + if (body.TryGetProperty("image", out var img)) + { + var imageData = img.GetString(); + if (imageService != null) + { + sets.Add("image_small = @imageSmall"); + p.Add("imageSmall", imageService.ResizeDataUrlSmall(imageData)); + imageData = imageService.ResizeDataUrl(imageData); + } + sets.Add("image = @image"); + p.Add("image", imageData); + } + if (body.TryGetProperty("agentId", out var a)) { sets.Add("agent_id = @agentId2"); p.Add("agentId2", a.GetString()); } + if (body.TryGetProperty("workspace", out var w)) { sets.Add("workspace = @workspace"); p.Add("workspace", w.GetString()); } + if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); } + if (body.TryGetProperty("customCommands", out var cc)) { sets.Add("custom_commands = @customCommands"); p.Add("customCommands", cc.ValueKind == System.Text.Json.JsonValueKind.Null ? null : cc.GetRawText()); } + if (body.TryGetProperty("avatarScale", out var asc)) { sets.Add("avatar_scale = @avatarScale"); p.Add("avatarScale", asc.GetDouble()); } + if (body.TryGetProperty("avatarOffsetX", out var aox)) { sets.Add("avatar_offset_x = @avatarOffsetX"); p.Add("avatarOffsetX", aox.GetDouble()); } + if (body.TryGetProperty("avatarOffsetY", out var aoy)) { sets.Add("avatar_offset_y = @avatarOffsetY"); p.Add("avatarOffsetY", aoy.GetDouble()); } + if (body.TryGetProperty("reviewAgentId", out var ra)) { sets.Add("review_agent_id = @reviewAgentId"); p.Add("reviewAgentId", ra.ValueKind == System.Text.Json.JsonValueKind.Null ? null : ra.GetString()); } + if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); } + if (body.TryGetProperty("finalizerAgentId", out var fa)) { sets.Add("finalizer_agent_id = @finalizerAgentId"); p.Add("finalizerAgentId", fa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : fa.GetString()); } + if (body.TryGetProperty("preparerAgentId", out var pa)) { sets.Add("preparer_agent_id = @preparerAgentId"); p.Add("preparerAgentId", pa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pa.GetString()); } + + await conn.ExecuteAsync($"UPDATE sessions SET {string.Join(", ", sets)} WHERE session_id = @sessionId", p); + } + + public async Task DeleteSession(string sessionId, string userId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE sessions SET is_deleted = 1, updated_at = @now WHERE session_id = @sessionId AND user_id = @userId", + new { sessionId, userId, now = DateTime.UtcNow.ToString("o") }); + } + + // ── Archives ── + + public async Task CreateArchive(string sessionId, string name = null) + { + using var conn = CreateConnection(); + + var chainCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM chains WHERE archive_id IS NULL AND session_id = @sessionId", + new { sessionId }); + if (chainCount == 0) return null; + + var messageCount = await conn.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM messages + WHERE type = 'text' AND chain_id IN (SELECT chain_id FROM chains WHERE archive_id IS NULL AND session_id = @sessionId)", + new { sessionId }); + + var archive = new ArchiveRecord + { + ArchiveId = Guid.NewGuid().ToString("N"), + SessionId = sessionId, + Name = name, + ChainCount = chainCount, + MessageCount = messageCount, + CreatedAt = DateTime.UtcNow.ToString("o") + }; + + await conn.ExecuteAsync(@" + INSERT INTO archives (archive_id, session_id, name, chain_count, message_count, created_at) + VALUES (@ArchiveId, @SessionId, @Name, @ChainCount, @MessageCount, @CreatedAt) + ", archive); + + await conn.ExecuteAsync( + "UPDATE chains SET archive_id = @archiveId WHERE archive_id IS NULL AND session_id = @sessionId", + new { archiveId = archive.ArchiveId, sessionId }); + + return archive; + } + + public async Task> GetArchives(string sessionId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM archives WHERE session_id = @sessionId ORDER BY id DESC", + new { sessionId }); + } + + public async Task> GetArchiveChains(string archiveId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM chains WHERE archive_id = @archiveId ORDER BY id", + new { archiveId }); + } + + // ── Chains ── + + public async Task CreateChain(ChainRecord chain) + { + using var conn = CreateConnection(); + chain.CreatedAt = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + INSERT INTO chains (chain_id, session_id, archive_id, agent_id, model, system_prompt, tool_choice, tool_names, input_tokens, output_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at) + VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @PlanMode, @CreatedAt) + ", chain); + } + + public async Task UpdateChain(ChainRecord chain) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync(@" + UPDATE chains SET + input_tokens = @InputTokens, + output_tokens = @OutputTokens, + cache_creation_tokens = @CacheCreationTokens, + cache_read_tokens = @CacheReadTokens, + iterations = @Iterations, + stop_reason = @StopReason, + stream_error_info = @StreamErrorInfo, + duration_ms = @DurationMs + WHERE chain_id = @ChainId + ", chain); + } + + public async Task GetChain(string chainId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM chains WHERE chain_id = @chainId", new { chainId } + ); + } + + public async Task> GetActiveChains(string sessionId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM chains WHERE session_id = @sessionId AND archive_id IS NULL ORDER BY id DESC", + new { sessionId }); + } + + // ── Messages ── + + public async Task SaveMessage(MessageRecord record) + { + using var conn = CreateConnection(); + record.CreatedAt = DateTime.UtcNow.ToString("o"); + return await conn.ExecuteScalarAsync(@" + INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, tag, thought_signature, created_at) + VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @Tag, @ThoughtSignature, @CreatedAt); + SELECT last_insert_rowid(); + ", record); + } + + /// + /// Speichert Message + Attachments in einer Transaktion (1x fsync statt N+1). + /// + public async Task SaveMessageWithAttachments(MessageRecord record, List attachments) + { + using var conn = CreateConnection(); + using var tx = conn.BeginTransaction(); + + record.CreatedAt = DateTime.UtcNow.ToString("o"); + record.AttachmentCount = attachments?.Count ?? 0; + if (attachments != null && attachments.Count > 0) + { + var names = attachments.Select(a => a.FileName ?? "").ToArray(); + record.AttachmentNames = System.Text.Json.JsonSerializer.Serialize(names); + } + var messageId = await conn.ExecuteScalarAsync(@" + INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, attachment_names, tag, created_at) + VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @AttachmentNames, @Tag, @CreatedAt); + SELECT last_insert_rowid(); + ", record, tx); + + if (attachments != null) + { + var now = DateTime.UtcNow.ToString("o"); + foreach (var att in attachments) + { + att.MessageId = messageId; + att.CreatedAt = now; + await conn.ExecuteAsync(@" + INSERT INTO attachments (message_id, type, media_type, file_name, data, created_at) + VALUES (@MessageId, @Type, @MediaType, @FileName, @Data, @CreatedAt) + ", att, tx); + } + } + + tx.Commit(); + return messageId; + } + + + // ── Attachments ── + + public async Task> GetAttachments(long messageId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id", + new { messageId }); + } + + public async Task> GetAttachmentsForMessages(IEnumerable messageIds) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id", + new { messageIds }); + } + + public async Task> GetChainMessages(string chainId, bool includeDeleted = false) + { + using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL"; + return await conn.QueryAsync( + $"SELECT * FROM messages WHERE chain_id = @chainId{deletedFilter} ORDER BY id", new { chainId } + ); + } + + public async Task GetActiveMessageCount(string sessionId, bool includeDeleted = false) + { + using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; + return await conn.ExecuteScalarAsync($@" + SELECT COUNT(*) FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", + new { sessionId }); + } + + public async Task> GetLatestActiveMessages(string sessionId, int limit, bool includeDeleted = false) + { + using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; + var messages = await conn.QueryAsync($@" + SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} + ORDER BY sort_key DESC LIMIT @limit", + new { sessionId, limit }); + return messages.Reverse().ToList(); + } + + public async Task GetActiveMessageCountBeforeSortKey(string sessionId, long beforeSortKey, bool includeDeleted = false) + { + using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; + return await conn.ExecuteScalarAsync($@" + SELECT COUNT(*) FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", + new { sessionId, beforeSortKey }); + } + + public async Task> GetActiveMessagesBeforeSortKey(string sessionId, long beforeSortKey, int limit, bool includeDeleted = false) + { + using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; + var messages = await conn.QueryAsync($@" + SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} + ORDER BY sort_key DESC LIMIT @limit", + new { sessionId, beforeSortKey, limit }); + return messages.Reverse().ToList(); + } + + public async Task> GetActiveMessagesAfterSortKey(string sessionId, long afterSortKey, int limit = 200, bool includeDeleted = false) + { + using var conn = CreateConnection(); + var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; + return await conn.QueryAsync($@" + SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) > @afterSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} + ORDER BY sort_key LIMIT @limit", + new { sessionId, afterSortKey, limit }); + } + + // ── Message Marking ── + + public async Task ToggleMessageMark(long messageId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE messages SET is_marked = CASE WHEN is_marked = 1 THEN 0 ELSE 1 END WHERE id = @messageId", + new { messageId }); + return await conn.ExecuteScalarAsync( + "SELECT is_marked FROM messages WHERE id = @messageId", + new { messageId }); + } + + // ── Message Deletion ── + + public async Task ToggleMessageDelete(long messageId) + { + using var conn = CreateConnection(); + var current = await conn.ExecuteScalarAsync( + "SELECT deleted_on FROM messages WHERE id = @messageId", new { messageId }); + string newValue; + if (current == null) + { + newValue = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync( + "UPDATE messages SET deleted_on = @newValue WHERE id = @messageId", + new { messageId, newValue }); + } + else + { + newValue = null; + await conn.ExecuteAsync( + "UPDATE messages SET deleted_on = NULL WHERE id = @messageId", + new { messageId }); + } + return newValue; + } + + public async Task> GetMarkedMessages(string sessionId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync(@" + SELECT m.* FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE c.session_id = @sessionId AND m.is_marked = 1 + ORDER BY m.id", + new { sessionId }); + } + +} diff --git a/Services/DatabaseService.Users.cs b/Services/DatabaseService.Users.cs new file mode 100644 index 0000000..d0fbfa3 --- /dev/null +++ b/Services/DatabaseService.Users.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapper; +using Gott.Models; + +namespace Gott.Services; + +public partial class DatabaseService +{ + // ── Shared Messages ── + + public async Task CreateSharedMessage(long messageId, string userId, bool includeContext, string expiresAt = null) + { + using var conn = CreateConnection(); + var record = new SharedMessageRecord + { + ShareToken = Guid.NewGuid().ToString("N"), + MessageId = messageId, + UserId = userId, + IncludeContext = includeContext, + ExpiresAt = expiresAt, + CreatedAt = DateTime.UtcNow.ToString("o") + }; + await conn.ExecuteAsync(@" + INSERT INTO shared_messages (share_token, message_id, user_id, include_context, expires_at, created_at) + VALUES (@ShareToken, @MessageId, @UserId, @IncludeContext, @ExpiresAt, @CreatedAt) + ", record); + return record; + } + + public async Task GetSharedMessage(string shareToken) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken }); + } + + public async Task> GetSharedMessagesByUser(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM shared_messages WHERE user_id = @userId ORDER BY created_at DESC", new { userId }); + } + + public async Task DeleteSharedMessage(string shareToken, string userId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "DELETE FROM shared_messages WHERE share_token = @shareToken AND user_id = @userId", + new { shareToken, userId }); + } + + public async Task GetMessage(long messageId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM messages WHERE id = @messageId", new { messageId }); + } + + public async Task> GetChainMessagesUpTo(string chainId, long messageId) + { + using var conn = CreateConnection(); + var result = await conn.QueryAsync( + "SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC", + new { chainId, messageId }); + return result.AsList(); + } + + + // ── Users ── + + public void EnsureDefaultUser(AuthService auth) + { + using var conn = CreateConnection(); + var count = conn.ExecuteScalar("SELECT COUNT(*) FROM users"); + string adminUserId; + if (count == 0) + { + adminUserId = Guid.NewGuid().ToString("N"); + var now = DateTime.UtcNow.ToString("o"); + conn.Execute(@" + INSERT INTO users (user_id, username, password_hash, display_name, is_admin, created_at, updated_at) + VALUES (@userId, @username, @hash, @displayName, 1, @now, @now)", + new + { + userId = adminUserId, + username = "admin", + hash = auth.HashPassword("admin"), + displayName = "Admin", + now + }); + } + else + { + adminUserId = conn.ExecuteScalar("SELECT user_id FROM users ORDER BY id LIMIT 1"); + } + + // Bestehende Sessions/Agents ohne User dem ersten User zuweisen + conn.Execute("UPDATE sessions SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId }); + conn.Execute("UPDATE agents SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId }); + + // Erster User ist immer Admin + conn.Execute("UPDATE users SET is_admin = 1 WHERE user_id = @userId AND is_admin = 0", new { userId = adminUserId }); + } + + public async Task GetUserByUsername(string username) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM users WHERE username = @username COLLATE NOCASE", + new { username }); + } + + public async Task GetUser(string userId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM users WHERE user_id = @userId", + new { userId }); + } + + public async Task> GetUsers() + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM users ORDER BY username"); + } + + public async Task UpdateUserPassword(string userId, string passwordHash) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE users SET password_hash = @passwordHash, updated_at = @now WHERE user_id = @userId", + new { userId, passwordHash, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task UpdateUserProfile(string userId, string displayName, string persona) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId", + new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task GetUserSettings(string userId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT settings FROM users WHERE user_id = @userId", + new { userId }); + } + + public async Task UpdateUserSettings(string userId, string settings) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE users SET settings = @settings, updated_at = @now WHERE user_id = @userId", + new { userId, settings, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task CreateUser(string username, string passwordHash, string displayName, bool isAdmin, string defaultPath = null) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + var userId = Guid.NewGuid().ToString("N"); + await conn.ExecuteAsync(@" + INSERT INTO users (user_id, username, password_hash, display_name, is_admin, default_path, created_at, updated_at) + VALUES (@userId, @username, @passwordHash, @displayName, @isAdmin, @defaultPath, @now, @now)", + new { userId, username, passwordHash, displayName, isAdmin, defaultPath, now }); + return await GetUser(userId); + } + + public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin, string defaultPath) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "UPDATE users SET username = @username, display_name = @displayName, is_admin = @isAdmin, default_path = @defaultPath, updated_at = @now WHERE user_id = @userId", + new { userId, username, displayName, isAdmin, defaultPath, now = DateTime.UtcNow.ToString("o") }); + } + + public async Task DeleteUser(string userId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync("DELETE FROM tokens WHERE user_id = @userId", new { userId }); + await conn.ExecuteAsync("DELETE FROM shared_messages WHERE user_id = @userId", new { userId }); + await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId }); + } + +} diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 14f6c7c..f8561ff 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -12,7 +12,7 @@ using SixLabors.ImageSharp.Processing; namespace Gott.Services; -public class DatabaseService +public partial class DatabaseService { private readonly string _connectionString; @@ -422,1122 +422,4 @@ public class DatabaseService catch { /* column already exists */ } } - // ── Shared Messages ── - - public async Task CreateSharedMessage(long messageId, string userId, bool includeContext, string expiresAt = null) - { - using var conn = CreateConnection(); - var record = new SharedMessageRecord - { - ShareToken = Guid.NewGuid().ToString("N"), - MessageId = messageId, - UserId = userId, - IncludeContext = includeContext, - ExpiresAt = expiresAt, - CreatedAt = DateTime.UtcNow.ToString("o") - }; - await conn.ExecuteAsync(@" - INSERT INTO shared_messages (share_token, message_id, user_id, include_context, expires_at, created_at) - VALUES (@ShareToken, @MessageId, @UserId, @IncludeContext, @ExpiresAt, @CreatedAt) - ", record); - return record; - } - - public async Task GetSharedMessage(string shareToken) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken }); - } - - public async Task> GetSharedMessagesByUser(string userId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM shared_messages WHERE user_id = @userId ORDER BY created_at DESC", new { userId }); - } - - public async Task DeleteSharedMessage(string shareToken, string userId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "DELETE FROM shared_messages WHERE share_token = @shareToken AND user_id = @userId", - new { shareToken, userId }); - } - - public async Task GetMessage(long messageId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM messages WHERE id = @messageId", new { messageId }); - } - - public async Task> GetChainMessagesUpTo(string chainId, long messageId) - { - using var conn = CreateConnection(); - var result = await conn.QueryAsync( - "SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC", - new { chainId, messageId }); - return result.AsList(); - } - - // ── Agents ── - - public async Task CreateAgent(AgentRecord agent) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - agent.AgentId = Guid.NewGuid().ToString("N"); - agent.CreatedAt = now; - agent.UpdatedAt = now; - await conn.ExecuteAsync(@" - INSERT INTO agents (agent_id, user_id, name, avatar, avatar_small, description, model, tool_choice, custom_commands, auto_commit, use_soul, use_identity, use_user_info, use_memory, use_auto_context, use_web_search, web_search_version, allow_user_choice, task_instruction, avatar_scale, avatar_offset_x, avatar_offset_y, is_deleted, created_at, updated_at) - VALUES (@AgentId, @UserId, @Name, @Avatar, @AvatarSmall, @Description, @Model, @ToolChoice, @CustomCommands, @AutoCommit, @UseSoul, @UseIdentity, @UseUserInfo, @UseMemory, @UseAutoContext, @UseWebSearch, @WebSearchVersion, @AllowUserChoice, @TaskInstruction, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, 0, @CreatedAt, @UpdatedAt) - ", agent); - return agent; - } - - public async Task> GetAgents(string userId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM agents WHERE user_id = @userId AND is_deleted = 0 ORDER BY name", new { userId }); - } - - public async Task> GetAvailableAgents(string userId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM agents WHERE (user_id = @userId OR is_public = 1) AND is_deleted = 0 ORDER BY name", new { userId }); - } - - public async Task GetAgent(string agentId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM agents WHERE agent_id = @agentId AND is_deleted = 0", new { agentId }); - } - - public async Task UpdateAgent(AgentRecord agent) - { - using var conn = CreateConnection(); - agent.UpdatedAt = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - UPDATE agents SET name = @Name, avatar = @Avatar, avatar_small = @AvatarSmall, description = @Description, - model = @Model, tool_choice = @ToolChoice, - custom_commands = @CustomCommands, auto_commit = @AutoCommit, - use_soul = @UseSoul, use_identity = @UseIdentity, use_user_info = @UseUserInfo, use_memory = @UseMemory, use_auto_context = @UseAutoContext, use_web_search = @UseWebSearch, web_search_version = @WebSearchVersion, - allow_user_choice = @AllowUserChoice, task_instruction = @TaskInstruction, - avatar_scale = @AvatarScale, avatar_offset_x = @AvatarOffsetX, avatar_offset_y = @AvatarOffsetY, - is_public = @IsPublic, updated_at = @UpdatedAt - WHERE agent_id = @AgentId AND user_id = @UserId - ", agent); - } - - public async Task DeleteAgent(string agentId, string userId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE agents SET is_deleted = 1, updated_at = @now WHERE agent_id = @agentId AND user_id = @userId", - new { agentId, userId, now = DateTime.UtcNow.ToString("o") }); - } - - // ── Sessions ── - - public async Task CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - var session = new SessionRecord - { - SessionId = Guid.NewGuid().ToString("N"), - UserId = userId, - Name = name, - Description = description, - Image = image, - ImageSmall = imageSmall, - AgentId = agentId, - Workspace = workspace, - Context = context, - CustomCommands = customCommands, - AvatarScale = avatarScale, - AvatarOffsetX = avatarOffsetX, - AvatarOffsetY = avatarOffsetY, - ReviewAgentId = reviewAgentId, - MaxReviewIterations = maxReviewIterations, - FinalizerAgentId = finalizerAgentId, - PreparerAgentId = preparerAgentId, - CreatedAt = now, - UpdatedAt = now - }; - await conn.ExecuteAsync(@" - INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, created_at, updated_at) - VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @CreatedAt, @UpdatedAt) - ", session); - return session; - } - - public async Task GetSessionByName(string name) - { - using var conn = CreateConnection(); - var sessions = await conn.QueryAsync( - "SELECT * FROM sessions WHERE is_deleted = 0 AND LOWER(name) = LOWER(@name) ORDER BY updated_at DESC LIMIT 1", - new { name }); - return sessions.FirstOrDefault(); - } - - public async Task> GetSessionsWithStats(string userId) - { - using var conn = CreateConnection(); - var sessions = await conn.QueryAsync( - "SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId }); - - var results = new List(); - foreach (var s in sessions) - { - var activeCount = await conn.ExecuteScalarAsync(@" - SELECT COUNT(*) FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE c.session_id = @sessionId AND c.archive_id IS NULL AND m.type = 'text'", - new { sessionId = s.SessionId }); - - var totalCount = await conn.ExecuteScalarAsync(@" - SELECT COUNT(*) FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE c.session_id = @sessionId AND m.type = 'text'", - new { sessionId = s.SessionId }); - - results.Add(new SessionWithStats - { - Session = s, - MessageCount = activeCount, - ArchiveMessageCount = totalCount - activeCount, - TotalMessageCount = totalCount, - TelegramConfigured = !string.IsNullOrEmpty(s.TelegramBotId), - TelegramLinked = !string.IsNullOrEmpty(s.TelegramChatId) - }); - } - return results; - } - - public async Task GetSession(string sessionId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM sessions WHERE session_id = @sessionId AND is_deleted = 0", new { sessionId }); - } - - public async Task UpdateSessionTimestamp(string sessionId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE sessions SET updated_at = @now WHERE session_id = @sessionId", - new { sessionId, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null) - { - using var conn = CreateConnection(); - var sets = new List { "updated_at = @now" }; - var p = new Dapper.DynamicParameters(); - p.Add("sessionId", sessionId); - p.Add("now", DateTime.UtcNow.ToString("o")); - - if (body.TryGetProperty("name", out var n)) { sets.Add("name = @name"); p.Add("name", n.GetString()); } - if (body.TryGetProperty("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); } - if (body.TryGetProperty("image", out var img)) - { - var imageData = img.GetString(); - if (imageService != null) - { - sets.Add("image_small = @imageSmall"); - p.Add("imageSmall", imageService.ResizeDataUrlSmall(imageData)); - imageData = imageService.ResizeDataUrl(imageData); - } - sets.Add("image = @image"); - p.Add("image", imageData); - } - if (body.TryGetProperty("agentId", out var a)) { sets.Add("agent_id = @agentId2"); p.Add("agentId2", a.GetString()); } - if (body.TryGetProperty("workspace", out var w)) { sets.Add("workspace = @workspace"); p.Add("workspace", w.GetString()); } - if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); } - if (body.TryGetProperty("customCommands", out var cc)) { sets.Add("custom_commands = @customCommands"); p.Add("customCommands", cc.ValueKind == System.Text.Json.JsonValueKind.Null ? null : cc.GetRawText()); } - if (body.TryGetProperty("avatarScale", out var asc)) { sets.Add("avatar_scale = @avatarScale"); p.Add("avatarScale", asc.GetDouble()); } - if (body.TryGetProperty("avatarOffsetX", out var aox)) { sets.Add("avatar_offset_x = @avatarOffsetX"); p.Add("avatarOffsetX", aox.GetDouble()); } - if (body.TryGetProperty("avatarOffsetY", out var aoy)) { sets.Add("avatar_offset_y = @avatarOffsetY"); p.Add("avatarOffsetY", aoy.GetDouble()); } - if (body.TryGetProperty("reviewAgentId", out var ra)) { sets.Add("review_agent_id = @reviewAgentId"); p.Add("reviewAgentId", ra.ValueKind == System.Text.Json.JsonValueKind.Null ? null : ra.GetString()); } - if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); } - if (body.TryGetProperty("finalizerAgentId", out var fa)) { sets.Add("finalizer_agent_id = @finalizerAgentId"); p.Add("finalizerAgentId", fa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : fa.GetString()); } - if (body.TryGetProperty("preparerAgentId", out var pa)) { sets.Add("preparer_agent_id = @preparerAgentId"); p.Add("preparerAgentId", pa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pa.GetString()); } - - await conn.ExecuteAsync($"UPDATE sessions SET {string.Join(", ", sets)} WHERE session_id = @sessionId", p); - } - - public async Task DeleteSession(string sessionId, string userId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE sessions SET is_deleted = 1, updated_at = @now WHERE session_id = @sessionId AND user_id = @userId", - new { sessionId, userId, now = DateTime.UtcNow.ToString("o") }); - } - - // ── Archives ── - - public async Task CreateArchive(string sessionId, string name = null) - { - using var conn = CreateConnection(); - - var chainCount = await conn.ExecuteScalarAsync( - "SELECT COUNT(*) FROM chains WHERE archive_id IS NULL AND session_id = @sessionId", - new { sessionId }); - if (chainCount == 0) return null; - - var messageCount = await conn.ExecuteScalarAsync(@" - SELECT COUNT(*) FROM messages - WHERE type = 'text' AND chain_id IN (SELECT chain_id FROM chains WHERE archive_id IS NULL AND session_id = @sessionId)", - new { sessionId }); - - var archive = new ArchiveRecord - { - ArchiveId = Guid.NewGuid().ToString("N"), - SessionId = sessionId, - Name = name, - ChainCount = chainCount, - MessageCount = messageCount, - CreatedAt = DateTime.UtcNow.ToString("o") - }; - - await conn.ExecuteAsync(@" - INSERT INTO archives (archive_id, session_id, name, chain_count, message_count, created_at) - VALUES (@ArchiveId, @SessionId, @Name, @ChainCount, @MessageCount, @CreatedAt) - ", archive); - - await conn.ExecuteAsync( - "UPDATE chains SET archive_id = @archiveId WHERE archive_id IS NULL AND session_id = @sessionId", - new { archiveId = archive.ArchiveId, sessionId }); - - return archive; - } - - public async Task> GetArchives(string sessionId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM archives WHERE session_id = @sessionId ORDER BY id DESC", - new { sessionId }); - } - - public async Task> GetArchiveChains(string archiveId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM chains WHERE archive_id = @archiveId ORDER BY id", - new { archiveId }); - } - - // ── Chains ── - - public async Task CreateChain(ChainRecord chain) - { - using var conn = CreateConnection(); - chain.CreatedAt = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - INSERT INTO chains (chain_id, session_id, archive_id, agent_id, model, system_prompt, tool_choice, tool_names, input_tokens, output_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at) - VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @PlanMode, @CreatedAt) - ", chain); - } - - public async Task UpdateChain(ChainRecord chain) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync(@" - UPDATE chains SET - input_tokens = @InputTokens, - output_tokens = @OutputTokens, - cache_creation_tokens = @CacheCreationTokens, - cache_read_tokens = @CacheReadTokens, - iterations = @Iterations, - stop_reason = @StopReason, - stream_error_info = @StreamErrorInfo, - duration_ms = @DurationMs - WHERE chain_id = @ChainId - ", chain); - } - - public async Task GetChain(string chainId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM chains WHERE chain_id = @chainId", new { chainId } - ); - } - - public async Task> GetActiveChains(string sessionId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM chains WHERE session_id = @sessionId AND archive_id IS NULL ORDER BY id DESC", - new { sessionId }); - } - - // ── Messages ── - - public async Task SaveMessage(MessageRecord record) - { - using var conn = CreateConnection(); - record.CreatedAt = DateTime.UtcNow.ToString("o"); - return await conn.ExecuteScalarAsync(@" - INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, tag, thought_signature, created_at) - VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @Tag, @ThoughtSignature, @CreatedAt); - SELECT last_insert_rowid(); - ", record); - } - - /// - /// Speichert Message + Attachments in einer Transaktion (1x fsync statt N+1). - /// - public async Task SaveMessageWithAttachments(MessageRecord record, List attachments) - { - using var conn = CreateConnection(); - using var tx = conn.BeginTransaction(); - - record.CreatedAt = DateTime.UtcNow.ToString("o"); - record.AttachmentCount = attachments?.Count ?? 0; - if (attachments != null && attachments.Count > 0) - { - var names = attachments.Select(a => a.FileName ?? "").ToArray(); - record.AttachmentNames = System.Text.Json.JsonSerializer.Serialize(names); - } - var messageId = await conn.ExecuteScalarAsync(@" - INSERT INTO messages (chain_id, role, type, content, tool_name, tool_call_id, is_error, iteration, attachment_count, attachment_names, tag, created_at) - VALUES (@ChainId, @Role, @Type, @Content, @ToolName, @ToolCallId, @IsError, @Iteration, @AttachmentCount, @AttachmentNames, @Tag, @CreatedAt); - SELECT last_insert_rowid(); - ", record, tx); - - if (attachments != null) - { - var now = DateTime.UtcNow.ToString("o"); - foreach (var att in attachments) - { - att.MessageId = messageId; - att.CreatedAt = now; - await conn.ExecuteAsync(@" - INSERT INTO attachments (message_id, type, media_type, file_name, data, created_at) - VALUES (@MessageId, @Type, @MediaType, @FileName, @Data, @CreatedAt) - ", att, tx); - } - } - - tx.Commit(); - return messageId; - } - - // ── OpenClaw Import ── - - public async Task CreateChainForImport(string chainId, string sessionId, string agentId, string model, int inputTokens, int outputTokens, int cacheCreationTokens, int cacheReadTokens, string createdAt) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync(@" - INSERT INTO chains (chain_id, session_id, agent_id, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at) - VALUES (@chainId, @sessionId, @agentId, @model, @inputTokens, @outputTokens, @cacheCreationTokens, @cacheReadTokens, 0, 'end_turn', 0, 0, @createdAt) - ", new { chainId, sessionId, agentId, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, createdAt }); - } - - public async Task CreateMessageForImport(string chainId, string role, string type, string content, string createdAt, string tag) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync(@" - INSERT INTO messages (chain_id, role, type, content, is_error, iteration, attachment_count, tag, created_at) - VALUES (@chainId, @role, @type, @content, 0, 0, 0, @tag, @createdAt) - ", new { chainId, role, type, content, tag, createdAt }); - } - - public async Task DeleteOpenClawImport(string sessionId) - { - using var conn = CreateConnection(); - using var tx = conn.BeginTransaction(); - - // Get all chain_ids that belong to this session and have imported messages - var importedChainIds = (await conn.QueryAsync(@" - SELECT DISTINCT c.chain_id - FROM chains c - INNER JOIN messages m ON m.chain_id = c.chain_id - WHERE c.session_id = @sessionId AND m.tag = 'openclaw-import' - ", new { sessionId }, tx)).ToList(); - - if (importedChainIds.Count > 0) - { - // Delete messages for these chains - await conn.ExecuteAsync( - "DELETE FROM messages WHERE chain_id IN @chainIds", - new { chainIds = importedChainIds }, tx); - - // Delete the chains themselves - await conn.ExecuteAsync( - "DELETE FROM chains WHERE chain_id IN @chainIds", - new { chainIds = importedChainIds }, tx); - } - - tx.Commit(); - } - - // ── Attachments ── - - public async Task> GetAttachments(long messageId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id", - new { messageId }); - } - - public async Task> GetAttachmentsForMessages(IEnumerable messageIds) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id", - new { messageIds }); - } - - public async Task> GetChainMessages(string chainId, bool includeDeleted = false) - { - using var conn = CreateConnection(); - var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL"; - return await conn.QueryAsync( - $"SELECT * FROM messages WHERE chain_id = @chainId{deletedFilter} ORDER BY id", new { chainId } - ); - } - - public async Task GetActiveMessageCount(string sessionId, bool includeDeleted = false) - { - using var conn = CreateConnection(); - var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; - return await conn.ExecuteScalarAsync($@" - SELECT COUNT(*) FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", - new { sessionId }); - } - - public async Task> GetLatestActiveMessages(string sessionId, int limit, bool includeDeleted = false) - { - using var conn = CreateConnection(); - var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; - var messages = await conn.QueryAsync($@" - SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} - ORDER BY sort_key DESC LIMIT @limit", - new { sessionId, limit }); - return messages.Reverse().ToList(); - } - - public async Task GetActiveMessageCountBeforeSortKey(string sessionId, long beforeSortKey, bool includeDeleted = false) - { - using var conn = CreateConnection(); - var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; - return await conn.ExecuteScalarAsync($@" - SELECT COUNT(*) FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId AND m.type = 'text' {deletedFilter}", - new { sessionId, beforeSortKey }); - } - - public async Task> GetActiveMessagesBeforeSortKey(string sessionId, long beforeSortKey, int limit, bool includeDeleted = false) - { - using var conn = CreateConnection(); - var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; - var messages = await conn.QueryAsync($@" - SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) < @beforeSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} - ORDER BY sort_key DESC LIMIT @limit", - new { sessionId, beforeSortKey, limit }); - return messages.Reverse().ToList(); - } - - public async Task> GetActiveMessagesAfterSortKey(string sessionId, long afterSortKey, int limit = 200, bool includeDeleted = false) - { - using var conn = CreateConnection(); - var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL"; - return await conn.QueryAsync($@" - SELECT m.*, (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) AS sort_key FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE (CASE WHEN m.tag = 'openclaw-import' THEN m.id - 10000000000 ELSE m.id END) > @afterSortKey AND c.archive_id IS NULL AND c.session_id = @sessionId {deletedFilter} - ORDER BY sort_key LIMIT @limit", - new { sessionId, afterSortKey, limit }); - } - - // ── Message Marking ── - - public async Task ToggleMessageMark(long messageId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE messages SET is_marked = CASE WHEN is_marked = 1 THEN 0 ELSE 1 END WHERE id = @messageId", - new { messageId }); - return await conn.ExecuteScalarAsync( - "SELECT is_marked FROM messages WHERE id = @messageId", - new { messageId }); - } - - // ── Message Deletion ── - - public async Task ToggleMessageDelete(long messageId) - { - using var conn = CreateConnection(); - var current = await conn.ExecuteScalarAsync( - "SELECT deleted_on FROM messages WHERE id = @messageId", new { messageId }); - string newValue; - if (current == null) - { - newValue = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync( - "UPDATE messages SET deleted_on = @newValue WHERE id = @messageId", - new { messageId, newValue }); - } - else - { - newValue = null; - await conn.ExecuteAsync( - "UPDATE messages SET deleted_on = NULL WHERE id = @messageId", - new { messageId }); - } - return newValue; - } - - public async Task> GetMarkedMessages(string sessionId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync(@" - SELECT m.* FROM messages m - JOIN chains c ON m.chain_id = c.chain_id - WHERE c.session_id = @sessionId AND m.is_marked = 1 - ORDER BY m.id", - new { sessionId }); - } - - // ── Agent Skills ── - - public async Task> GetAgentSkills(string agentId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug", - new { agentId }); - } - - public async Task SetAgentSkills(string agentId, IEnumerable skillSlugs) - { - using var conn = CreateConnection(); - using var tx = conn.BeginTransaction(); - - await conn.ExecuteAsync("DELETE FROM agent_skills WHERE agent_id = @agentId", new { agentId }, tx); - - var now = DateTime.UtcNow.ToString("o"); - foreach (var slug in skillSlugs) - { - await conn.ExecuteAsync(@" - INSERT INTO agent_skills (agent_id, skill_slug, created_at) - VALUES (@agentId, @slug, @now)", - new { agentId, slug, now }, tx); - } - - tx.Commit(); - } - - public async Task AddAgentSkill(string agentId, string skillSlug) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - INSERT OR IGNORE INTO agent_skills (agent_id, skill_slug, created_at) - VALUES (@agentId, @skillSlug, @now)", - new { agentId, skillSlug, now }); - } - - public async Task RemoveAgentSkill(string agentId, string skillSlug) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "DELETE FROM agent_skills WHERE agent_id = @agentId AND skill_slug = @skillSlug", - new { agentId, skillSlug }); - } - - // ── Users ── - - public void EnsureDefaultUser(AuthService auth) - { - using var conn = CreateConnection(); - var count = conn.ExecuteScalar("SELECT COUNT(*) FROM users"); - string adminUserId; - if (count == 0) - { - adminUserId = Guid.NewGuid().ToString("N"); - var now = DateTime.UtcNow.ToString("o"); - conn.Execute(@" - INSERT INTO users (user_id, username, password_hash, display_name, is_admin, created_at, updated_at) - VALUES (@userId, @username, @hash, @displayName, 1, @now, @now)", - new - { - userId = adminUserId, - username = "admin", - hash = auth.HashPassword("admin"), - displayName = "Admin", - now - }); - } - else - { - adminUserId = conn.ExecuteScalar("SELECT user_id FROM users ORDER BY id LIMIT 1"); - } - - // Bestehende Sessions/Agents ohne User dem ersten User zuweisen - conn.Execute("UPDATE sessions SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId }); - conn.Execute("UPDATE agents SET user_id = @userId WHERE user_id IS NULL", new { userId = adminUserId }); - - // Erster User ist immer Admin - conn.Execute("UPDATE users SET is_admin = 1 WHERE user_id = @userId AND is_admin = 0", new { userId = adminUserId }); - } - - public async Task GetUserByUsername(string username) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM users WHERE username = @username COLLATE NOCASE", - new { username }); - } - - public async Task GetUser(string userId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM users WHERE user_id = @userId", - new { userId }); - } - - public async Task> GetUsers() - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM users ORDER BY username"); - } - - public async Task UpdateUserPassword(string userId, string passwordHash) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE users SET password_hash = @passwordHash, updated_at = @now WHERE user_id = @userId", - new { userId, passwordHash, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task UpdateUserProfile(string userId, string displayName, string persona) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE users SET display_name = @displayName, persona = @persona, updated_at = @now WHERE user_id = @userId", - new { userId, displayName, persona, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task GetUserSettings(string userId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT settings FROM users WHERE user_id = @userId", - new { userId }); - } - - public async Task UpdateUserSettings(string userId, string settings) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE users SET settings = @settings, updated_at = @now WHERE user_id = @userId", - new { userId, settings, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task CreateUser(string username, string passwordHash, string displayName, bool isAdmin, string defaultPath = null) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - var userId = Guid.NewGuid().ToString("N"); - await conn.ExecuteAsync(@" - INSERT INTO users (user_id, username, password_hash, display_name, is_admin, default_path, created_at, updated_at) - VALUES (@userId, @username, @passwordHash, @displayName, @isAdmin, @defaultPath, @now, @now)", - new { userId, username, passwordHash, displayName, isAdmin, defaultPath, now }); - return await GetUser(userId); - } - - public async Task UpdateUserAdmin(string userId, string username, string displayName, bool isAdmin, string defaultPath) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE users SET username = @username, display_name = @displayName, is_admin = @isAdmin, default_path = @defaultPath, updated_at = @now WHERE user_id = @userId", - new { userId, username, displayName, isAdmin, defaultPath, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task DeleteUser(string userId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync("DELETE FROM tokens WHERE user_id = @userId", new { userId }); - await conn.ExecuteAsync("DELETE FROM shared_messages WHERE user_id = @userId", new { userId }); - await conn.ExecuteAsync("DELETE FROM users WHERE user_id = @userId", new { userId }); - } - - // ── Telegram Bots ── - - private void MigrateTelegramBotsFromSessions(SqliteConnection conn) - { - // Prüfe ob es Sessions mit bot_token gibt, die noch kein telegram_bot_id haben - var sessions = conn.Query( - "SELECT session_id, user_id, name, telegram_bot_token, telegram_webhook_secret FROM sessions WHERE telegram_bot_token IS NOT NULL AND telegram_bot_token != '' AND (telegram_bot_id IS NULL OR telegram_bot_id = '')").ToList(); - - if (sessions.Count == 0) return; - - var now = DateTime.UtcNow.ToString("o"); - foreach (var s in sessions) - { - string token = s.telegram_bot_token; - string userId = s.user_id ?? ""; - string sessionName = s.name ?? "Unbenannt"; - string secret = s.telegram_webhook_secret ?? Guid.NewGuid().ToString("N"); - - // Prüfe ob für diesen Token bereits ein Bot existiert - var existing = conn.QuerySingleOrDefault( - "SELECT bot_id FROM telegram_bots WHERE bot_token = @token", new { token }); - - string botId; - if (existing != null) - { - botId = existing.bot_id; - } - else - { - botId = Guid.NewGuid().ToString(); - conn.Execute(@" - INSERT INTO telegram_bots (bot_id, user_id, name, bot_token, webhook_secret, created_at, updated_at) - VALUES (@botId, @userId, @name, @token, @secret, @now, @now)", - new { botId, userId, name = $"Bot von {sessionName}", token, secret, now }); - } - - conn.Execute( - "UPDATE sessions SET telegram_bot_id = @botId, updated_at = @now WHERE session_id = @sessionId", - new { botId, sessionId = (string)s.session_id, now }); - } - } - - public async Task> GetTelegramBots(string userId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM telegram_bots WHERE user_id = @userId ORDER BY created_at DESC", - new { userId }); - } - - public async Task> GetAvailableTelegramBots(string userId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM telegram_bots WHERE user_id = @userId OR is_public = 1 ORDER BY created_at DESC", - new { userId }); - } - - public async Task GetTelegramBot(string botId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM telegram_bots WHERE bot_id = @botId", - new { botId }); - } - - public async Task CreateTelegramBot(string userId, string name, string botToken) - { - using var conn = CreateConnection(); - var botId = Guid.NewGuid().ToString(); - var secret = Guid.NewGuid().ToString("N"); - var now = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - INSERT INTO telegram_bots (bot_id, user_id, name, bot_token, webhook_secret, created_at, updated_at) - VALUES (@botId, @userId, @name, @botToken, @secret, @now, @now)", - new { botId, userId, name, botToken, secret, now }); - return await GetTelegramBot(botId); - } - - public async Task UpdateTelegramBot(string botId, string name, string botToken, bool isPublic) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - UPDATE telegram_bots SET name = @name, bot_token = @botToken, is_public = @isPublic, updated_at = @now - WHERE bot_id = @botId", - new { botId, name, botToken, isPublic, now }); - } - - public async Task DeleteTelegramBot(string botId) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - // Erst alle Session-Verknüpfungen entfernen - await conn.ExecuteAsync( - "UPDATE sessions SET telegram_bot_id = NULL, updated_at = @now WHERE telegram_bot_id = @botId", - new { botId, now }); - await conn.ExecuteAsync("DELETE FROM telegram_bots WHERE bot_id = @botId", new { botId }); - } - - public async Task GetTelegramBotSessionCount(string botId) - { - using var conn = CreateConnection(); - return await conn.ExecuteScalarAsync( - "SELECT COUNT(*) FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0", - new { botId }); - } - - public async Task SetSessionTelegramBot(string sessionId, string botId) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync( - "UPDATE sessions SET telegram_bot_id = @botId, updated_at = @now WHERE session_id = @sessionId", - new { sessionId, botId = (object)botId ?? DBNull.Value, now }); - } - - public async Task UpdateSessionTelegramChat(string sessionId, string chatId) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - UPDATE sessions SET telegram_chat_id = @chatId, updated_at = @now - WHERE session_id = @sessionId", - new { sessionId, chatId, now }); - } - - public async Task> GetSessionsByTelegramBot(string botId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0", - new { botId }); - } - - public async Task FindSessionForTelegramChat(string botId, long chatId) - { - using var conn = CreateConnection(); - var chatIdStr = chatId.ToString(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM sessions WHERE telegram_bot_id = @botId AND telegram_chat_id = @chatIdStr AND is_deleted = 0", - new { botId, chatIdStr }); - } - - public async Task> FindUnlinkedSessionsForBot(string botId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM sessions WHERE telegram_bot_id = @botId AND (telegram_chat_id IS NULL OR telegram_chat_id = '') AND is_deleted = 0", - new { botId }); - } - - // ── Cronjobs ── - - public async Task CreateCronjob(CronjobRecord cronjob) - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - cronjob.CronjobId = Guid.NewGuid().ToString("N"); - cronjob.CreatedAt = now; - cronjob.UpdatedAt = now; - await conn.ExecuteAsync(@" - INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at) - VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt) - ", cronjob); - return cronjob; - } - - public async Task> GetCronjobs(string userId) - { - using var conn = CreateConnection(); - return await conn.QueryAsync(@" - SELECT c.*, s.name AS session_name, ns.name AS notify_session_name - FROM cronjobs c - LEFT JOIN sessions s ON c.session_id = s.session_id - LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id - WHERE c.user_id = @userId - ORDER BY c.name", - new { userId }); - } - - public async Task GetCronjob(string cronjobId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync(@" - SELECT c.*, s.name AS session_name, ns.name AS notify_session_name - FROM cronjobs c - LEFT JOIN sessions s ON c.session_id = s.session_id - LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id - WHERE c.cronjob_id = @cronjobId", - new { cronjobId }); - } - - public async Task UpdateCronjob(CronjobRecord cronjob) - { - using var conn = CreateConnection(); - cronjob.UpdatedAt = DateTime.UtcNow.ToString("o"); - await conn.ExecuteAsync(@" - UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression, - prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt - WHERE cronjob_id = @CronjobId AND user_id = @UserId", - cronjob); - } - - public async Task DeleteCronjob(string cronjobId, string userId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId", - new { cronjobId, userId }); - } - - public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync(@" - UPDATE cronjobs SET last_run_at = @now, last_run_status = @status, - last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now - WHERE cronjob_id = @cronjobId", - new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task> GetDueCronjobs() - { - using var conn = CreateConnection(); - var now = DateTime.UtcNow.ToString("o"); - return await conn.QueryAsync(@" - SELECT c.*, s.name AS session_name, ns.name AS notify_session_name - FROM cronjobs c - LEFT JOIN sessions s ON c.session_id = s.session_id - LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id - WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now", - new { now }); - } - - // ── API Keys ── - - private void MigrateAnthropicTokenFromConfig(SqliteConnection conn) - { - // Nur migrieren wenn noch keine Anthropic-Keys existieren - var count = conn.ExecuteScalar("SELECT COUNT(*) FROM api_keys WHERE provider = 'anthropic'"); - if (count > 0) return; - - // Token aus appsettings.Production.json lesen - var configPath = Path.Combine(Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")) ?? "", "..", "appsettings.Production.json"); - // Normaler Pfad: neben der DB-Datei im übergeordneten Ordner - var basePath = Path.GetDirectoryName(_connectionString.Replace("Data Source=", "")); - if (basePath != null) - { - var prodPath = Path.Combine(basePath, "..", "appsettings.Production.json"); - if (File.Exists(prodPath)) - { - try - { - var json = File.ReadAllText(prodPath); - using var doc = System.Text.Json.JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("AnthropicToken", out var tokenEl)) - { - var token = tokenEl.GetString(); - if (!string.IsNullOrWhiteSpace(token)) - { - var now = DateTime.UtcNow.ToString("o"); - conn.Execute(@" - INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) - VALUES (@keyId, 'anthropic', 'Migriert aus Config', @token, 1, @now, @now)", - new { keyId = Guid.NewGuid().ToString("N"), token, now }); - Console.WriteLine("[Migration] AnthropicToken aus appsettings.Production.json → api_keys migriert"); - } - } - } - catch { /* Config nicht lesbar – ignorieren */ } - } - } - } - - public async Task> GetApiKeys() - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM api_keys ORDER BY provider, name"); - } - - public async Task> GetApiKeysByProvider(string provider) - { - using var conn = CreateConnection(); - return await conn.QueryAsync( - "SELECT * FROM api_keys WHERE provider = @provider ORDER BY name", - new { provider }); - } - - public async Task GetActiveApiKey(string provider) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1", - new { provider }); - } - - public async Task GetApiKey(string keyId) - { - using var conn = CreateConnection(); - return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM api_keys WHERE key_id = @keyId", - new { keyId }); - } - - public async Task CreateApiKey(ApiKeyRecord record) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync(@" - INSERT INTO api_keys (key_id, provider, name, api_key, is_active, created_at, updated_at) - VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @CreatedAt, @UpdatedAt)", - record); - } - - public async Task UpdateApiKey(string keyId, string name, string apiKey = null) - { - using var conn = CreateConnection(); - if (!string.IsNullOrWhiteSpace(apiKey)) - { - await conn.ExecuteAsync( - "UPDATE api_keys SET name = @name, api_key = @apiKey, updated_at = @now WHERE key_id = @keyId", - new { keyId, name, apiKey, now = DateTime.UtcNow.ToString("o") }); - } - else - { - await conn.ExecuteAsync( - "UPDATE api_keys SET name = @name, updated_at = @now WHERE key_id = @keyId", - new { keyId, name, now = DateTime.UtcNow.ToString("o") }); - } - } - - public async Task SetActiveApiKey(string keyId, string provider) - { - using var conn = CreateConnection(); - // Erst alle deaktivieren - await conn.ExecuteAsync( - "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE provider = @provider", - new { provider, now = DateTime.UtcNow.ToString("o") }); - // Dann den gewählten aktivieren - await conn.ExecuteAsync( - "UPDATE api_keys SET is_active = 1, updated_at = @now WHERE key_id = @keyId", - new { keyId, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task DeactivateApiKey(string keyId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync( - "UPDATE api_keys SET is_active = 0, updated_at = @now WHERE key_id = @keyId", - new { keyId, now = DateTime.UtcNow.ToString("o") }); - } - - public async Task DeleteApiKey(string keyId) - { - using var conn = CreateConnection(); - await conn.ExecuteAsync("DELETE FROM api_keys WHERE key_id = @keyId", new { keyId }); - } } diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 4aa7f53..58a7bfa 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -332,8 +332,8 @@ public class OpenAiService Role = "system", Type = "error", Content = errMsg, IsError = true }); - await TryWriteSse(response, "error", new { error = errMsg }, sseCt); - await TryWriteSse(response, "done", new { chainId }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); return; } @@ -399,9 +399,9 @@ public class OpenAiService Role = "system", Type = "error", Content = errMsg, IsError = true }); - await TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); await FinalizeChain(chain, usage, iteration, "error", sw); - await TryWriteSse(response, "done", new { chainId }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); return; } @@ -452,11 +452,11 @@ public class OpenAiService { if (!textStarted) { - await TryWriteSse(response, "text_start", new { index = 0 }, sseCt); + await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt); textStarted = true; } fullText.Append(chunk); - await TryWriteSse(response, "text_delta", new { text = chunk }, sseCt); + await SseHelper.TryWriteSse(response, "text_delta", new { text = chunk }, sseCt); } } @@ -544,7 +544,7 @@ public class OpenAiService // Text-End senden falls Text gestreamt wurde if (textStarted) - await TryWriteSse(response, "text_end", new { }, sseCt); + await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt); // Text in DB speichern var finalText = fullText.ToString(); @@ -562,7 +562,7 @@ public class OpenAiService if (toolCallAccumulators.Count == 0) { await FinalizeChain(chain, usage, iteration, stopReason, sw); - await TryWriteSse(response, "done", new + await SseHelper.TryWriteSse(response, "done", new { model = modelString, inputTokens = usage.InputTokens, @@ -625,7 +625,7 @@ public class OpenAiService foreach (var (_, (tcId, tcName, tcArgs, _)) in toolCallAccumulators) { // Tool-Start SSE - await TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt); + await SseHelper.TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt); // Tool ausführen JsonElement inputEl; @@ -636,7 +636,7 @@ public class OpenAiService var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:"); // Tool-End SSE - await TryWriteSse(response, "tool_end", new + await SseHelper.TryWriteSse(response, "tool_end", new { name = tcName, id = tcId, @@ -668,14 +668,14 @@ public class OpenAiService } // Max Iterations erreicht - await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt); await FinalizeChain(chain, usage, iteration, "max_iterations", sw); - await TryWriteSse(response, "done", new { chainId }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); } catch (OperationCanceledException) { await FinalizeChain(chain, usage, iteration, "stopped", sw); - await TryWriteSse(response, "done", new + await SseHelper.TryWriteSse(response, "done", new { model = modelString, iterations = iteration, @@ -693,9 +693,9 @@ public class OpenAiService Role = "system", Type = "error", Content = errMsg, IsError = true }); - await TryWriteSse(response, "error", new { error = errMsg }, sseCt); + await SseHelper.TryWriteSse(response, "error", new { error = errMsg }, sseCt); await FinalizeChain(chain, usage, iteration, "error", sw); - await TryWriteSse(response, "done", new { chainId }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); } } @@ -797,17 +797,8 @@ public class OpenAiService return null; } - private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) - { - chain.InputTokens = usage.InputTokens; - chain.OutputTokens = usage.OutputTokens; - chain.CacheCreationTokens = usage.CacheCreationTokens; - chain.CacheReadTokens = usage.CacheReadTokens; - chain.Iterations = iterations; - chain.StopReason = stopReason; - chain.DurationMs = sw.ElapsedMilliseconds; - await _db.UpdateChain(chain); - } + private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) + => SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw); private HttpClient CreateClient(string apiKey) { @@ -819,13 +810,5 @@ public class OpenAiService return client; } - private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct) - { - try - { - if (!ct.IsCancellationRequested) - await AnthropicService.WriteSseEvent(response, eventType, data, ct); - } - catch { } - } + // TryWriteSse → SseHelper.cs } diff --git a/Services/SseHelper.cs b/Services/SseHelper.cs new file mode 100644 index 0000000..a1f25dd --- /dev/null +++ b/Services/SseHelper.cs @@ -0,0 +1,85 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Gott.Models; +using Microsoft.AspNetCore.Http; + +namespace Gott.Services; + +/// +/// Shared SSE-Streaming-Utilities für AnthropicService und OpenAiService. +/// +public static class SseHelper +{ + /// + /// Schreibt ein SSE-Event (wirft bei CancellationToken). + /// + public static async Task WriteSseEvent(HttpResponse response, string eventType, object data, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var json = JsonSerializer.Serialize(data); + await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n", ct); + await response.Body.FlushAsync(ct); + } + + /// + /// Versucht ein SSE-Event zu schreiben, ignoriert Fehler (Client disconnected). + /// + public static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct) + { + try + { + if (!ct.IsCancellationRequested) + await WriteSseEvent(response, eventType, data, ct); + } + catch + { + // Client bereits disconnected – ignorieren + } + } + + /// + /// Finalisiert eine Chain in der Datenbank (Token-Counts, Stopgrund, Dauer). + /// + public static async Task FinalizeChain(DatabaseService db, ChainRecord chain, TokenUsage usage, int iterations, string stopReason, Stopwatch sw, string streamErrorInfo = null) + { + chain.InputTokens = usage.InputTokens; + chain.OutputTokens = usage.OutputTokens; + chain.CacheCreationTokens = usage.CacheCreationTokens; + chain.CacheReadTokens = usage.CacheReadTokens; + chain.Iterations = iterations; + chain.StopReason = stopReason; + chain.StreamErrorInfo = streamErrorInfo; + chain.DurationMs = sw.ElapsedMilliseconds; + await db.UpdateChain(chain); + } + + /// + /// Kürzt Tool-Results auf max. Zeichenzahl. + /// + public const int MaxToolResultChars = 20_000; + + public static string TruncateToolResult(string result) + { + return result.Length > MaxToolResultChars + ? result[..MaxToolResultChars] + $"\n\n[... gekürzt, {result.Length - MaxToolResultChars} weitere Zeichen ...]" + : result; + } +} + +/// +/// Token-Zähler für eine Chat-Session (Input, Output, Cache). +/// +public class TokenUsage +{ + public long InputTokens { get; set; } + public long OutputTokens { get; set; } + public long CacheCreationTokens { get; set; } + public long CacheReadTokens { get; set; } +} + +/// +/// Ergebnis einer Tool-Ausführung. +/// +public record ToolResult(string Id, string Result, bool IsError); diff --git a/wwwroot/css/base.css b/wwwroot/css/base.css index afa605c..822f579 100644 --- a/wwwroot/css/base.css +++ b/wwwroot/css/base.css @@ -93,7 +93,7 @@ button.icon-btn:hover { border-color: transparent; } -textarea, code, pre, .tool-input, .tool-diff, .tool-result, .fe-line-numbers { +textarea, code, pre, .tool-input, .tool-diff, .tool-result { font-family: var(--mono); font-size: var(--mono-size); } diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index f841fef..46a6806 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -266,26 +266,6 @@ overflow: auto; } -.tool-result-markdown { - white-space: normal; -} - -.tool-result-markdown p, -.tool-result-markdown ul, -.tool-result-markdown ol { - margin: 0 0 .4em 0; -} - -.tool-result-markdown p:last-child, -.tool-result-markdown ul:last-child, -.tool-result-markdown ol:last-child { - margin-bottom: 0; -} - -.tool-result-markdown li { - margin-left: 1.2em; -} - /* Todo-Liste */ .todo-list { diff --git a/wwwroot/css/dashboard.css b/wwwroot/css/dashboard.css index dca5198..9e50501 100644 --- a/wwwroot/css/dashboard.css +++ b/wwwroot/css/dashboard.css @@ -117,11 +117,6 @@ text-overflow: ellipsis; } -.card-desc { - display: -webkit-box; - overflow: hidden; -} - .badge { padding: var(--badge-padding); background: var(--f2-bg); diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index 0d96480..da35b3e 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -406,24 +406,6 @@ label.skill-item { border-color: var(--primary); } -/* ── Profile ── */ - -.profile-message { - padding: var(--badge-spacing) var(--box-spacing); - border-radius: var(--border-radius); - margin-top: var(--badge-spacing); -} - -.profile-message-success { - background: rgba(0, 180, 0, 0.15); - color: #0a0; -} - -.profile-message-error { - background: rgba(220, 0, 0, 0.15); - color: var(--dangerous); -} - .danger-btn { color: var(--dangerous); border-color: currentcolor; @@ -488,26 +470,6 @@ label.skill-item { transform: translateX(1em); } -/* ── Telegram Section ── */ - -.telegram-status-box { - padding: var(--box-padding); - background: var(--f3-bg); - border: var(--border-width) solid var(--f2-border); - border-radius: var(--border-radius); - display: flex; - flex-direction: column; - gap: var(--badge-spacing); -} - -.telegram-status-box .tg-error { - color: var(--dangerous); -} - -.telegram-actions { - margin-top: var(--badge-spacing); -} - /* ── Cronjob Status ── */ .form-status-box { @@ -649,34 +611,6 @@ label.skill-item { cursor: pointer; } -/* ── Login Link Modal ── */ - -.login-link-overlay { - position: fixed; - inset: 0; - background: rgb(from var(--f1-bg) r g b / var(--semi-transparent)); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.login-link-modal { - background: var(--f2-bg); - border: var(--border-width) solid var(--f2-border); - border-radius: var(--border-radius); - width: 90vw; - max-width: var(--modal-width); - padding: var(--box-padding); - display: flex; - flex-direction: column; - gap: var(--box-spacing); -} - -.login-link-modal h3 { - margin: 0; -} - .login-link-desc { opacity: var(--semi-transparent); margin: 0; diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 9263b06..ca8c91a 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -14,728 +14,250 @@ }; })(); -// ── Auth API ── +// ── Internal Fetch Helpers ── -async function apiLogin(username, password) { - const resp = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) - }); +var _jsonHeaders = { 'Content-Type': 'application/json' }; + +/** GET → JSON (returns fallback on error, default: throws) */ +async function _get(url, fallback) { + var resp = await fetch(url); if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Login failed'); + if (arguments.length >= 2) return fallback; + throw new Error('Fehler beim Laden'); } return resp.json(); } -async function apiLogout() { - await fetch('/api/auth/logout', { method: 'POST' }); -} - -async function apiMe() { - const resp = await fetch('/api/auth/me'); - if (!resp.ok) return null; +/** POST/PUT/DELETE with JSON body → JSON (throws on error) */ +async function _send(method, url, body, errMsg) { + var opts = { method: method }; + if (body !== undefined) { + opts.headers = _jsonHeaders; + opts.body = JSON.stringify(body); + } + var resp = await fetch(url, opts); + if (!resp.ok) { + var err = await resp.json().catch(function() { return {}; }); + throw new Error(err.error || errMsg || 'Anfrage fehlgeschlagen'); + } return resp.json(); } -// ── API Calls ── +/** POST with JSON body → JSON */ +function _post(url, body, errMsg) { return _send('POST', url, body, errMsg); } -async function apiChat(content, sessionId, signal, planMode) { - var payload = { content, sessionId }; +/** PUT with JSON body → JSON */ +function _put(url, body, errMsg) { return _send('PUT', url, body, errMsg); } + +/** DELETE → JSON */ +function _del(url, errMsg) { return _send('DELETE', url, undefined, errMsg); } + +// ── Auth API ── + +function apiLogin(u, p) { return _post('/api/auth/login', { username: u, password: p }, 'Login fehlgeschlagen'); } +function apiLogout() { return fetch('/api/auth/logout', { method: 'POST' }); } +function apiMe() { return _get('/api/auth/me', null); } + +// ── Chat API ── + +function apiChat(content, sessionId, signal, planMode) { + var payload = { content: content, sessionId: sessionId }; if (planMode) payload.planMode = true; - return fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal - }); + return fetch('/api/chat', { method: 'POST', headers: _jsonHeaders, body: JSON.stringify(payload), signal: signal }); } -async function apiChatChoice(sessionId, choiceToolCallId, choiceResult, signal, planMode) { - var payload = { sessionId, choiceToolCallId, choiceResult }; +function apiChatChoice(sessionId, choiceToolCallId, choiceResult, signal, planMode) { + var payload = { sessionId: sessionId, choiceToolCallId: choiceToolCallId, choiceResult: choiceResult }; if (planMode) payload.planMode = true; - return fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal - }); + return fetch('/api/chat', { method: 'POST', headers: _jsonHeaders, body: JSON.stringify(payload), signal: signal }); } +function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); } + // ── Agent API ── -async function apiGetAgents() { - const resp = await fetch('/api/agents'); - return resp.json(); -} - -async function apiGetAvailableAgents() { - const resp = await fetch('/api/agents/available'); - if (!resp.ok) return []; - return resp.json(); -} - -async function apiGetAgent(agentId) { - const resp = await fetch(`/api/agents/${agentId}`); - if (!resp.ok) return null; - return resp.json(); -} - -async function apiCreateAgent(data) { - const resp = await fetch('/api/agents', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - return resp.json(); -} - -async function apiUpdateAgent(agentId, data) { - const resp = await fetch(`/api/agents/${agentId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - return resp.json(); -} - -async function apiDeleteAgent(agentId) { - return fetch(`/api/agents/${agentId}`, { method: 'DELETE' }); -} - -async function apiHistory(sessionId, beforeSortKey, includeDeleted = false) { - let url = `/api/history?sessionId=${sessionId}`; - if (beforeSortKey) url += `&beforeSortKey=${beforeSortKey}`; - if (includeDeleted) url += `&includeDeleted=true`; - const resp = await fetch(url); - return resp.json(); -} - -async function apiHistoryPoll(sessionId, afterSortKey, includeDeleted = false) { - let url = `/api/history/poll?sessionId=${sessionId}&afterSortKey=${afterSortKey}`; - if (includeDeleted) url += `&includeDeleted=true`; - const resp = await fetch(url); - return resp.json(); -} - -async function apiArchive(sessionId) { - const resp = await fetch(`/api/archive?sessionId=${sessionId}`, { method: 'POST' }); - if (resp.status === 204) return null; - return resp.json(); -} - -async function apiListArchives(sessionId) { - const resp = await fetch(`/api/archives?sessionId=${sessionId}`); - return resp.json(); -} - -async function apiGetArchive(archiveId) { - const resp = await fetch(`/api/archive/${archiveId}`); - return resp.json(); -} +function apiGetAgents() { return _get('/api/agents'); } +function apiGetAvailableAgents() { return _get('/api/agents/available', []); } +function apiGetAgent(id) { return _get('/api/agents/' + id, null); } +function apiCreateAgent(data) { return _post('/api/agents', data); } +function apiUpdateAgent(id, data) { return _put('/api/agents/' + id, data); } +function apiDeleteAgent(id) { return fetch('/api/agents/' + id, { method: 'DELETE' }); } // ── Session API ── -async function apiGetSessions() { - const resp = await fetch('/api/sessions'); - return resp.json(); -} +function apiGetSessions() { return _get('/api/sessions'); } +function apiGetSession(id) { return _get('/api/sessions/' + id, null); } +function apiCreateSession(data) { return _post('/api/sessions', data || {}); } +function apiUpdateSession(id, data) { return _put('/api/sessions/' + id, data); } +function apiDeleteSession(id) { return fetch('/api/sessions/' + id, { method: 'DELETE' }); } -async function apiCreateSession(data = {}) { - const resp = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) +function apiExecCommand(sessionId, command, mode) { + mode = mode || 'background'; + var resp = fetch('/api/sessions/' + sessionId + '/exec', { + method: 'POST', headers: _jsonHeaders, body: JSON.stringify({ command: command, mode: mode }) }); - return resp.json(); + return mode === 'foreground' ? resp.then(function(r) { return r.json(); }) : resp; } -async function apiGetSession(sessionId) { - const resp = await fetch(`/api/sessions/${sessionId}`); - if (!resp.ok) return null; - return resp.json(); -} - -async function apiDeleteSession(sessionId) { - return fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' }); -} - -async function apiUpdateSession(sessionId, data) { - const resp = await fetch(`/api/sessions/${sessionId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) +function apiGetSystemPrompt(sessionId) { + return fetch('/api/sessions/' + sessionId + '/system-prompt').then(function(r) { + if (!r.ok) throw new Error('System-Prompt konnte nicht geladen werden'); + return r.text(); }); - return resp.json(); } -async function apiExecCommand(sessionId, command, mode = 'background') { - const resp = await fetch(`/api/sessions/${sessionId}/exec`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command, mode }) +function apiGetSystemPromptBlocks(sessionId, planMode) { + var url = '/api/sessions/' + sessionId + '/system-prompt-blocks' + (planMode ? '?planMode=true' : ''); + return _get(url); +} + +function apiIsStreaming(sessionId) { return _get('/api/sessions/' + sessionId + '/streaming'); } +function apiGitStatus(sessionId) { return _get('/api/sessions/' + sessionId + '/git-status', null); } + +// ── History API ── + +function apiHistory(sessionId, beforeSortKey, includeDeleted) { + var url = '/api/history?sessionId=' + sessionId; + if (beforeSortKey) url += '&beforeSortKey=' + beforeSortKey; + if (includeDeleted) url += '&includeDeleted=true'; + return _get(url); +} + +function apiHistoryPoll(sessionId, afterSortKey, includeDeleted) { + var url = '/api/history/poll?sessionId=' + sessionId + '&afterSortKey=' + afterSortKey; + if (includeDeleted) url += '&includeDeleted=true'; + return _get(url); +} + +// ── Archive API ── + +function apiArchive(sessionId) { + return fetch('/api/archive?sessionId=' + sessionId, { method: 'POST' }).then(function(r) { + return r.status === 204 ? null : r.json(); }); - if (mode === 'foreground') return resp.json(); - return resp; } -async function apiGetSystemPrompt(sessionId) { - const resp = await fetch(`/api/sessions/${sessionId}/system-prompt`); - if (!resp.ok) throw new Error('System-Prompt konnte nicht geladen werden'); - return resp.text(); -} +function apiListArchives(sessionId) { return _get('/api/archives?sessionId=' + sessionId); } +function apiGetArchive(id) { return _get('/api/archive/' + id); } -async function apiGetSystemPromptBlocks(sessionId, planMode) { - const url = `/api/sessions/${sessionId}/system-prompt-blocks` + (planMode ? '?planMode=true' : ''); - const resp = await fetch(url); - if (!resp.ok) throw new Error('System-Prompt konnte nicht geladen werden'); - return resp.json(); -} +// ── Chain / Message API ── -async function apiIsStreaming(sessionId) { - const resp = await fetch(`/api/sessions/${sessionId}/streaming`); - return resp.json(); -} - -async function apiStopChat(sessionId) { - const resp = await fetch('/api/chat/stop', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }) - }); - return resp.json(); -} - -async function apiGetChain(chainId) { - const resp = await fetch(`/api/chains/${chainId}`); - if (!resp.ok) return null; - return resp.json(); -} - -async function apiToggleMessageMark(messageId) { - const resp = await fetch(`/api/messages/${messageId}/mark`, { method: 'PUT' }); - return resp.json(); -} - -async function apiToggleMessageDelete(messageId) { - const resp = await fetch(`/api/messages/${messageId}/delete`, { method: 'PUT' }); - return resp.json(); -} - -async function apiGitStatus(sessionId) { - const resp = await fetch(`/api/sessions/${sessionId}/git-status`); - if (!resp.ok) return null; - return resp.json(); -} +function apiGetChain(id) { return _get('/api/chains/' + id, null); } +function apiToggleMessageMark(id) { return _put('/api/messages/' + id + '/mark'); } +function apiToggleMessageDelete(id) { return _put('/api/messages/' + id + '/delete'); } // ── Skills API ── -async function apiGetSkills() { - const resp = await fetch('/api/skills'); - return resp.json(); -} - -async function apiGetAgentSkills(agentId) { - const resp = await fetch(`/api/agents/${agentId}/skills`); - return resp.json(); -} - -async function apiSetAgentSkills(agentId, skills) { - const resp = await fetch(`/api/agents/${agentId}/skills`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ skills }) - }); - return resp.json(); -} - -async function apiAddAgentSkill(agentId, slug) { - return fetch(`/api/agents/${agentId}/skills/${slug}`, { method: 'POST' }); -} - -async function apiRemoveAgentSkill(agentId, slug) { - return fetch(`/api/agents/${agentId}/skills/${slug}`, { method: 'DELETE' }); -} +function apiGetSkills() { return _get('/api/skills'); } +function apiGetAgentSkills(agentId) { return _get('/api/agents/' + agentId + '/skills'); } +function apiSetAgentSkills(agentId, skills) { return _put('/api/agents/' + agentId + '/skills', { skills: skills }); } +function apiAddAgentSkill(agentId, slug) { return fetch('/api/agents/' + agentId + '/skills/' + slug, { method: 'POST' }); } +function apiRemoveAgentSkill(agentId, slug) { return fetch('/api/agents/' + agentId + '/skills/' + slug, { method: 'DELETE' }); } // ── Profile API ── -async function apiGetProfile() { - const resp = await fetch('/api/profile'); - if (!resp.ok) return null; - return resp.json(); -} - -async function apiUpdateProfile(data) { - const resp = await fetch('/api/profile', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Speichern'); - } - return resp.json(); -} - -async function apiChangePassword(currentPassword, newPassword) { - const resp = await fetch('/api/profile/password', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ currentPassword, newPassword }) - }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Ändern'); - } - return resp.json(); -} +function apiGetProfile() { return _get('/api/profile', null); } +function apiUpdateProfile(data) { return _put('/api/profile', data, 'Fehler beim Speichern'); } +function apiChangePassword(cur, pw) { return _put('/api/profile/password', { currentPassword: cur, newPassword: pw }, 'Fehler beim Ändern'); } // ── Telegram Bot API ── -async function apiGetTelegramBots() { - const resp = await fetch('/api/telegram/bots'); - return resp.json(); -} - -async function apiGetTelegramBot(botId) { - const resp = await fetch(`/api/telegram/bots/${botId}`); - if (!resp.ok) return null; - return resp.json(); -} - -async function apiCreateTelegramBot(name, botToken) { - const resp = await fetch('/api/telegram/bots', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, botToken }) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Bot erstellen fehlgeschlagen'); - } - return resp.json(); -} - -async function apiGetAvailableTelegramBots() { - const resp = await fetch('/api/telegram/bots/available'); - return resp.json(); -} - -async function apiUpdateTelegramBot(botId, name, botToken, isPublic) { - const resp = await fetch(`/api/telegram/bots/${botId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, botToken, isPublic }) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Bot speichern fehlgeschlagen'); - } - return resp.json(); -} - -async function apiDeleteTelegramBot(botId) { - const resp = await fetch(`/api/telegram/bots/${botId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Bot löschen fehlgeschlagen'); - } - return resp.json(); -} - -async function apiTelegramBotRegisterWebhook(botId, baseUrl) { - const resp = await fetch(`/api/telegram/bots/${botId}/register-webhook`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ baseUrl }) - }); - if (!resp.ok) throw new Error('Webhook-Registrierung fehlgeschlagen'); - return resp.json(); -} - -async function apiTelegramBotWebhookInfo(botId) { - const resp = await fetch(`/api/telegram/bots/${botId}/webhook-info`); - if (!resp.ok) throw new Error('Webhook-Info fehlgeschlagen'); - return resp.json(); -} - -async function apiTelegramBotDeleteWebhook(botId) { - const resp = await fetch(`/api/telegram/bots/${botId}/webhook`, { method: 'DELETE' }); - if (!resp.ok) throw new Error('Webhook-Löschung fehlgeschlagen'); - return resp.json(); -} +function apiGetTelegramBots() { return _get('/api/telegram/bots'); } +function apiGetTelegramBot(id) { return _get('/api/telegram/bots/' + id, null); } +function apiGetAvailableTelegramBots() { return _get('/api/telegram/bots/available'); } +function apiCreateTelegramBot(name, token) { return _post('/api/telegram/bots', { name: name, botToken: token }, 'Bot erstellen fehlgeschlagen'); } +function apiUpdateTelegramBot(id, name, token, isPublic) { return _put('/api/telegram/bots/' + id, { name: name, botToken: token, isPublic: isPublic }, 'Bot speichern fehlgeschlagen'); } +function apiDeleteTelegramBot(id) { return _del('/api/telegram/bots/' + id, 'Bot löschen fehlgeschlagen'); } +function apiTelegramBotRegisterWebhook(id, baseUrl) { return _post('/api/telegram/bots/' + id + '/register-webhook', { baseUrl: baseUrl }, 'Webhook-Registrierung fehlgeschlagen'); } +function apiTelegramBotWebhookInfo(id) { return _get('/api/telegram/bots/' + id + '/webhook-info'); } +function apiTelegramBotDeleteWebhook(id) { return _del('/api/telegram/bots/' + id + '/webhook', 'Webhook-Löschung fehlgeschlagen'); } // ── Session Telegram Config ── -async function apiSessionTelegramGet(sessionId) { - const resp = await fetch(`/api/sessions/${sessionId}/telegram`); - if (!resp.ok) return { configured: false }; - return resp.json(); -} +function apiSessionTelegramGet(sessionId) { return _get('/api/sessions/' + sessionId + '/telegram', { configured: false }); } +function apiSessionTelegramSave(sessionId, botId, chatId) { return _put('/api/sessions/' + sessionId + '/telegram', { telegramBotId: botId, chatId: chatId }, 'Telegram-Config speichern fehlgeschlagen'); } -async function apiSessionTelegramSave(sessionId, telegramBotId, chatId) { - const resp = await fetch(`/api/sessions/${sessionId}/telegram`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ telegramBotId, chatId }) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Telegram-Config speichern fehlgeschlagen'); - } - return resp.json(); -} +// ── Settings API ── -async function apiGetSettings() { - const resp = await fetch('/api/settings'); - if (!resp.ok) return {}; - return resp.json(); -} - -async function apiSaveSettings(settings) { - const resp = await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings) - }); - return resp.json(); -} +function apiGetSettings() { return _get('/api/settings', {}); } +function apiSaveSettings(settings) { return _put('/api/settings', settings); } // ── Cronjob API ── -async function apiGetCronjobs() { - const resp = await fetch('/api/cronjobs'); - return resp.json(); -} - -async function apiGetCronjob(id) { - const resp = await fetch(`/api/cronjobs/${id}`); - if (!resp.ok) return null; - return resp.json(); -} - -async function apiCreateCronjob(data) { - const resp = await fetch('/api/cronjobs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Cronjob erstellen fehlgeschlagen'); - } - return resp.json(); -} - -async function apiUpdateCronjob(id, data) { - const resp = await fetch(`/api/cronjobs/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Cronjob speichern fehlgeschlagen'); - } - return resp.json(); -} - -async function apiDeleteCronjob(id) { - return fetch(`/api/cronjobs/${id}`, { method: 'DELETE' }); -} - -async function apiToggleCronjob(id) { - const resp = await fetch(`/api/cronjobs/${id}/toggle`, { method: 'POST' }); - return resp.json(); -} - -async function apiRunCronjob(id) { - const resp = await fetch(`/api/cronjobs/${id}/run`, { method: 'POST' }); - return resp.json(); -} +function apiGetCronjobs() { return _get('/api/cronjobs'); } +function apiGetCronjob(id) { return _get('/api/cronjobs/' + id, null); } +function apiCreateCronjob(data) { return _post('/api/cronjobs', data, 'Cronjob erstellen fehlgeschlagen'); } +function apiUpdateCronjob(id, data) { return _put('/api/cronjobs/' + id, data, 'Cronjob speichern fehlgeschlagen'); } +function apiDeleteCronjob(id) { return fetch('/api/cronjobs/' + id, { method: 'DELETE' }); } +function apiToggleCronjob(id) { return _post('/api/cronjobs/' + id + '/toggle'); } +function apiRunCronjob(id) { return _post('/api/cronjobs/' + id + '/run'); } // ── Shared Message API ── -async function apiListShares() { - const resp = await fetch('/api/shared'); - return resp.json(); -} +function apiListShares() { return _get('/api/shared'); } +function apiShareMessage(id, includeContext) { return _post('/api/messages/' + id + '/share', { includeContext: includeContext || false }, 'Teilen fehlgeschlagen'); } +function apiGetShared(token) { return _get('/api/shared/' + token); } +function apiDeleteShare(token) { return fetch('/api/shared/' + token, { method: 'DELETE' }); } -async function apiShareMessage(messageId, includeContext = false) { - const resp = await fetch(`/api/messages/${messageId}/share`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ includeContext }) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Teilen fehlgeschlagen'); - } - return resp.json(); -} +// ── Admin User API ── -async function apiGetShared(token) { - const resp = await fetch(`/api/shared/${token}`); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Geteilte Nachricht nicht gefunden'); - } - return resp.json(); -} - -async function apiDeleteShare(token) { - return fetch(`/api/shared/${token}`, { method: 'DELETE' }); -} - -// ── Admin API ── - -async function apiAdminGetUsers() { - const resp = await fetch('/api/admin/users'); - if (!resp.ok) throw new Error('Fehler beim Laden der Benutzer'); - return resp.json(); -} - -async function apiAdminCreateUser(data) { - const resp = await fetch('/api/admin/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Erstellen'); - } - return resp.json(); -} - -async function apiAdminUpdateUser(userId, data) { - const resp = await fetch('/api/admin/users/' + userId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Aktualisieren'); - } - return resp.json(); -} - -async function apiAdminResetPassword(userId, password) { - const resp = await fetch('/api/admin/users/' + userId + '/password', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }) - }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Zurücksetzen'); - } - return resp.json(); -} - -async function apiAdminDeleteUser(userId) { - const resp = await fetch('/api/admin/users/' + userId, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Löschen'); - } - return resp.json(); -} - -async function apiAdminImpersonate(userId) { - const resp = await fetch('/api/admin/users/' + userId + '/impersonate', { method: 'POST' }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Anmelden'); - } - return resp.json(); -} - -async function apiAdminGetLoginLink(userId) { - const resp = await fetch('/api/admin/users/' + userId + '/login-link', { method: 'POST' }); - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.error || 'Fehler beim Erstellen des Login-Links'); - } - return resp.json(); -} +function apiAdminGetUsers() { return _get('/api/admin/users'); } +function apiAdminCreateUser(data) { return _post('/api/admin/users', data, 'Fehler beim Erstellen'); } +function apiAdminUpdateUser(id, data) { return _put('/api/admin/users/' + id, data, 'Fehler beim Aktualisieren'); } +function apiAdminResetPassword(id, pw) { return _put('/api/admin/users/' + id + '/password', { password: pw }, 'Fehler beim Zurücksetzen'); } +function apiAdminDeleteUser(id) { return _del('/api/admin/users/' + id, 'Fehler beim Löschen'); } +function apiAdminImpersonate(id) { return _post('/api/admin/users/' + id + '/impersonate', undefined, 'Fehler beim Anmelden'); } +function apiAdminGetLoginLink(id) { return _post('/api/admin/users/' + id + '/login-link', undefined, 'Fehler beim Erstellen des Login-Links'); } // ── Server Settings API (Admin) ── -async function apiGetServerSettings() { - const resp = await fetch('/api/admin/server-settings'); - if (!resp.ok) throw new Error('Fehler beim Laden der Servereinstellungen'); - return resp.json(); -} - -async function apiSaveServerSettings(data) { - const resp = await fetch('/api/admin/server-settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Speichern'); - } - return resp.json(); -} +function apiGetServerSettings() { return _get('/api/admin/server-settings'); } +function apiSaveServerSettings(data) { return _put('/api/admin/server-settings', data, 'Fehler beim Speichern'); } // ── API Keys (Admin) ── -async function apiGetApiKeys() { - const resp = await fetch('/api/admin/api-keys'); - if (!resp.ok) throw new Error('Fehler beim Laden der API-Keys'); - return resp.json(); -} - -async function apiCreateApiKey(data) { - const resp = await fetch('/api/admin/api-keys', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Erstellen'); - } - return resp.json(); -} - -async function apiUpdateApiKey(keyId, data) { - const resp = await fetch('/api/admin/api-keys/' + keyId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Aktualisieren'); - } - return resp.json(); -} - -async function apiActivateApiKey(keyId) { - const resp = await fetch('/api/admin/api-keys/' + keyId + '/activate', { method: 'PUT' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Aktivieren'); - } - return resp.json(); -} - -async function apiDeactivateApiKey(keyId) { - const resp = await fetch('/api/admin/api-keys/' + keyId + '/deactivate', { method: 'PUT' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Deaktivieren'); - } - return resp.json(); -} - -async function apiDeleteApiKey(keyId) { - const resp = await fetch('/api/admin/api-keys/' + keyId, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Löschen'); - } - return resp.json(); -} - -async function apiCheckApiKey(keyId) { - const resp = await fetch('/api/admin/api-keys/' + keyId + '/check', { method: 'POST' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Fehler beim Prüfen'); - } - return resp.json(); -} +function apiGetApiKeys() { return _get('/api/admin/api-keys'); } +function apiCreateApiKey(data) { return _post('/api/admin/api-keys', data, 'Fehler beim Erstellen'); } +function apiUpdateApiKey(id, data) { return _put('/api/admin/api-keys/' + id, data, 'Fehler beim Aktualisieren'); } +function apiActivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/activate', undefined, 'Fehler beim Aktivieren'); } +function apiDeactivateApiKey(id) { return _put('/api/admin/api-keys/' + id + '/deactivate', undefined, 'Fehler beim Deaktivieren'); } +function apiDeleteApiKey(id) { return _del('/api/admin/api-keys/' + id, 'Fehler beim Löschen'); } +function apiCheckApiKey(id) { return _post('/api/admin/api-keys/' + id + '/check', undefined, 'Fehler beim Prüfen'); } // ── Krea Image API ── -async function apiKreaStatus() { - const resp = await fetch('/api/images/status'); - if (!resp.ok) return { configured: false }; - return resp.json(); -} - -async function apiKreaGenerate(prompt, model, width, height) { - const resp = await fetch('/api/images/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, model: model || 'flux', width: width || 512, height: height || 512, batchSize: 1 }) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Bildgenerierung fehlgeschlagen'); - } - return resp.json(); +function apiKreaStatus() { return _get('/api/images/status', { configured: false }); } +function apiKreaGenerate(prompt, model, width, height) { + return _post('/api/images/generate', { + prompt: prompt, model: model || 'flux', width: width || 512, height: height || 512, batchSize: 1 + }, 'Bildgenerierung fehlgeschlagen'); } // ── OpenClaw Import API ── -async function apiGetOpenclawSessions() { - const resp = await fetch('/api/openclaw/sessions'); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'OpenClaw-Sessions konnten nicht geladen werden'); - } - return resp.json(); -} - -async function apiImportOpenclawSession(openclawSessionId, sessionId) { - const resp = await fetch('/api/openclaw/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ openclawSessionId, sessionId }) - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Import fehlgeschlagen'); - } - return resp.json(); +function apiGetOpenclawSessions() { return _get('/api/openclaw/sessions'); } +function apiImportOpenclawSession(openclawSessionId, sessionId) { + return _post('/api/openclaw/import', { openclawSessionId: openclawSessionId, sessionId: sessionId }, 'Import fehlgeschlagen'); } // ── Remote API ── -async function apiGetRemoteConnections() { - const resp = await fetch('/api/remote/connections'); - return resp.json(); -} - -async function apiAssignRemote(connectionId, sessionId) { - const resp = await fetch('/api/remote/assign', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connectionId, sessionId }) - }); - return resp.json(); -} - -async function apiUnassignRemote(connectionId) { - const resp = await fetch('/api/remote/unassign', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connectionId }) - }); - return resp.json(); -} +function apiGetRemoteConnections() { return _get('/api/remote/connections', []); } +function apiAssignRemote(connectionId, sessionId) { return _post('/api/remote/assign', { connectionId: connectionId, sessionId: sessionId }); } +function apiUnassignRemote(connectionId) { return _post('/api/remote/unassign', { connectionId: connectionId }); } // ── Backup API ── async function apiBackupDb() { - const resp = await fetch('/api/backup', { method: 'POST', credentials: 'include' }); + var resp = await fetch('/api/backup', { method: 'POST', credentials: 'include' }); if (!resp.ok) throw new Error('Backup fehlgeschlagen'); - const blob = await resp.blob(); - const cd = resp.headers.get('content-disposition') || ''; - const filename = cd.match(/filename\*=UTF-8''(.+?)(?:;|$)/i)?.[1] + var blob = await resp.blob(); + var cd = resp.headers.get('content-disposition') || ''; + var filename = cd.match(/filename\*=UTF-8''(.+?)(?:;|$)/i)?.[1] || cd.match(/filename="?([^";]+)"?/)?.[1] || 'gott-backup.tar.gz'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a);