diff --git a/src/routes/admin/ai.ts b/src/routes/admin/ai.ts index 3a74c7a..4f81c9b 100644 --- a/src/routes/admin/ai.ts +++ b/src/routes/admin/ai.ts @@ -1,12 +1,14 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import multipart from "@fastify/multipart"; import { requirePermission } from "../../middleware/auth"; -import { success, error } from "../../utils/response"; +import { success, error, parseId } from "../../utils/response"; import { parseBody } from "../../schemas/common"; import { AiChatSchema, AiBudgetSchema, AiHistoryAppendSchema, + AiCreateConversationSchema, + AiRenameConversationSchema, } from "../../schemas/ai.schema"; import { logAudit } from "../../services/audit"; import { config } from "../../config/env"; @@ -18,9 +20,12 @@ import { setBudgetUsd, chat, extractInvoice, - getChatHistory, - appendChatMessages, - clearChatHistory, + listConversations, + createConversation, + getConversationMessages, + appendConversationMessages, + renameConversation, + deleteConversation, type ExtractedInvoice, } from "../../services/ai.service"; @@ -131,35 +136,95 @@ export default async function aiRoutes(app: FastifyInstance): Promise { }, ); - // GET /api/admin/ai/history — this user's stored chat thread + // GET /conversations — this user's conversations (stable order) app.get( - "/history", + "/conversations", { preHandler: requirePermission("ai.use") }, async (request: FastifyRequest, reply: FastifyReply) => { - const messages = await getChatHistory(request.authData!.userId); - return success(reply, { messages }); + const conversations = await listConversations(request.authData!.userId); + return success(reply, { conversations }); }, ); - // POST /api/admin/ai/history — append turns to this user's thread + // POST /conversations — create app.post( - "/history", + "/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); - await appendChatMessages(request.authData!.userId, body.data.messages); - return success(reply, { ok: true }); + 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); }, ); - // DELETE /api/admin/ai/history — clear this user's thread - app.delete( - "/history", + // PATCH /conversations/:id — rename + app.patch( + "/conversations/:id", { preHandler: requirePermission("ai.use") }, async (request: FastifyRequest, reply: FastifyReply) => { - await clearChatHistory(request.authData!.userId); - return success(reply, { ok: true }); + 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); }, ); } diff --git a/src/schemas/ai.schema.ts b/src/schemas/ai.schema.ts index 79a2a01..11be2fd 100644 --- a/src/schemas/ai.schema.ts +++ b/src/schemas/ai.schema.ts @@ -24,6 +24,14 @@ export const AiHistoryAppendSchema = z.object({ .max(20, "Příliš mnoho zpráv"), }); +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), +}); + export const AiBudgetSchema = z.object({ budget_usd: z .union([z.number(), z.string()]) diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index 65a745d..8e0d9d7 100644 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -104,48 +104,121 @@ export async function assertBudgetAvailable(): Promise<{ return null; } -// ── Chat history (server-side, permanent, per user) ─────────────────────── +// ── 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; +} -// Cap how many turns we read back / display. The full thread stays in the DB; -// this only bounds a single GET payload (and, on the client, the model context). -const HISTORY_LIMIT = 200; +const DEFAULT_CONVERSATION_TITLE = "Nová konverzace"; +const MESSAGE_LIMIT = 200; // cap a single thread read -/** This user's chat thread, oldest → newest, capped at HISTORY_LIMIT. */ -export async function getChatHistory( +export async function listConversations( userId: number, -): Promise { - const rows = await prisma.ai_chat_messages.findMany({ +): Promise { + 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 { + 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: HISTORY_LIMIT, + take: MESSAGE_LIMIT, select: { role: true, content: true, created_at: true }, }); - return rows.reverse(); + return { data: rows.reverse() }; } -/** Append turns to this user's thread (best-effort caller). */ -export async function appendChatMessages( +export async function appendConversationMessages( userId: number, + convId: number, messages: { role: string; content: string }[], -): Promise { - if (messages.length === 0) return; - await prisma.ai_chat_messages.createMany({ - data: messages.map((m) => ({ - user_id: userId, - role: m.role, - content: m.content, - })), - }); +): Promise<{ data: { ok: true } } | { error: string; status: number }> { + const conv = await ownConversation(userId, convId); + if (!conv) return { error: "Konverzace nenalezena", status: 404 }; + // The route validates messages with min(1), so this is normally non-empty; + // the guard is defensive (and still bumps updated_at below for an empty call). + 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 } }; } -/** Wipe this user's thread. */ -export async function clearChatHistory(userId: number): Promise { - await prisma.ai_chat_messages.deleteMany({ where: { user_id: userId } }); +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 } }; } export interface ChatMessage {