From 817f0b2f009e6a22a3f5d3023120842606ca8b60 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 19:01:50 +0200 Subject: [PATCH] docs(odin): design spec for multi-conversation chat Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-08-odin-conversations-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-odin-conversations-design.md diff --git a/docs/superpowers/specs/2026-06-08-odin-conversations-design.md b/docs/superpowers/specs/2026-06-08-odin-conversations-design.md new file mode 100644 index 0000000..723f279 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-odin-conversations-design.md @@ -0,0 +1,218 @@ +# 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.