# 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.
