diff --git a/Datenbank-Schema-%28KI-%C3%BCberpr%C3%BCfung-n%C3%B6tig%29.md b/Datenbank-Schema-%28KI-%C3%BCberpr%C3%BCfung-n%C3%B6tig%29.md new file mode 100644 index 0000000..5e4a0cb --- /dev/null +++ b/Datenbank-Schema-%28KI-%C3%BCberpr%C3%BCfung-n%C3%B6tig%29.md @@ -0,0 +1,491 @@ +# Datenbank + +Die App verwendet **SQLite** (über `better-sqlite3`). Das Schema wird an drei Stellen aufgebaut: + +| Datei | Funktion | Aufgabe | +|---|---|---| +| `src/db/schema.js` | `initializeSchema(db)` | Legt die Grundtabellen an | +| `src/db/schema.js` | `runMigrations(db, { dropLegacyChats })` | Ergänzt fehlende Spalten/Tabellen in bestehenden Datenbanken, migriert Altdaten | +| `src/db/attachments.js` | `initializeAttachments(db)` | Tabellen und Trigger für Bild-Uploads | + +**Wichtig:** Das vollständige Schema ergibt sich erst aus allen drei Funktionen zusammen. +Die Tabelle `chat_message_versions` und die Spalten `chat_messages.sources` und +`chat_messages.thinking` existieren z. B. nur in `runMigrations`. + +## Allgemeine Konventionen + +- **`PRAGMA foreign_keys = ON`**: SQLite prüft Fremdschlüssel nur mit dieser Einstellung. + Sie muss bei jeder Verbindung gesetzt werden. +- **`PRAGMA journal_mode = WAL`**: Erlaubt gleichzeitiges Lesen während geschrieben wird. +- **Booleans** werden als `INTEGER` gespeichert (`0` = false, `1` = true), + z. B. `is_pinned`, `is_shared`. +- **JSON-Daten** werden als `TEXT` gespeichert (z. B. `retry_variants`, `sources`, + `custom_fields`, `tags`, `snapshot_json`) und im Code mit `JSON.parse` gelesen. +- **Zeitstempel** werden mit `DEFAULT CURRENT_TIMESTAMP` in UTC gesetzt. +- Spaltennamen in der DB sind `snake_case`, im JavaScript-Code `camelCase` + (siehe z. B. `attachmentMetadata`). + +## Übersicht der Bereiche + +| Bereich | Tabellen | +|---|---| +| Benutzer | `users`, `user_settings` | +| Chats | `chat_sessions`, `chat_messages`, `chat_message_versions`, `chat_memories`, `persona_chat_state` | +| Personas | `personas`, `persona_versions`, `prompt_templates` | +| Marktplatz | `persona_market`, `persona_market_favorites`, `persona_market_ratings`, `persona_market_reports` | +| Anhänge | `attachments`, `message_attachments`, `attachment_vision_analyses`, `upload_deletions` | + +## Beziehungsdiagramm + +Legende: **durchgezogene Linie** = echter Fremdschlüssel (von SQLite geprüft), +**gestrichelte Linie** = nur logische Beziehung (Spalte verweist auf eine andere Tabelle, +aber ohne `FOREIGN KEY`, wird also nicht von der DB geprüft). + +```mermaid +erDiagram + users { + TEXT username PK + } + user_settings { + TEXT username PK, FK + INTEGER active_persona_id FK + INTEGER active_user_persona_id FK + } + chat_sessions { + INTEGER id PK + TEXT username + INTEGER assistant_persona_id FK + INTEGER user_persona_id FK + INTEGER source_chat_id FK + INTEGER branched_from_message_id FK + INTEGER active_leaf_message_id + } + chat_messages { + INTEGER id PK + INTEGER chat_id FK + INTEGER parent_message_id FK + INTEGER retry_prompt_message_id + } + chat_message_versions { + INTEGER id PK + INTEGER message_id FK + } + chat_memories { + INTEGER id PK + TEXT username FK + INTEGER chat_id FK + INTEGER assistant_persona_id FK + INTEGER user_persona_id FK + } + persona_chat_state { + INTEGER id PK + TEXT username FK + INTEGER chat_id FK, UK + INTEGER assistant_persona_id FK + INTEGER user_persona_id FK + } + personas { + INTEGER id PK + TEXT username + INTEGER source_market_id + } + persona_versions { + INTEGER id PK + INTEGER persona_id FK + } + prompt_templates { + INTEGER id PK + TEXT username + INTEGER persona_id FK + } + persona_market { + INTEGER id PK + INTEGER persona_id + TEXT creator_username + } + persona_market_favorites { + INTEGER market_id PK, FK + TEXT username PK + } + persona_market_ratings { + INTEGER market_id PK, FK + TEXT username PK + } + persona_market_reports { + INTEGER id PK + INTEGER market_id FK + TEXT reporter_username + } + attachments { + TEXT id PK + TEXT username FK + TEXT storage_key UK + } + message_attachments { + INTEGER message_id PK, FK + TEXT attachment_id PK, FK + } + attachment_vision_analyses { + TEXT attachment_id PK, FK + TEXT vision_model PK + INTEGER prompt_version PK + TEXT question PK + } + upload_deletions { + TEXT storage_key PK + } + + users ||--o| user_settings : "hat" + users ||--o{ chat_memories : "besitzt" + users ||--o{ persona_chat_state : "besitzt" + users ||--o{ attachments : "lädt hoch" + users ||..o{ chat_sessions : "besitzt" + users ||..o{ personas : "besitzt" + users ||..o{ persona_market : "veröffentlicht" + users ||..o{ prompt_templates : "besitzt" + + chat_sessions ||--o{ chat_messages : "enthält" + chat_sessions ||--o{ chat_memories : "hat" + chat_sessions ||--o| persona_chat_state : "hat" + chat_sessions |o--o{ chat_sessions : "Quelle für Branch" + chat_messages |o--o{ chat_sessions : "Abzweigpunkt" + chat_messages |o--o{ chat_messages : "Parent" + chat_messages ||--o{ chat_message_versions : "hat Versionen" + chat_messages ||--o{ message_attachments : "hat" + attachments ||--o{ message_attachments : "wird verwendet in" + attachments ||--o{ attachment_vision_analyses : "hat Analysen" + attachments ||..o| upload_deletions : "Trigger" + + personas |o--o{ chat_sessions : "Assistant-/User-Persona" + personas |o--o{ chat_memories : "bezieht sich auf" + personas |o--o{ persona_chat_state : "bezieht sich auf" + personas |o--o{ user_settings : "aktive Persona" + personas ||--o{ persona_versions : "hat Versionen" + personas ||--o{ prompt_templates : "hat" + personas |o..o{ persona_market : "veröffentlicht als" + persona_market |o..o{ personas : "importiert als" + persona_market ||--o{ persona_market_favorites : "hat" + persona_market ||--o{ persona_market_ratings : "hat" + persona_market ||--o{ persona_market_reports : "hat" +``` + +### Lösch-Verhalten (ON DELETE) + +| Wird gelöscht | Folge | +|---|---| +| `users` | Löscht `user_settings`, `chat_memories`, `persona_chat_state`, `attachments` (CASCADE). **Nicht** automatisch: `chat_sessions`, `personas`, `prompt_templates`, Marktplatz-Einträge (kein FK) | +| `chat_sessions` | Löscht alle `chat_messages`, `chat_memories`, `persona_chat_state` des Chats | +| `chat_messages` | Löscht `chat_message_versions` und `message_attachments`. Kind-Nachrichten behalten ihren Platz, `parent_message_id` wird `NULL` | +| `personas` | Löscht `persona_versions`, `prompt_templates`. In Chats, Memories und Settings wird der Verweis auf `NULL` gesetzt | +| `persona_market` | Löscht Favoriten, Bewertungen und Meldungen | +| `attachments` | Löscht `message_attachments`, `attachment_vision_analyses` und trägt die Datei in `upload_deletions` ein (Trigger) | + +## Tabellen + +### Benutzer + +#### `users` +Registrierte Benutzer. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `username` | TEXT | **PK** | +| `password` | TEXT | Passwort-Hash | +| `session_version` | INTEGER | Default `0`. Wird erhöht, um alle Sessions eines Benutzers ungültig zu machen | + +#### `user_settings` +Einstellungen pro Benutzer (1:1 zu `users`). + +| Spalte | Typ | Hinweis | +|---|---|---| +| `username` | TEXT | **PK**, FK → `users` (CASCADE) | +| `active_persona_id` | INTEGER | FK → `personas` (SET NULL), aktive Assistant-Persona | +| `active_user_persona_id` | INTEGER | FK → `personas` (SET NULL), aktive User-Persona | + +### Chats + +#### `chat_sessions` +Ein Chat bzw. eine Unterhaltung. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK**, Autoincrement | +| `username` | TEXT | Besitzer (logisch → `users`, kein FK) | +| `title` | TEXT | Chat-Titel | +| `assistant_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `user_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `folder_name` | TEXT | Ordner in der Seitenleiste | +| `is_pinned` | INTEGER | Boolean, Default `0` | +| `archived_at` | DATETIME | `NULL` = nicht archiviert | +| `scenario_prompt` | TEXT | Szenario-Beschreibung | +| `scenario_summary` | TEXT | Kurzfassung des Szenarios | +| `context_summary` | TEXT | Automatische Zusammenfassung des bisherigen Verlaufs | +| `context_summary_message_id` | INTEGER | Bis zu welcher Nachricht die Zusammenfassung reicht. Default `0` | +| `active_leaf_message_id` | INTEGER | Letzte Nachricht des aktuell sichtbaren Zweigs (logisch → `chat_messages`) | +| `source_chat_id` | INTEGER | FK → `chat_sessions` (SET NULL), Ursprungs-Chat bei Branches | +| `branched_from_message_id` | INTEGER | FK → `chat_messages` (SET NULL), Nachricht, an der abgezweigt wurde | +| `preferred_model` | TEXT | Bevorzugtes Modell für diesen Chat | +| `temperature` | REAL | Modell-Parameter | +| `context_length` | INTEGER | Modell-Parameter | +| `response_length` | INTEGER | Modell-Parameter | +| `system_instruction` | TEXT | Zusätzliche System-Anweisung | +| `created_at`, `updated_at` | DATETIME | | + +#### `chat_messages` +Einzelne Nachrichten. Nachrichten bilden über `parent_message_id` einen **Baum**, damit +Verzweigungen (z. B. durch Regenerieren oder Bearbeiten) möglich sind. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK**, Autoincrement | +| `chat_id` | INTEGER | FK → `chat_sessions` (CASCADE) | +| `role` | TEXT | z. B. `user`, `bot` | +| `content` | TEXT | Nachrichtentext | +| `thinking` | TEXT | Reasoning-Ausgabe des Modells (nur via Migration) | +| `sources` | TEXT | Websuche-Quellen als JSON (nur via Migration) | +| `model_name` | TEXT | Verwendetes Modell | +| `retry_variants` | TEXT | Alternative Antworten als JSON | +| `retry_active_index` | INTEGER | Aktuell angezeigte Variante. Default `0` | +| `retry_retries_used` | INTEGER | Anzahl genutzter Wiederholungen. Default `0` | +| `retry_prompt_message_id` | INTEGER | Zugehörige User-Nachricht (logisch → `chat_messages`) | +| `delivery_status` | TEXT | Default `'complete'` | +| `error_message` | TEXT | Fehlertext bei fehlgeschlagener Antwort | +| `parent_message_id` | INTEGER | FK → `chat_messages` (SET NULL), Vorgänger im Baum | +| `created_at` | DATETIME | | + +#### `chat_message_versions` +Frühere Fassungen einer Nachricht (z. B. nach Bearbeiten oder Regenerieren). + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `message_id` | INTEGER | FK → `chat_messages` (CASCADE), indiziert | +| `content`, `thinking`, `sources`, `model_name` | TEXT | Stand der Nachricht zu diesem Zeitpunkt | +| `created_at` | DATETIME | | + +#### `chat_memories` +Fakten, die sich das Modell innerhalb eines Chats merkt. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | FK → `users` (CASCADE) | +| `chat_id` | INTEGER | FK → `chat_sessions` (CASCADE) | +| `assistant_persona_id`, `user_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `fact` | TEXT | Der gemerkte Fakt | +| `created_at`, `updated_at` | DATETIME | | + +#### `persona_chat_state` +Rollenspiel-Zustand eines Chats (1:1 zu `chat_sessions`, wegen `UNIQUE` auf `chat_id`). + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | FK → `users` (CASCADE) | +| `chat_id` | INTEGER | FK → `chat_sessions` (CASCADE), **UNIQUE** | +| `assistant_persona_id`, `user_persona_id` | INTEGER | FK → `personas` (SET NULL) | +| `relationship_notes`, `tone`, `current_location`, `goals`, `unresolved_threads`, `boundaries` | TEXT | Zustand der Unterhaltung | +| `created_at`, `updated_at` | DATETIME | | + +### Personas + +#### `personas` +Charaktere, entweder für den Assistenten oder für den Benutzer selbst. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | Besitzer (logisch → `users`, kein FK) | +| `name` | TEXT | | +| `pronouns`, `appearance`, `background`, `details` | TEXT | Beschreibung | +| `custom_fields` | TEXT | Eigene Felder als JSON | +| `persona_type` | TEXT | `'assistant'` (Default) oder User-Persona | +| `source_market_id` | INTEGER | Falls aus dem Marktplatz importiert (logisch → `persona_market`) | +| `source_creator_username` | TEXT | Ursprünglicher Ersteller | +| `example_dialogues` | TEXT | Beispieldialoge | +| `created_at`, `updated_at` | DATETIME | | + +#### `persona_versions` +Versionsverlauf einer Persona. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `persona_id` | INTEGER | FK → `personas` (CASCADE) | +| `username` | TEXT | Wer die Version erstellt hat | +| `version_number` | INTEGER | Fortlaufende Nummer | +| `snapshot_json` | TEXT | Vollständiger Stand der Persona als JSON | +| `created_at` | DATETIME | | + +#### `prompt_templates` +Wiederverwendbare Prompt-Vorlagen. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `username` | TEXT | Besitzer, darf `NULL` sein (z. B. System-Vorlagen) | +| `persona_id` | INTEGER | FK → `personas` (CASCADE), optional | +| `name`, `description`, `category` | TEXT | | +| `prompt_text` | TEXT | Der eigentliche Prompt | +| `starter_text` | TEXT | Optionaler Einstiegstext | +| `is_shared` | INTEGER | Boolean, Default `0` | +| `created_at`, `updated_at` | DATETIME | | + +### Marktplatz + +#### `persona_market` +Öffentlich geteilte Personas. Eine Kopie der Persona, damit spätere Änderungen am Original +den Marktplatz-Eintrag nicht automatisch verändern. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `persona_id` | INTEGER | Original-Persona (logisch → `personas`, kein FK) | +| `creator_username` | TEXT | Ersteller | +| `name`, `pronouns`, `appearance`, `background`, `details`, `example_dialogues` | TEXT | Kopie der Persona-Daten | +| `persona_type` | TEXT | Default `'assistant'` | +| `tags` | TEXT | Schlagwörter | +| `usage_count` | INTEGER | Wie oft importiert. Default `0` | +| `favorite_count` | INTEGER | Zwischengespeicherte Anzahl Favoriten | +| `rating_total`, `rating_count` | INTEGER | Summe und Anzahl der Bewertungen (Durchschnitt = total / count) | +| `moderation_status` | TEXT | Default `'approved'` | +| `moderation_notes` | TEXT | | +| `soft_deleted_at` | DATETIME | `NULL` = sichtbar. Einträge werden nicht wirklich gelöscht | +| `created_at`, `updated_at` | DATETIME | | + +**UNIQUE** (`persona_id`, `creator_username`): Eine Persona kann pro Ersteller nur einmal veröffentlicht werden. + +#### `persona_market_favorites` +| Spalte | Typ | Hinweis | +|---|---|---| +| `market_id` | INTEGER | **PK**, FK → `persona_market` (CASCADE) | +| `username` | TEXT | **PK** | +| `created_at` | DATETIME | | + +Zusammengesetzter PK: Jeder Benutzer kann einen Eintrag nur einmal favorisieren. + +#### `persona_market_ratings` +| Spalte | Typ | Hinweis | +|---|---|---| +| `market_id` | INTEGER | **PK**, FK → `persona_market` (CASCADE) | +| `username` | TEXT | **PK** | +| `rating` | INTEGER | Bewertung | +| `created_at`, `updated_at` | DATETIME | | + +Zusammengesetzter PK: Eine Bewertung pro Benutzer und Eintrag (kann aktualisiert werden). + +#### `persona_market_reports` +Meldungen von Benutzern zur Moderation. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | INTEGER | **PK** | +| `market_id` | INTEGER | FK → `persona_market` (CASCADE) | +| `reporter_username` | TEXT | | +| `reason` | TEXT | | +| `details` | TEXT | | +| `status` | TEXT | Default `'open'` | +| `created_at`, `updated_at` | DATETIME | | + +### Anhänge + +#### `attachments` +Hochgeladene Bilder. Die Datei selbst liegt im Upload-Verzeichnis, die DB speichert nur Metadaten. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | TEXT | **PK** | +| `username` | TEXT | FK → `users` (CASCADE, auch ON UPDATE) | +| `original_name` | TEXT | Ursprünglicher Dateiname | +| `storage_key` | TEXT | **UNIQUE**, Dateiname im Speicher | +| `mime_type` | TEXT | | +| `size_bytes`, `width`, `height` | INTEGER | | +| `status` | TEXT | `'temporary'` (Default) oder `'attached'` (per CHECK erzwungen) | +| `created_at` | TEXT | | +| `vision_model`, `vision_analysis`, `vision_analyzed_at` | TEXT | Einzelne Bildanalyse (siehe auch `attachment_vision_analyses`) | + +#### `message_attachments` +Verknüpfungstabelle Nachricht ↔ Anhang (n:m). + +| Spalte | Typ | Hinweis | +|---|---|---| +| `message_id` | INTEGER | **PK**, FK → `chat_messages` (CASCADE) | +| `attachment_id` | TEXT | **PK**, FK → `attachments` (CASCADE), indiziert | +| `position` | INTEGER | Reihenfolge der Bilder in der Nachricht | + +#### `attachment_vision_analyses` +Zwischengespeicherte Bildanalysen. Pro Bild, Modell, Prompt-Version und Frage wird +das Ergebnis nur einmal berechnet. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `attachment_id` | TEXT | **PK**, FK → `attachments` (CASCADE) | +| `vision_model` | TEXT | **PK** | +| `prompt_version` | INTEGER | **PK** | +| `question` | TEXT | **PK** | +| `analysis` | TEXT | Ergebnis | +| `analyzed_at` | TEXT | | + +#### `upload_deletions` +Warteschlange für Dateien, die vom Speicher gelöscht werden müssen. + +| Spalte | Typ | Hinweis | +|---|---|---| +| `storage_key` | TEXT | **PK** | + +### Lebenszyklus eines Anhangs + +Die Datenbank kann keine Dateien löschen. Deshalb tragen Trigger die zu löschenden +Dateien in `upload_deletions` ein, und die App räumt sie danach im Dateisystem auf. + +```mermaid +flowchart TD + A[Bild hochgeladen] --> B["attachments
status = temporary"] + B -->|Nachricht gesendet| C["status = attached
+ Eintrag in message_attachments"] + C -->|Nachricht oder Chat gelöscht| D[message_attachments-Zeile gelöscht] + D -->|"Trigger release_message_attachment
(keine weiteren Verweise)"| E[attachments-Zeile gelöscht] + B -->|temporärer Upload verworfen| E + E -->|Trigger queue_attachment_file_delete| F["storage_key in upload_deletions"] + F --> G[App löscht Datei vom Speicher] +``` + +**Trigger:** + +- **`release_message_attachment`**: Nach dem Löschen einer Verknüpfung wird der Anhang gelöscht, + wenn er `attached` ist und von keiner anderen Nachricht mehr verwendet wird. +- **`queue_attachment_file_delete`**: Nach dem Löschen eines Anhangs wird dessen + `storage_key` in `upload_deletions` eingetragen. + +## Migrationen + +`runMigrations` ist so geschrieben, dass sie **beliebig oft** laufen kann: + +- `CREATE TABLE IF NOT EXISTS` legt nur fehlende Tabellen an. +- `ensureColumn` liest die vorhandenen Spalten per `PRAGMA table_info` und führt + `ALTER TABLE ... ADD COLUMN` nur aus, wenn die Spalte fehlt. +- `UPDATE ... WHERE ... IS NULL` füllt Default-Werte für alte Zeilen nach. + +**Einmalige Datenmigrationen:** + +- **Nachrichtenbaum:** Wenn `parent_message_id` neu angelegt wurde, wird jede Nachricht mit + ihrer Vorgängernachricht im selben Chat verknüpft (`LAG`-Fensterfunktion), und + `active_leaf_message_id` wird auf die letzte Nachricht gesetzt. +- **Alte `chats`-Tabelle:** Existiert die frühere Tabelle `chats` und ist `chat_messages` + leer, werden alle alten Nachrichten pro Benutzer in einen Chat „Imported chat“ übernommen. + Mit `dropLegacyChats = true` (Config `DROP_LEGACY_CHATS`) wird `chats` danach gelöscht. + +## Bekannte Auffälligkeiten + +- **Fehlende Fremdschlüssel auf `users`:** `chat_sessions`, `personas`, `prompt_templates`, + `persona_versions` und die Marktplatz-Tabellen verweisen nur logisch auf `users`. + Beim Löschen eines Benutzers bleiben diese Daten erhalten, sofern der Code sie nicht + selbst löscht. +- **Doppelte Tabellendefinitionen:** Mehrere Tabellen werden sowohl in `initializeSchema` + als auch in `runMigrations` angelegt. Wegen `IF NOT EXISTS` ist das harmlos, muss aber + bei Änderungen an beiden Stellen gepflegt werden. +- **Zwei Orte für Bildanalysen:** `attachments.vision_analysis` und die Tabelle + `attachment_vision_analyses`. Prüfen, ob die Spalten in `attachments` noch verwendet werden. +- **Uneinheitliche Zeitstempel-Typen:** Meist `DATETIME`, in den Anhang-Tabellen `TEXT`. + In SQLite technisch gleichwertig, aber inkonsistent. +- **Bewertungen ohne Wertebereich:** `persona_market_ratings.rating` hat keinen `CHECK`, + der gültige Bereich wird nur im Code geprüft. \ No newline at end of file