docs(odin): implementation plan for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
# Odin Multi-Conversation Chat — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
|
||||||
|
|
||||||
|
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
|
||||||
|
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
|
||||||
|
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
|
||||||
|
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
|
||||||
|
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
|
||||||
|
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
|
||||||
|
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
|
||||||
|
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
|
||||||
|
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
|
||||||
|
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
|
||||||
|
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
|
||||||
|
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
|
||||||
|
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
|
||||||
|
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
|
||||||
|
|
||||||
|
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate` → `npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data model + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma`
|
||||||
|
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
|
||||||
|
|
||||||
|
```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")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_conversations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||||
|
|
||||||
|
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||||
|
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`;
|
||||||
|
|
||||||
|
UPDATE `ai_chat_messages` m
|
||||||
|
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||||
|
SET m.`conversation_id` = c.`id`;
|
||||||
|
|
||||||
|
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
DROP INDEX `idx_ai_chat_user`,
|
||||||
|
MODIFY `conversation_id` INTEGER 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`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Controller applies the migration** (not the implementer):
|
||||||
|
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
|
||||||
|
- `npx prisma generate` — expect "Generated Prisma Client".
|
||||||
|
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
|
||||||
|
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — conversation service, schemas, routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/ai.service.ts`
|
||||||
|
- Modify: `src/schemas/ai.schema.ts`
|
||||||
|
- Modify: `src/routes/admin/ai.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ── Conversations (server-side, per user) ─────────────────────────────────
|
||||||
|
export interface StoredChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
|
||||||
|
const MESSAGE_LIMIT = 200; // cap a single thread read
|
||||||
|
|
||||||
|
export async function listConversations(
|
||||||
|
userId: number,
|
||||||
|
): Promise<ConversationSummary[]> {
|
||||||
|
return prisma.ai_conversations.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
orderBy: { id: "asc" }, // stable tab order
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConversation(
|
||||||
|
userId: number,
|
||||||
|
title?: string,
|
||||||
|
): Promise<ConversationSummary> {
|
||||||
|
return prisma.ai_conversations.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ownership-checked fetch; null when not found / not owned. */
|
||||||
|
async function ownConversation(userId: number, convId: number) {
|
||||||
|
return prisma.ai_conversations.findFirst({
|
||||||
|
where: { id: convId, user_id: userId },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversationMessages(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
const rows = await prisma.ai_chat_messages.findMany({
|
||||||
|
where: { conversation_id: convId },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: MESSAGE_LIMIT,
|
||||||
|
select: { role: true, content: true, created_at: true },
|
||||||
|
});
|
||||||
|
return { data: rows.reverse() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendConversationMessages(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||||
|
const conv = await ownConversation(userId, convId);
|
||||||
|
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await prisma.ai_chat_messages.createMany({
|
||||||
|
data: messages.map((m) => ({
|
||||||
|
user_id: userId,
|
||||||
|
conversation_id: convId,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Auto-title from the first user message (only while still the default), and
|
||||||
|
// always bump updated_at.
|
||||||
|
const firstUser = messages.find((m) => m.role === "user");
|
||||||
|
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
|
||||||
|
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
|
||||||
|
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
|
||||||
|
if (t) data.title = t;
|
||||||
|
}
|
||||||
|
await prisma.ai_conversations.update({ where: { id: convId }, data });
|
||||||
|
return { data: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameConversation(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
const updated = await prisma.ai_conversations.update({
|
||||||
|
where: { id: convId },
|
||||||
|
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
return { data: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConversation(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
|
||||||
|
return { data: { ok: true } };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const AiCreateConversationSchema = z.object({
|
||||||
|
title: z.string().max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AiRenameConversationSchema = z.object({
|
||||||
|
title: z.string().min(1, "Název je povinný").max(255),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// GET /conversations — this user's conversations (stable order)
|
||||||
|
app.get(
|
||||||
|
"/conversations",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const conversations = await listConversations(request.authData!.userId);
|
||||||
|
return success(reply, { conversations });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /conversations — create
|
||||||
|
app.post(
|
||||||
|
"/conversations",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = parseBody(AiCreateConversationSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const conversation = await createConversation(
|
||||||
|
request.authData!.userId,
|
||||||
|
body.data.title,
|
||||||
|
);
|
||||||
|
return success(reply, { conversation });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /conversations/:id/messages
|
||||||
|
app.get(
|
||||||
|
"/conversations/:id/messages",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const result = await getConversationMessages(request.authData!.userId, id);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, { messages: result.data });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /conversations/:id/messages — append turns
|
||||||
|
app.post(
|
||||||
|
"/conversations/:id/messages",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const body = parseBody(AiHistoryAppendSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await appendConversationMessages(
|
||||||
|
request.authData!.userId,
|
||||||
|
id,
|
||||||
|
body.data.messages,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, result.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /conversations/:id — rename
|
||||||
|
app.patch(
|
||||||
|
"/conversations/:id",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const body = parseBody(AiRenameConversationSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await renameConversation(
|
||||||
|
request.authData!.userId,
|
||||||
|
id,
|
||||||
|
body.data.title,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, { conversation: result.data });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /conversations/:id
|
||||||
|
app.delete(
|
||||||
|
"/conversations/:id",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const result = await deleteConversation(request.authData!.userId, id);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, result.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
|
||||||
|
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/__tests__/ai.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
computeCostUsd,
|
||||||
|
recordUsage,
|
||||||
|
getMonthSpendUsd,
|
||||||
|
getBudgetUsd,
|
||||||
|
assertBudgetAvailable,
|
||||||
|
isConfigured,
|
||||||
|
listConversations,
|
||||||
|
createConversation,
|
||||||
|
getConversationMessages,
|
||||||
|
appendConversationMessages,
|
||||||
|
renameConversation,
|
||||||
|
deleteConversation,
|
||||||
|
} from "../services/ai.service";
|
||||||
|
|
||||||
|
const KIND = "test_ai";
|
||||||
|
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
||||||
|
const CONV_UID_B = 999999992;
|
||||||
|
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { title: { contains: HIST_MARKER } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe("ai.service conversations", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
expect(conv.title).toBe("Nová konverzace");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "Kolik je hodin v Praze?" },
|
||||||
|
{ role: "assistant", content: "Nevím, ale…" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
||||||
|
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
||||||
|
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
|
||||||
|
"Kolik je hodin v Praze?",
|
||||||
|
"Nevím, ale…",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite a renamed title on later appends", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "tohle by nemělo přepsat název" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list[0].title).toBe("Moje téma");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates conversations per user (404 across owners)", async () => {
|
||||||
|
const a = await createConversation(CONV_UID_A);
|
||||||
|
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
||||||
|
const read = await getConversationMessages(CONV_UID_B, a.id);
|
||||||
|
expect("error" in read && read.status).toBe(404);
|
||||||
|
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
|
||||||
|
expect("error" in ren && ren.status).toBe(404);
|
||||||
|
const del = await deleteConversation(CONV_UID_B, a.id);
|
||||||
|
expect("error" in del && del.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascade-deletes messages with the conversation", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "x" },
|
||||||
|
]);
|
||||||
|
await deleteConversation(CONV_UID_A, conv.id);
|
||||||
|
const remaining = await prisma.ai_chat_messages.count({
|
||||||
|
where: { conversation_id: conv.id },
|
||||||
|
});
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("GET /conversations requires ai.use", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} t` },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const id = created.json().data.conversation.id as number;
|
||||||
|
|
||||||
|
const app2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||||
|
});
|
||||||
|
expect(app2.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const msgs = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
||||||
|
).toContain("ahoj");
|
||||||
|
|
||||||
|
const renamed = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} renamed` },
|
||||||
|
});
|
||||||
|
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
|
||||||
|
|
||||||
|
const del = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(del.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET messages of a non-existent conversation → 404", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations/999999000/messages",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/__tests__/ai.test.ts
|
||||||
|
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend queries + types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/ai.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiConversationsOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversations"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ conversations: Conversation[] }>(
|
||||||
|
"/api/admin/ai/conversations",
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiConversationMessagesOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversation", id, "messages"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ messages: StoredChatMessage[] }>(
|
||||||
|
`/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
),
|
||||||
|
// Re-read the DB on each mount/selection (no stale-cache resurrection).
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/ai.ts
|
||||||
|
git commit -m "feat(odin): conversation query options"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend presentational components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/odin/OdinTabs.tsx`
|
||||||
|
- Create: `src/admin/components/odin/OdinThread.tsx`
|
||||||
|
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
|
||||||
|
- Create: `src/admin/components/odin/OdinComposer.tsx`
|
||||||
|
|
||||||
|
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ChatTurn {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export interface ReviewInvoice {
|
||||||
|
uid: string;
|
||||||
|
supplier_name: string;
|
||||||
|
invoice_number: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
vat_rate: string;
|
||||||
|
issue_date: string;
|
||||||
|
due_date: string;
|
||||||
|
description: string;
|
||||||
|
file_name: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
export type EditableField =
|
||||||
|
| "supplier_name"
|
||||||
|
| "invoice_number"
|
||||||
|
| "amount"
|
||||||
|
| "currency"
|
||||||
|
| "vat_rate"
|
||||||
|
| "issue_date"
|
||||||
|
| "due_date"
|
||||||
|
| "description";
|
||||||
|
export interface StagedFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinTabsProps {
|
||||||
|
conversations: { id: number; title: string }[];
|
||||||
|
activeId: number | null;
|
||||||
|
busy: boolean;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onRename: (id: number, title: string) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `OdinThread.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinThreadProps {
|
||||||
|
turns: ChatTurn[];
|
||||||
|
busy: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
threadRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
|
||||||
|
|
||||||
|
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface InvoiceReviewCardProps {
|
||||||
|
inv: ReviewInvoice;
|
||||||
|
busy: boolean;
|
||||||
|
onChange: (uid: string, field: EditableField, value: string) => void;
|
||||||
|
onSave: (inv: ReviewInvoice) => void;
|
||||||
|
onDiscard: (uid: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)`→`onChange(...)`, `saveInvoice`→`onSave`, the discard filter→`onDiscard(inv.uid)`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `OdinComposer.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinComposerProps {
|
||||||
|
input: string;
|
||||||
|
attachments: StagedFile[];
|
||||||
|
busy: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
fileRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onInput: (v: string) => void;
|
||||||
|
onFiles: (files: FileList | null) => void;
|
||||||
|
onRemoveAttachment: (id: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/odin
|
||||||
|
git commit -m "feat(odin): tabs, thread, review-card, composer components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: OdinChat orchestrator + page + delete DashAssistant
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/odin/OdinChat.tsx`
|
||||||
|
- Modify: `src/admin/pages/Odin.tsx`
|
||||||
|
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
|
||||||
|
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
|
||||||
|
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
|
||||||
|
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
|
||||||
|
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
|
||||||
|
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
|
||||||
|
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
|
||||||
|
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
|
||||||
|
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
|
||||||
|
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
|
||||||
|
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
|
||||||
|
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
|
||||||
|
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
|
||||||
|
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
|
||||||
|
|
||||||
|
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52–333) — copy them, applying only the adaptations above.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import OdinChat from "../components/odin/OdinChat";
|
||||||
|
// …
|
||||||
|
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
|
||||||
|
<OdinChat />
|
||||||
|
</Box>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm src/admin/components/dashboard/DashAssistant.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
|
||||||
|
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification (controller)
|
||||||
|
|
||||||
|
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
|
||||||
|
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
|
||||||
|
- Adversarial review pass (workflow) before merge/release.
|
||||||
Reference in New Issue
Block a user