10 KiB
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_messagesis a single rolling thread per user (user_id, role, content, created_at). EndpointsGET/POST/DELETE /api/admin/ai/historyread/append/clear that one thread.- The chat lives in
DashAssistant.tsx, rendered on the/odinpage (moved off the dashboard in the prior change, branchfeat/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). Keepuser_id(denormalized, harmless; simplifies cleanup). - Replace index
idx_ai_chat_user (user_id, id)withidx_ai_chat_conv (conversation_id, id)(messages are read per conversation).
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)
CREATE TABLE ai_conversations (...).ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id.- 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; - 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; - 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 byidasc (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; bumpupdated_at; auto-title: if the conversation's title is still the default and this batch has a user message, settitle= 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 }, reuseStoredChatMessage.
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 theTypography— GlobalStyles pinspto 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
- Mount → load
aiConversationsOptions. If empty,POST /conversationsto create a default and select it; else select the first (or last-active). - 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). - Send (chat): optimistic user turn →
POST /chat→ append assistant reply →POST /conversations/:id/messagespersists both → bump usage. On error: roll back, keep input. - 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). - 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 byuser_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_atbumps; 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.