import { useState, useRef, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Drawer from "@mui/material/Drawer"; import IconButton from "@mui/material/IconButton"; import useMediaQuery from "@mui/material/useMediaQuery"; 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); // Drag & drop over the chat column. The depth counter pairs enter/leave // events bubbling from children so the overlay doesn't flicker mid-drag. const [dragOver, setDragOver] = useState(false); const dragDepth = useRef(0); // Below md the conversation list collapses into a slide-in drawer. const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), { noSsr: true, }); const [sidebarOpen, setSidebarOpen] = useState(false); // Mobile is immersive (no AppShell header on /odin) — this is the only way // back. A deep link straight to /odin has no in-app history → go home. const navigate = useNavigate(); const goBack = () => { if (((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0) { navigate(-1); } else { navigate("/"); } }; // 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, tools: m.meta?.tools, })), ); 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), ...(m.tools && m.tools.length > 0 ? { meta: { tools: m.tools } } : {}), })), }), }) .then((r) => { if (!r.ok) return r.json().then((b) => Promise.reject(b?.error)); // The append applies the server-side auto-title and bumps updated_at, // so refresh the list now (a new conversation gets its real title here). qc.invalidateQueries({ queryKey: ["ai", "conversations"] }); }) .catch((e) => console.error("[odin] history persist failed", e)); }; // Attaching only stages files; nothing is sent until Odeslat. const stageFiles = (files: File[]) => { if (files.length === 0) return; setAttachments((a) => [ ...a, ...files.map((file) => ({ id: nextUid(), file })), ]); }; const onFiles = (files: FileList | null) => { if (!files || files.length === 0) return; stageFiles(Array.from(files)); if (fileRef.current) fileRef.current.value = ""; }; const removeAttachment = (id: string) => setAttachments((a) => a.filter((s) => s.id !== id)); // Drag & drop anywhere over the chat column stages files exactly like the // paperclip (same accept filter as the file input). Only file drags react — // text/link drags pass through untouched — and nothing stages while busy. const isAccepted = (f: File) => f.type === "application/pdf" || f.type.startsWith("image/"); const dragHasFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files"); const onDragEnter = (e: React.DragEvent) => { if (busy || !dragHasFiles(e)) return; e.preventDefault(); dragDepth.current += 1; setDragOver(true); }; const onDragOver = (e: React.DragEvent) => { if (!dragHasFiles(e)) return; // preventDefault permits the drop (and keeps the browser from replacing // the app with the dropped file); while busy the drop itself is refused. e.preventDefault(); if (busy) e.dataTransfer.dropEffect = "none"; }; const onDragLeave = () => { if (dragDepth.current === 0) return; dragDepth.current -= 1; if (dragDepth.current === 0) setDragOver(false); }; const onDrop = (e: React.DragEvent) => { if (!dragHasFiles(e)) return; e.preventDefault(); dragDepth.current = 0; setDragOver(false); if (busy) return; const dropped = Array.from(e.dataTransfer.files); const accepted = dropped.filter(isAccepted); if (accepted.length === 0) { // Nothing usable — hint instead of silently doing nothing. if (dropped.length > 0) alert.info("Podporované soubory: PDF a obrázky"); return; } stageFiles(accepted); }; const onSelect = (id: number) => { setSidebarOpen(false); 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); // Don't refresh the list yet — the conversation is still the default // "Nová konverzace" here; persist() invalidates after the auto-title lands. 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; // Phase 2a: text messages go to the agentic /chat endpoint (read-only // tools over system data); attachments keep the invoice-extract path. setBusy(true); const prevTurns = turns; const prevInput = input; const prevAttachments = attachments; 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); // Clear the composer immediately — the message now lives in the thread // as the optimistic bubble; a failure restores both below. setInput(""); setAttachments([]); 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 }]); // 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; const tools: ChatTurn["tools"] = Array.isArray(body.data.tool_trace) ? body.data.tool_trace : undefined; setTurns((t) => [...t, { role: "assistant", content: reply, tools }]); // 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, tools }, ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } } catch (e) { setTurns(prevTurns); // Give the user their message back to retry/edit. setInput(prevInput); setAttachments(prevAttachments); 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 = () => { setSidebarOpen(false); 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"; const sidebar = ( ); return ( {isMobile ? ( setSidebarOpen(false)} ModalProps={{ keepMounted: true }} > {sidebar} ) : ( sidebar )} {/* Chat column — also the drop target for invoice files (overlay below). */} {isMobile && ( )} {isMobile && ( setSidebarOpen(true)} aria-label="Konverzace" size="small" sx={{ flexShrink: 0 }} > )} {activeTitle} {usage && ( Utraceno: ${usage.month_spend_usd.toFixed(2)} )} {review.length > 0 && ( *": { flexShrink: 0 }, }} > {review.map((inv) => ( setReview((r) => r.filter((x) => x.uid !== uid)) } /> ))} )} {/* Drop-target overlay — pointer-events pass through so the drag events keep firing on the column beneath it. */} {dragOver && ( ({ position: "absolute", inset: 0, zIndex: 10, pointerEvents: "none", display: "flex", alignItems: "center", justifyContent: "center", // Longhand on purpose — see the container border note above. borderStyle: "dashed", borderWidth: 2, borderColor: "primary.main", borderRadius: { xs: 0, md: 3 }, bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.08)`, })} > Přetáhněte soubory sem PDF nebo obrázky faktur )} ); }