Files
app/docs/superpowers/specs/2026-06-08-odin-conversations-design.md
BOHA 817f0b2f00 docs(odin): design spec for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:01:50 +02:00

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_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

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.