import { useState, useRef, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; import { Card, Button, TextField } from "../../ui"; import apiFetch from "../../utils/api"; import { useAlert } from "../../context/AlertContext"; import { aiUsageOptions, aiHistoryOptions, type ExtractedInvoice, } from "../../lib/queries/ai"; interface ChatTurn { role: "user" | "assistant"; content: string; } // The review card is a *form* model: every editable field is a string (what the // inputs hold), distinct from the API's ExtractedInvoice. We convert numbers at // save time. This keeps the type honest — no `number` field holding a string. 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; } type EditableField = | "supplier_name" | "invoice_number" | "amount" | "currency" | "vat_rate" | "issue_date" | "due_date" | "description"; // A staged file carries a stable id so chip keys survive mid-list removal. interface StagedFile { id: string; file: File; } // Stable, monotonic id. NOT crypto.randomUUID() — that only exists in a secure // context (HTTPS/localhost) and throws over plain HTTP on a LAN. A counter is // unique within the session and context-independent. let uidSeq = 0; const nextUid = () => `ai-${(uidSeq += 1)}`; // How many recent turns to send the model as context (bounds tokens/cost). const MODEL_CONTEXT = 20; // Server schema caps a stored message at 8000 chars. const MAX_STORE = 8000; const plural = (n: number) => n === 1 ? "fakturu" : n >= 2 && n <= 4 ? "faktury" : "faktur"; /** A human, Czech summary of an extraction so the assistant "reports back". */ function summaryNote(reviews: ReviewInvoice[]): string { if (reviews.length === 0) return "V přílohách jsem nenašel žádnou fakturu."; const suppliers = [ ...new Set(reviews.map((r) => r.supplier_name).filter(Boolean)), ]; const supTxt = suppliers.length === 1 ? ` od ${suppliers[0]}` : suppliers.length > 1 ? ` od ${suppliers[0]} a dalších` : ""; const totals: Record = {}; for (const r of reviews) { const cur = r.currency || "CZK"; totals[cur] = (totals[cur] || 0) + (Number(r.amount) || 0); } const totalsTxt = Object.entries(totals) .map( ([cur, sum]) => `${sum.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2, })} ${cur}`, ) .join(" + "); return `Načetl jsem ${reviews.length} ${plural(reviews.length)}${supTxt}, celkem ${totalsTxt}. Zkontrolujte údaje a uložte.`; } export default function DashAssistant() { const alert = useAlert(); const qc = useQueryClient(); const { data: usage } = useQuery(aiUsageOptions()); const { data: historyData, isPending: historyPending } = useQuery(aiHistoryOptions()); const [turns, setTurns] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [review, setReview] = useState([]); const [attachments, setAttachments] = useState([]); const fileRef = useRef(null); const threadRef = useRef(null); const seeded = useRef(false); // Seed the thread from server history exactly once (first time it arrives). useEffect(() => { if (!seeded.current && historyData) { seeded.current = true; setTurns( historyData.messages.map((m) => ({ role: m.role, content: m.content })), ); } }, [historyData]); // Keep the thread scrolled to the latest message. Deliberately NOT keyed on // `review` — editing a review-card field must not yank the thread to bottom. useEffect(() => { const el = threadRef.current; if (el) el.scrollTop = el.scrollHeight; }, [turns, busy]); // AI is hidden entirely when the backend isn't configured. if (usage && usage.configured === false) return null; // Best-effort: persist the given turns to the server thread. A persistence // blip must not fail the user's interaction, but must not be swallowed. const persist = (msgs: ChatTurn[]) => { apiFetch("/api/admin/ai/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: msgs.map((m) => ({ role: m.role, content: m.content.slice(0, MAX_STORE), })), }), }) .then((r) => { if (!r.ok) return r.json().then((b) => Promise.reject(b?.error)); }) .catch((e) => console.error("[ai] history persist failed", e)); }; // Attaching only *stages* files — nothing is sent until the user presses // Odeslat. We append (multiple picks accumulate) and clear the input element. const onFiles = (files: FileList | null) => { if (!files || files.length === 0) return; setAttachments((a) => [ ...a, ...Array.from(files).map((file) => ({ id: nextUid(), file })), ]); if (fileRef.current) fileRef.current.value = ""; }; const removeAttachment = (id: string) => setAttachments((a) => a.filter((s) => s.id !== id)); // Disabled until history has settled, so the user can't send a turn before // the seed runs (which would otherwise let a late history load clobber it). const canSubmit = !historyPending && !busy && (!!input.trim() || attachments.length > 0); // Single submit (Odeslat). With staged attachments → extract invoices for // review (the typed message is shown as a caption; the assistant's summary is // the reply to it). Otherwise → a normal chat turn. const submit = async () => { if (busy) return; const text = input.trim(); const files = attachments.map((a) => a.file); if (!text && files.length === 0) return; // Any interaction means the thread is "live"; a late-arriving history load // must not overwrite it (belt-and-suspenders alongside the canSubmit gate). seeded.current = true; const prevTurns = turns; // snapshot for rollback on failure setBusy(true); const userContent = [ text, files.length > 0 ? `📎 ${files.map((f) => f.name).join(", ")}` : "", ] .filter(Boolean) .join("\n"); const optimistic: ChatTurn[] = [ ...turns, { role: "user", content: userContent }, ]; setTurns(optimistic); try { if (files.length > 0) { const fd = new FormData(); files.forEach((f) => fd.append("files", f)); const res = await apiFetch("/api/admin/ai/extract-invoices", { method: "POST", body: fd, }); const body = await res.json(); if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur"); // The server iterates parts serially and pushes one result per file in // submission order (truncating only if budget runs out mid-batch), so // files[i] is reliably this result's File — more robust than matching by // name, which would collide on duplicate filenames. const reviews: ReviewInvoice[] = ( body.data.invoices as Array<{ file_name: string; fields?: ExtractedInvoice; error?: string; }> ) .map((r, i): ReviewInvoice | null => { const f = r.fields; return f ? { uid: nextUid(), supplier_name: f.supplier_name ?? "", invoice_number: f.invoice_number ?? "", amount: f.amount != null ? String(f.amount) : "", currency: f.currency ?? "", vat_rate: f.vat_rate != null ? String(f.vat_rate) : "", issue_date: f.issue_date ?? "", due_date: f.due_date ?? "", description: f.description ?? "", file_name: r.file_name, file: files[i], } : null; }) .filter((x): x is ReviewInvoice => x !== null); setReview((prev) => [...prev, ...reviews]); const note = summaryNote(reviews); setTurns((t) => [...t, { role: "assistant", content: note }]); setInput(""); setAttachments([]); persist([ { role: "user", content: userContent }, { role: "assistant", content: note }, ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } else { // Send only recent context, and ensure it starts on a user turn (the // Anthropic API requires the first message to be from the user). let ctx = optimistic.slice(-MODEL_CONTEXT); while (ctx.length && ctx[0].role !== "user") ctx = ctx.slice(1); const res = await apiFetch("/api/admin/ai/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: ctx }), }); const body = await res.json(); if (!res.ok) throw new Error(body?.error || "Chyba AI"); const reply: string = body.data.reply; setTurns((t) => [...t, { role: "assistant", content: reply }]); setInput(""); persist([ { role: "user", content: userContent }, { role: "assistant", content: reply }, ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } } catch (e) { // Roll back the optimistic user turn and KEEP input + attachments so the // user can retry without losing their typed text or staged PDFs. setTurns(prevTurns); const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI"; alert.error(e instanceof Error ? e.message : fallback); } finally { setBusy(false); } }; const saveInvoice = async (inv: ReviewInvoice) => { if (busy) return; setBusy(true); try { const fd = new FormData(); fd.append("files", inv.file, inv.file_name); fd.append( "invoices", JSON.stringify([ { supplier_name: inv.supplier_name, invoice_number: inv.invoice_number || undefined, description: inv.description || undefined, amount: inv.amount === "" ? undefined : Number(inv.amount), currency: inv.currency, vat_rate: inv.vat_rate === "" ? undefined : Number(inv.vat_rate), issue_date: inv.issue_date || undefined, due_date: inv.due_date || undefined, }, ]), ); const res = await apiFetch("/api/admin/received-invoices", { method: "POST", body: fd, }); const body = await res.json(); if (!res.ok) throw new Error(body?.error || "Uložení selhalo"); alert.success("Faktura uložena"); setReview((r) => r.filter((x) => x.uid !== inv.uid)); qc.invalidateQueries({ queryKey: ["received-invoices"] }); } catch (e) { alert.error(e instanceof Error ? e.message : "Uložení selhalo"); } finally { setBusy(false); } }; const clearHistory = async () => { if (busy) return; setBusy(true); try { const res = await apiFetch("/api/admin/ai/history", { method: "DELETE" }); if (!res.ok) throw new Error("Nepodařilo se vymazat historii"); setTurns([]); setReview([]); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba"); } finally { setBusy(false); } }; const patch = (uid: string, field: EditableField, value: string) => setReview((r) => r.map((inv) => (inv.uid === uid ? { ...inv, [field]: value } : inv)), ); const fieldCols = { xs: "1fr", sm: "1fr 1fr" }; return ( {/* Header */} AI Asistent {usage && ( Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ {usage.budget_usd.toFixed(2)} )} {turns.length > 0 && ( )} {/* Thread */} {turns.length === 0 && !busy && ( {historyPending ? "Načítám…" : "Zeptejte se, nebo přiložte fakturu (PDF) k automatickému importu."} )} {turns.map((t, i) => ( {/* Color MUST sit on the Typography: GlobalStyles pins `p` to text.secondary, which beats a color merely inherited from the Box. An sx class on the element wins over that element rule. */} {t.content} ))} {busy && ( Pracuji… )} {/* Pending invoices to confirm — rendered OUTSIDE the scrollable thread so the Uložit button is always reachable (the page scrolls if there are many), and editing a field never yanks the chat scroll. */} {review.length > 0 && ( Faktury k potvrzení ({review.length}) {review.map((inv) => ( {inv.file_name} patch(inv.uid, "supplier_name", e.target.value) } /> patch(inv.uid, "invoice_number", e.target.value) } /> patch(inv.uid, "amount", e.target.value)} /> patch(inv.uid, "currency", e.target.value)} /> patch(inv.uid, "vat_rate", e.target.value)} /> patch(inv.uid, "issue_date", e.target.value) } /> patch(inv.uid, "due_date", e.target.value)} /> patch(inv.uid, "description", e.target.value) } /> ))} )} {/* Staged attachments */} {attachments.length > 0 && ( {attachments.map((s) => ( removeAttachment(s.id)} /> ))} )} {/* Composer */} onFiles(e.target.files)} /> setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } }} /> ); }