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 apiFetch from "../../utils/api"; import { useAlert } from "../../context/AlertContext"; import { useAuth } from "../../context/AuthContext"; import { aiUsageOptions, aiConversationsOptions, aiConversationMessagesOptions, type ExtractedInvoice, } from "../../lib/queries/ai"; import type { ChatTurn, ReviewInvoice, EditableField, StagedFile, } from "./types"; import OdinSidebar from "./OdinSidebar"; import OdinThread from "./OdinThread"; import InvoiceReviewCard from "./InvoiceReviewCard"; import OdinComposer from "./OdinComposer"; // Stable, monotonic id (NOT crypto.randomUUID — undefined over plain HTTP). let uidSeq = 0; const nextUid = () => `ai-${(uidSeq += 1)}`; const MODEL_CONTEXT = 20; // recent turns sent to the model const MAX_STORE = 8000; // server caps a stored message at 8000 chars const plural = (n: number) => n === 1 ? "fakturu" : n >= 2 && n <= 4 ? "faktury" : "faktur"; 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 OdinChat() { const alert = useAlert(); const qc = useQueryClient(); const { user, hasPermission } = useAuth(); // Saving an extracted invoice hits /received-invoices (needs invoices.create); // Odin itself only needs ai.use, so the Save affordance is gated separately. const canSave = hasPermission("invoices.create"); // Time-based Czech greeting + the user's first name (client-local time). const greetingHour = new Date().getHours(); const greetingPhrase = greetingHour < 9 ? "Dobré ráno" : greetingHour < 12 ? "Dobrý den" : greetingHour < 18 ? "Dobré odpoledne" : "Dobrý večer"; const userName = (user?.fullName || user?.username || "") .trim() .split(/\s+/)[0]; const { data: usage } = useQuery(aiUsageOptions()); const { data: convData, isPending: convPending } = useQuery( aiConversationsOptions(), ); const conversations = convData?.conversations ?? []; const [activeId, setActiveId] = useState(null); const { data: messagesData } = useQuery({ ...aiConversationMessagesOptions(activeId ?? 0), enabled: activeId != null, }); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [turns, setTurns] = useState([]); const [review, setReview] = useState([]); const [attachments, setAttachments] = useState([]); const fileRef = useRef(null); const threadRef = useRef(null); const seededId = useRef(null); // No auto-select: like claude.ai, we land on a fresh "new chat" (activeId // null) and the sidebar lists existing conversations to open. A conversation // row in the DB is created lazily on the first message (see submit), so // pressing "Nová konverzace" repeatedly never spawns empty conversations. // Seed the thread from the active conversation's messages, once per id. useEffect(() => { if (activeId != null && messagesData && seededId.current !== activeId) { seededId.current = activeId; setTurns( messagesData.messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content, })), ); setReview([]); } }, [activeId, messagesData]); // Keep the thread scrolled to the latest message. 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; const messagesLoading = activeId != null && !messagesData && seededId.current !== activeId; const persist = (convId: number, msgs: ChatTurn[]) => { apiFetch(`/api/admin/ai/conversations/${convId}/messages`, { 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("[odin] history persist failed", e)); }; // Attaching only stages files; nothing is sent until Odeslat. 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)); const onSelect = (id: number) => { if (id === activeId || busy) return; setActiveId(id); setTurns([]); setReview([]); seededId.current = null; }; // Returns the active conversation id, creating one if none is selected yet. const ensureActive = async (): Promise => { if (activeId != null) return activeId; const res = await apiFetch("/api/admin/ai/conversations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const b = await res.json(); if (!res.ok || !b?.data?.conversation) return null; const id = b.data.conversation.id as number; seededId.current = id; setActiveId(id); qc.invalidateQueries({ queryKey: ["ai", "conversations"] }); return id; }; const canSubmit = !convPending && !busy && (!!input.trim() || attachments.length > 0); const submit = async () => { if (busy) return; const text = input.trim(); const files = attachments.map((a) => a.file); if (!text && files.length === 0) return; setBusy(true); const prevTurns = turns; 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"); 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([]); // Create the conversation now (first successful message) and persist. const convId = await ensureActive(); if (convId != null) persist(convId, [ { role: "user", content: userContent }, { role: "assistant", content: note }, ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } else { 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(""); // Create the conversation now (first successful message) and persist. const convId = await ensureActive(); if (convId != null) persist(convId, [ { role: "user", content: userContent }, { role: "assistant", content: reply }, ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } } catch (e) { 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: ["invoices"] }); } catch (e) { alert.error(e instanceof Error ? e.message : "Uložení selhalo"); } finally { setBusy(false); } }; // "Nová konverzace" just opens a fresh, empty composer — no DB row is created // until the first message, so repeated clicks never spawn empty conversations. const onNew = () => { if (busy) return; setActiveId(null); setTurns([]); setReview([]); setInput(""); setAttachments([]); seededId.current = null; }; const onRename = async (id: number, title: string) => { const res = await apiFetch(`/api/admin/ai/conversations/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); if (res.ok) qc.invalidateQueries({ queryKey: ["ai", "conversations"] }); else alert.error("Přejmenování selhalo"); }; const onDelete = async (id: number) => { const res = await apiFetch(`/api/admin/ai/conversations/${id}`, { method: "DELETE", }); if (!res.ok) { alert.error("Smazání selhalo"); return; } if (id === activeId) { // Deleting the open conversation returns to a fresh new chat. setActiveId(null); setTurns([]); setReview([]); seededId.current = null; } qc.invalidateQueries({ queryKey: ["ai", "conversations"] }); }; const patch = (uid: string, field: EditableField, value: string) => setReview((r) => r.map((inv) => (inv.uid === uid ? { ...inv, [field]: value } : inv)), ); const activeTitle = conversations.find((c) => c.id === activeId)?.title ?? "Nová konverzace"; return ( {/* Chat column */} {activeTitle} {usage && ( Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ {usage.budget_usd.toFixed(2)} )} {review.length > 0 && ( {review.map((inv) => ( setReview((r) => r.filter((x) => x.uid !== uid)) } /> ))} )} ); }