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

@@ -14,15 +14,28 @@ import {
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
getChatHistory,
appendChatMessages,
clearChatHistory,
} from "../services/ai.service";
const KIND = "test_ai"; // marker so we only clean our own rows
// Synthetic, FK-free user ids for chat-history service tests (no users row needed).
const HIST_UID_A = 999999991;
const HIST_UID_B = 999999992;
const HIST_MARKER = "「test-hist」"; // marker for HTTP-test rows on real users
beforeEach(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
});
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_chat_messages.deleteMany({
where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } },
});
await prisma.ai_chat_messages.deleteMany({
where: { content: { contains: HIST_MARKER } },
});
});
describe("ai.service cost + budget", () => {
@@ -99,6 +112,46 @@ describe("ai.service cost + budget", () => {
});
});
describe("ai.service chat history", () => {
beforeEach(async () => {
await prisma.ai_chat_messages.deleteMany({
where: { user_id: { in: [HIST_UID_A, HIST_UID_B] } },
});
});
it("appends and returns a user's thread oldest → newest", async () => {
await appendChatMessages(HIST_UID_A, [
{ role: "user", content: "ahoj" },
{ role: "assistant", content: "Dobrý den" },
]);
await appendChatMessages(HIST_UID_A, [
{ role: "user", content: "jak se máš" },
]);
const hist = await getChatHistory(HIST_UID_A);
expect(hist.map((m) => m.content)).toEqual([
"ahoj",
"Dobrý den",
"jak se máš",
]);
expect(hist[0].role).toBe("user");
});
it("isolates history per user", async () => {
await appendChatMessages(HIST_UID_A, [{ role: "user", content: "A1" }]);
await appendChatMessages(HIST_UID_B, [{ role: "user", content: "B1" }]);
const a = await getChatHistory(HIST_UID_A);
const b = await getChatHistory(HIST_UID_B);
expect(a.map((m) => m.content)).toEqual(["A1"]);
expect(b.map((m) => m.content)).toEqual(["B1"]);
});
it("clears a user's thread", async () => {
await appendChatMessages(HIST_UID_A, [{ role: "user", content: "x" }]);
await clearChatHistory(HIST_UID_A);
expect(await getChatHistory(HIST_UID_A)).toEqual([]);
});
});
describe("HTTP /api/admin/ai", () => {
let app: Awaited<ReturnType<typeof buildAiApp>>;
let adminToken: string;
@@ -212,4 +265,83 @@ describe("HTTP /api/admin/ai", () => {
expect(res.statusCode).toBe(503);
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
});
it("GET /history requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/history",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("POST /history appends and GET /history returns the thread", async () => {
const post = await app.inject({
method: "POST",
url: "/api/admin/ai/history",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: {
messages: [
{ role: "user", content: `${HIST_MARKER} dotaz` },
{ role: "assistant", content: `${HIST_MARKER} odpoved` },
],
},
});
expect(post.statusCode).toBe(200);
const get = await app.inject({
method: "GET",
url: "/api/admin/ai/history",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(get.statusCode).toBe(200);
const contents = get
.json()
.data.messages.map((m: { content: string }) => m.content);
expect(contents).toContain(`${HIST_MARKER} dotaz`);
expect(contents).toContain(`${HIST_MARKER} odpoved`);
});
it("POST /history rejects an empty messages array", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/history",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [] },
});
expect(res.statusCode).toBe(400);
});
it("DELETE /history clears the thread", async () => {
await app.inject({
method: "POST",
url: "/api/admin/ai/history",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: `${HIST_MARKER} x` }] },
});
const del = await app.inject({
method: "DELETE",
url: "/api/admin/ai/history",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
const get = await app.inject({
method: "GET",
url: "/api/admin/ai/history",
headers: { Authorization: `Bearer ${adminToken}` },
});
const contents = get
.json()
.data.messages.map((m: { content: string }) => m.content);
expect(contents).not.toContain(`${HIST_MARKER} x`);
});
});