feat(ai): server-side chat history + assistant UX fixes

Server-side, per-user chat history (the thread now survives reload):
- new ai_chat_messages table + migration; getChatHistory/appendChatMessages/
  clearChatHistory; GET/POST/DELETE /api/admin/ai/history (ai.use-guarded);
  service + HTTP tests (isolation, ordering, clear, perm, validation).
- DashAssistant loads the thread on mount and persists each turn; "Vymazat"
  clears it. gcTime:0 so each mount re-reads the DB (no stale-cache resurrection);
  composer gated until history settles + seed flag flipped on submit (no
  seed/submit race clobbering a freshly-sent turn).

UX + correctness fixes from review/feedback:
- review cards moved OUT of the scrollable thread into a "Faktury k potvrzení"
  section so Uložit is always reachable; chat auto-scroll no longer fires on
  field edits.
- chat bubble text colour set on the Typography (GlobalStyles pins `p` to
  text.secondary, which was overriding the inherited bubble colour → unreadable).
- never clear input/staged PDFs on a failed submit (rollback + keep), so a
  budget error can't wipe staged invoices; roll back the optimistic user turn.
- stable attachment ids (no crypto.randomUUID — undefined over plain HTTP).
- editable Datum splatnosti + Popis fields; client-side extraction summary.

Security: DashProfile clipboard copy now uses an execCommand fallback (works
over plain HTTP) and only shows the success toast when the copy actually
succeeded — previously it falsely claimed to copy 2FA codes / the TOTP secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 16:02:26 +02:00
parent 0226cdd52b
commit 58e5fd2b1d
9 changed files with 771 additions and 167 deletions

View File

@@ -104,6 +104,50 @@ export async function assertBudgetAvailable(): Promise<{
return null;
}
// ── Chat history (server-side, permanent, per user) ───────────────────────
export interface StoredChatMessage {
role: string;
content: string;
created_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;
/** This user's chat thread, oldest → newest, capped at HISTORY_LIMIT. */
export async function getChatHistory(
userId: number,
): Promise<StoredChatMessage[]> {
const rows = await prisma.ai_chat_messages.findMany({
where: { user_id: userId },
orderBy: { id: "desc" },
take: HISTORY_LIMIT,
select: { role: true, content: true, created_at: true },
});
return rows.reverse();
}
/** Append turns to this user's thread (best-effort caller). */
export async function appendChatMessages(
userId: number,
messages: { role: string; content: string }[],
): Promise<void> {
if (messages.length === 0) return;
await prisma.ai_chat_messages.createMany({
data: messages.map((m) => ({
user_id: userId,
role: m.role,
content: m.content,
})),
});
}
/** Wipe this user's thread. */
export async function clearChatHistory(userId: number): Promise<void> {
await prisma.ai_chat_messages.deleteMany({ where: { user_id: userId } });
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;