docs(odin): design spec for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user