# Odin — Multi-Conversation AI Chat — Design **Date:** 2026-06-08 **Status:** Approved (design), pending spec review ## Goal Replace the single-thread Odin assistant with a multi-conversation chat: a purpose-built UI on the `/odin` page where the user keeps several named conversations (like topics), switches between them via top tabs, and can rename or delete them. All existing assistant capabilities (chat, PDF invoice extraction → review → save, monthly budget) carry over unchanged. ## Background (current state) - `ai_chat_messages` is a single rolling thread per user (`user_id, role, content, created_at`). Endpoints `GET/POST/DELETE /api/admin/ai/history` read/append/clear that one thread. - The chat lives in `DashAssistant.tsx`, rendered on the `/odin` page (moved off the dashboard in the prior change, branch `feat/odin-page`). - Stateless AI endpoints (`/chat`, `/extract-invoices`, `/usage`, `/budget`) are unaffected by conversations — they don't persist anything themselves. ## Decisions (from brainstorming) - **Titling:** new conversation auto-titles from its first user message; user can rename. - **Per-conversation actions:** rename + delete. - **Existing history:** migrate the current single thread into one conversation per user ("keep as first conversation"); nothing is lost. - **Layout:** top tabs + chat (not a left sidebar). ## Data model ### `ai_conversations` (new) | column | type | notes | | ---------- | --------------------- | ----------------------------------- | | id | INT PK AUTO_INCREMENT | | | user_id | INT NOT NULL | owner; isolation key | | title | VARCHAR(255) NOT NULL | default "Nová konverzace" | | created_at | TIMESTAMP(0) NOT NULL | default CURRENT_TIMESTAMP | | updated_at | TIMESTAMP(0) NOT NULL | bumped on each append; sort/recency | Index: `idx_ai_conv_user (user_id, id)`. ### `ai_chat_messages` (modified) - Add `conversation_id INT NOT NULL` (FK → `ai_conversations(id)` `ON DELETE CASCADE`). Keep `user_id` (denormalized, harmless; simplifies cleanup). - Replace index `idx_ai_chat_user (user_id, id)` with `idx_ai_chat_conv (conversation_id, id)` (messages are read per conversation). ### Prisma ```prisma model ai_conversations { id Int @id @default(autoincrement()) user_id Int title String @db.VarChar(255) created_at DateTime @default(now()) @db.Timestamp(0) updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0) messages ai_chat_messages[] @@index([user_id, id], map: "idx_ai_conv_user") } model ai_chat_messages { id Int @id @default(autoincrement()) user_id Int conversation_id Int role String @db.VarChar(20) content String @db.Text created_at DateTime @default(now()) @db.Timestamp(0) conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade) @@index([conversation_id, id], map: "idx_ai_chat_conv") } ``` ### Migration (hand-written SQL; one tracked migration) 1. `CREATE TABLE ai_conversations (...)`. 2. `ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id`. 3. Backfill one conversation per user who has messages: `INSERT INTO ai_conversations (user_id, title, created_at, updated_at) SELECT user_id, 'Konverzace', NOW(), NOW() FROM ai_chat_messages GROUP BY user_id;` 4. Point messages at it: `UPDATE ai_chat_messages m JOIN ai_conversations c ON c.user_id = m.user_id SET m.conversation_id = c.id;` 5. Drop the old index, enforce NOT NULL, add the FK + new index: `ALTER TABLE ai_chat_messages DROP INDEX idx_ai_chat_user, MODIFY conversation_id INT NOT NULL, ADD CONSTRAINT fk_ai_chat_conv FOREIGN KEY (conversation_id) REFERENCES ai_conversations(id) ON DELETE CASCADE, ADD INDEX idx_ai_chat_conv (conversation_id, id);` The backfill is a no-op when `ai_chat_messages` is empty (prod today), and preserves the dev thread. ## Service layer (`ai.service.ts`) Replace the single-thread helpers (`getChatHistory` / `appendChatMessages` / `clearChatHistory`) with conversation-scoped ones. Every function takes `userId` and verifies the conversation belongs to the user. - `listConversations(userId)` → `{id, title, updated_at}[]`, ordered by `id` asc (stable tab order). - `createConversation(userId, title?)` → the new conversation (default title "Nová konverzace"). - `getConversation(userId, id)` → conversation or null (ownership check helper). - `getConversationMessages(userId, id)` → `StoredChatMessage[]` oldest→newest, or `{ error, status }` if not owned/found. - `appendConversationMessages(userId, id, msgs)` → append; **bump `updated_at`**; **auto-title**: if the conversation's title is still the default and this batch has a user message, set `title` = first ~60 chars of that user message. - `renameConversation(userId, id, title)` / `deleteConversation(userId, id)` — ownership-checked; delete cascades messages. Auto-title keeps titling server-side and robust (no client title logic). ## Routes (`routes/admin/ai.ts`, all `requirePermission("ai.use")`) | Method | Path | Body / result | | ------ | ----------------------------- | ------------------------------------------ | | GET | `/conversations` | → `{ conversations: [...] }` | | POST | `/conversations` | `{ title? }` → `{ conversation }` | | GET | `/conversations/:id/messages` | → `{ messages: [...] }` (404 if not owned) | | POST | `/conversations/:id/messages` | `{ messages:[{role,content}] }` (1..20) | | PATCH | `/conversations/:id` | `{ title }` (1..255) | | DELETE | `/conversations/:id` | → `{ ok: true }` | `:id` parsed via `parseId`; bodies via `parseBody`; ownership 404 from the service surfaced with `error(reply, ...)`. The old `/history` routes are removed. `/chat`, `/extract-invoices`, `/usage`, `/budget` are unchanged. ## Frontend ### Queries (`lib/queries/ai.ts`) - `aiConversationsOptions()` → `["ai","conversations"]`, `GET /conversations`. - `aiConversationMessagesOptions(id)` → `["ai","conversation",id,"messages"]`, `GET /conversations/:id/messages`, `gcTime: 0` (each mount re-reads the DB, same rationale as today: no stale-cache resurrection). - Types: `Conversation { id, title, updated_at }`, reuse `StoredChatMessage`. ### Components (`src/admin/components/odin/`) - **`OdinChat.tsx`** — orchestrator. Owns: active conversation id, input, busy, staged attachments, review list. Loads the conversation list; ensures one exists (creates a default if the user has none); loads the active thread; routes send/extract/save through the active conversation. Full-height flex layout: header → tabs → thread (flex-grow, scroll) → review → composer. - **`OdinTabs.tsx`** — horizontally-scrollable tabs (one per conversation) + a "+" new-chat button + a ⋮ menu on the active tab (Přejmenovat / Smazat). Rename uses a small Modal with a text field; delete uses ConfirmDialog. - **`OdinThread.tsx`** — message list with an Odin avatar on assistant turns, user turns right-aligned; the "Pracuji…" indicator; empty-state greeting. (Message text colour set on the `Typography` — GlobalStyles pins `p` to text.secondary.) - **`InvoiceReviewCard.tsx`** — the editable extract card (supplier, number, Částka s DPH, měna, sazba, dates, popis) + Uložit / Zahodit. - **`OdinComposer.tsx`** — staged-attachment chips + attach button + message field (Enter to send) + send button; disabled until the active thread loads. `DashAssistant.tsx` is deleted; `pages/Odin.tsx` renders `OdinChat`. ### Data flow 1. Mount → load `aiConversationsOptions`. If empty, `POST /conversations` to create a default and select it; else select the first (or last-active). 2. Selecting a tab sets the active id → `aiConversationMessagesOptions(id)` loads that thread into the displayed turns (seed-once per id; submit marks the thread live so a late load can't clobber — same guard as today). 3. **Send (chat):** optimistic user turn → `POST /chat` → append assistant reply → `POST /conversations/:id/messages` persists both → bump usage. On error: roll back, keep input. 4. **Send (attachments):** `POST /extract-invoices` → review cards → client-side summary turn → persist the user+summary turns. Save a card → `POST /received-invoices` (gross/VAT-inclusive, unchanged). 5. **New / rename / delete** → mutations that invalidate `["ai","conversations"]` (and reset the active id appropriately on delete). ### Reuse All proven logic carries over verbatim: staged-attach (no auto-send), the three bug fixes (no clear-on-error, rollback dangling turn, stable ids — `nextUid`, NOT `crypto.randomUUID`), the model-context trimming (last 20, leading-user), budget indicator, gross/VAT save. The only structural change is "one thread" → "active conversation thread", plus the tab/CRUD shell. ## Error handling - Ownership: service returns `{ error: "Konverzace nenalezena", status: 404 }` for a conversation the user doesn't own; routes surface it. No cross-user read or write is possible (every query filters by `user_id`). - Persistence is best-effort and logged (never swallowed), as today. - Auto-create-default and create/rename/delete failures surface a Czech toast. ## Testing Backend (Vitest, real DB): - Conversation CRUD: create → list → rename → delete (cascade removes messages). - **Isolation:** user A cannot read/append/rename/delete user B's conversation (404), and `listConversations(A)` excludes B's. - Append: ordering oldest→newest; `updated_at` bumps; **auto-title** sets the title from the first user message and does not overwrite a renamed title. - HTTP: routes 403 without `ai.use`; 404 for a foreign/:id; 400 on bad body. Frontend: no component tests (consistent with the codebase); gated by `tsc -b` + `npm run build`. Live verification by the user/controller. ## Out of scope (Phase 2+) - Querying system data inside chat (the original deferred Phase-2 scope). - Conversation search, pinning, folders, sharing. - Streaming responses.