Refactoring + Dokumentation: api.js komprimiert (745→267 Zeilen), SseHelper extrahiert, DatabaseService in Partial Classes aufgeteilt, unbenutztes CSS entfernt, ARCHITECTURE.md/ARCHITECTURE_QUICK.md/README.md aktualisiert (neue Features: Embedding, Remote, API-Keys, 16 Tools)
This commit is contained in:
1 parent
8dc52c1337
commit
db1a7c7f64
18 files changed
+1701
-2007
No files matched your search
+171
-21
@@ -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/<OS>/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
|
||||
|
||||
+35
-10
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<object> Content, List<ToolResult> 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<object>();
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
}
|
||||
@@ -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<object> 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<object>
|
||||
{
|
||||
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 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<AgentRecord> 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<IEnumerable<AgentRecord>> GetAgents(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<AgentRecord>(
|
||||
"SELECT * FROM agents WHERE user_id = @userId AND is_deleted = 0 ORDER BY name", new { userId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AgentRecord>> GetAvailableAgents(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<AgentRecord>(
|
||||
"SELECT * FROM agents WHERE (user_id = @userId OR is_public = 1) AND is_deleted = 0 ORDER BY name", new { userId });
|
||||
}
|
||||
|
||||
public async Task<AgentRecord> GetAgent(string agentId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<AgentRecord>(
|
||||
"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<IEnumerable<string>> GetAgentSkills(string agentId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<string>(
|
||||
"SELECT skill_slug FROM agent_skills WHERE agent_id = @agentId ORDER BY skill_slug",
|
||||
new { agentId });
|
||||
}
|
||||
|
||||
public async Task SetAgentSkills(string agentId, IEnumerable<string> 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 });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<string>(@"
|
||||
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<dynamic>(
|
||||
"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<dynamic>(
|
||||
"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<IEnumerable<TelegramBotRecord>> GetTelegramBots(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<TelegramBotRecord>(
|
||||
"SELECT * FROM telegram_bots WHERE user_id = @userId ORDER BY created_at DESC",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TelegramBotRecord>> GetAvailableTelegramBots(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<TelegramBotRecord>(
|
||||
"SELECT * FROM telegram_bots WHERE user_id = @userId OR is_public = 1 ORDER BY created_at DESC",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task<TelegramBotRecord> GetTelegramBot(string botId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<TelegramBotRecord>(
|
||||
"SELECT * FROM telegram_bots WHERE bot_id = @botId",
|
||||
new { botId });
|
||||
}
|
||||
|
||||
public async Task<TelegramBotRecord> 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<int> GetTelegramBotSessionCount(string botId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"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<IEnumerable<SessionRecord>> GetSessionsByTelegramBot(string botId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<SessionRecord>(
|
||||
"SELECT * FROM sessions WHERE telegram_bot_id = @botId AND is_deleted = 0",
|
||||
new { botId });
|
||||
}
|
||||
|
||||
public async Task<SessionRecord> FindSessionForTelegramChat(string botId, long chatId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var chatIdStr = chatId.ToString();
|
||||
return await conn.QuerySingleOrDefaultAsync<SessionRecord>(
|
||||
"SELECT * FROM sessions WHERE telegram_bot_id = @botId AND telegram_chat_id = @chatIdStr AND is_deleted = 0",
|
||||
new { botId, chatIdStr });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SessionRecord>> FindUnlinkedSessionsForBot(string botId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<SessionRecord>(
|
||||
"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<CronjobRecord> 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<IEnumerable<CronjobRecord>> GetCronjobs(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<CronjobRecord>(@"
|
||||
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<CronjobRecord> GetCronjob(string cronjobId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<CronjobRecord>(@"
|
||||
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<IEnumerable<CronjobRecord>> GetDueCronjobs()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
return await conn.QueryAsync<CronjobRecord>(@"
|
||||
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<int>("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<IEnumerable<ApiKeyRecord>> GetApiKeys()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys ORDER BY provider, name");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ApiKeyRecord>> GetApiKeysByProvider(string provider)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE provider = @provider ORDER BY name",
|
||||
new { provider });
|
||||
}
|
||||
|
||||
public async Task<ApiKeyRecord> GetActiveApiKey(string provider)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||||
"SELECT * FROM api_keys WHERE provider = @provider AND is_active = 1 LIMIT 1",
|
||||
new { provider });
|
||||
}
|
||||
|
||||
public async Task<ApiKeyRecord> GetApiKey(string keyId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ApiKeyRecord>(
|
||||
"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 });
|
||||
}
|
||||
}
|
||||
@@ -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<SessionRecord> 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<SessionRecord> GetSessionByName(string name)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var sessions = await conn.QueryAsync<SessionRecord>(
|
||||
"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<List<SessionWithStats>> GetSessionsWithStats(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var sessions = await conn.QueryAsync<SessionRecord>(
|
||||
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC", new { userId });
|
||||
|
||||
var results = new List<SessionWithStats>();
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
var activeCount = await conn.ExecuteScalarAsync<int>(@"
|
||||
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<int>(@"
|
||||
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<SessionRecord> GetSession(string sessionId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<SessionRecord>(
|
||||
"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<string> { "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<ArchiveRecord> CreateArchive(string sessionId, string name = null)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
|
||||
var chainCount = await conn.ExecuteScalarAsync<int>(
|
||||
"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<int>(@"
|
||||
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<IEnumerable<ArchiveRecord>> GetArchives(string sessionId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ArchiveRecord>(
|
||||
"SELECT * FROM archives WHERE session_id = @sessionId ORDER BY id DESC",
|
||||
new { sessionId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChainRecord>> GetArchiveChains(string archiveId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ChainRecord>(
|
||||
"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<ChainRecord> GetChain(string chainId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ChainRecord>(
|
||||
"SELECT * FROM chains WHERE chain_id = @chainId", new { chainId }
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChainRecord>> GetActiveChains(string sessionId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<ChainRecord>(
|
||||
"SELECT * FROM chains WHERE session_id = @sessionId AND archive_id IS NULL ORDER BY id DESC",
|
||||
new { sessionId });
|
||||
}
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
public async Task<long> SaveMessage(MessageRecord record)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
record.CreatedAt = DateTime.UtcNow.ToString("o");
|
||||
return await conn.ExecuteScalarAsync<long>(@"
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Speichert Message + Attachments in einer Transaktion (1x fsync statt N+1).
|
||||
/// </summary>
|
||||
public async Task<long> SaveMessageWithAttachments(MessageRecord record, List<AttachmentRecord> 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<long>(@"
|
||||
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<IEnumerable<AttachmentRecord>> GetAttachments(long messageId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<AttachmentRecord>(
|
||||
"SELECT * FROM attachments WHERE message_id = @messageId ORDER BY id",
|
||||
new { messageId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AttachmentRecord>> GetAttachmentsForMessages(IEnumerable<long> messageIds)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<AttachmentRecord>(
|
||||
"SELECT * FROM attachments WHERE message_id IN @messageIds ORDER BY id",
|
||||
new { messageIds });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MessageRecord>> GetChainMessages(string chainId, bool includeDeleted = false)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var deletedFilter = includeDeleted ? "" : " AND deleted_on IS NULL";
|
||||
return await conn.QueryAsync<MessageRecord>(
|
||||
$"SELECT * FROM messages WHERE chain_id = @chainId{deletedFilter} ORDER BY id", new { chainId }
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<int> GetActiveMessageCount(string sessionId, bool includeDeleted = false)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var deletedFilter = includeDeleted ? "" : "AND m.deleted_on IS NULL";
|
||||
return await conn.ExecuteScalarAsync<int>($@"
|
||||
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<List<MessageRecord>> 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<MessageRecord>($@"
|
||||
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<int> 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<int>($@"
|
||||
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<List<MessageRecord>> 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<MessageRecord>($@"
|
||||
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<IEnumerable<MessageRecord>> 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<MessageRecord>($@"
|
||||
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<bool> 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<bool>(
|
||||
"SELECT is_marked FROM messages WHERE id = @messageId",
|
||||
new { messageId });
|
||||
}
|
||||
|
||||
// ── Message Deletion ──
|
||||
|
||||
public async Task<string> ToggleMessageDelete(long messageId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var current = await conn.ExecuteScalarAsync<string>(
|
||||
"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<IEnumerable<MessageRecord>> GetMarkedMessages(string sessionId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<MessageRecord>(@"
|
||||
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 });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<SharedMessageRecord> 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<SharedMessageRecord> GetSharedMessage(string shareToken)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<SharedMessageRecord>(
|
||||
"SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SharedMessageRecord>> GetSharedMessagesByUser(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<SharedMessageRecord>(
|
||||
"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<MessageRecord> GetMessage(long messageId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<MessageRecord>(
|
||||
"SELECT * FROM messages WHERE id = @messageId", new { messageId });
|
||||
}
|
||||
|
||||
public async Task<List<MessageRecord>> GetChainMessagesUpTo(string chainId, long messageId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var result = await conn.QueryAsync<MessageRecord>(
|
||||
"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<int>("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<string>("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<UserRecord> GetUserByUsername(string username)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<UserRecord>(
|
||||
"SELECT * FROM users WHERE username = @username COLLATE NOCASE",
|
||||
new { username });
|
||||
}
|
||||
|
||||
public async Task<UserRecord> GetUser(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<UserRecord>(
|
||||
"SELECT * FROM users WHERE user_id = @userId",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UserRecord>> GetUsers()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<UserRecord>(
|
||||
"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<string> GetUserSettings(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<string>(
|
||||
"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<UserRecord> 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 });
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1119
File diff suppressed because it is too large.
Load diff
+18
-35
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Shared SSE-Streaming-Utilities für AnthropicService und OpenAiService.
|
||||
/// </summary>
|
||||
public static class SseHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt ein SSE-Event (wirft bei CancellationToken).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Versucht ein SSE-Event zu schreiben, ignoriert Fehler (Client disconnected).
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalisiert eine Chain in der Datenbank (Token-Counts, Stopgrund, Dauer).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kürzt Tool-Results auf max. Zeichenzahl.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token-Zähler für eine Chat-Session (Input, Output, Cache).
|
||||
/// </summary>
|
||||
public class TokenUsage
|
||||
{
|
||||
public long InputTokens { get; set; }
|
||||
public long OutputTokens { get; set; }
|
||||
public long CacheCreationTokens { get; set; }
|
||||
public long CacheReadTokens { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis einer Tool-Ausführung.
|
||||
/// </summary>
|
||||
public record ToolResult(string Id, string Result, bool IsError);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -117,11 +117,6 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: var(--badge-padding);
|
||||
background: var(--f2-bg);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+165
-643
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user