diff --git a/src/admin/components/dashboard/DashAssistant.tsx b/src/admin/components/dashboard/DashAssistant.tsx deleted file mode 100644 index 6423f3e..0000000 --- a/src/admin/components/dashboard/DashAssistant.tsx +++ /dev/null @@ -1,631 +0,0 @@ -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 - - - Odin - - - - {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(); - } - }} - /> - - - - ); -} diff --git a/src/admin/components/odin/OdinChat.tsx b/src/admin/components/odin/OdinChat.tsx new file mode 100644 index 0000000..e773683 --- /dev/null +++ b/src/admin/components/odin/OdinChat.tsx @@ -0,0 +1,471 @@ +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 { + aiUsageOptions, + aiConversationsOptions, + aiConversationMessagesOptions, + type ExtractedInvoice, +} from "../../lib/queries/ai"; +import type { + ChatTurn, + ReviewInvoice, + EditableField, + StagedFile, +} from "./types"; +import OdinTabs from "./OdinTabs"; +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 { 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); + + // Select the first existing conversation once the list loads (lazy-create: + // a brand-new user has none until they send the first message or press "+"). + useEffect(() => { + if (!convPending && activeId == null && conversations.length > 0) { + setActiveId(conversations[0].id); + } + }, [convPending, conversations, activeId]); + + // 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 convId = await ensureActive(); + if (convId == null) { + setBusy(false); + alert.error("Nepodařilo se vytvořit konverzaci"); + return; + } + seededId.current = convId; + 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([]); + 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(""); + 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: ["received-invoices"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Uložení selhalo"); + } finally { + setBusy(false); + } + }; + + const onNew = async () => { + if (busy) return; + 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) { + const id = b.data.conversation.id as number; + seededId.current = id; + setActiveId(id); + setTurns([]); + setReview([]); + qc.invalidateQueries({ queryKey: ["ai", "conversations"] }); + } else { + alert.error("Nepodařilo se vytvořit konverzaci"); + } + }; + + 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) { + const next = conversations.find((c) => c.id !== id)?.id ?? null; + setActiveId(next); + 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)), + ); + + return ( + + + + + AI + + + Odin + + + {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)) + } + /> + ))} + + )} + + + + ); +} diff --git a/src/admin/lib/queries/ai.ts b/src/admin/lib/queries/ai.ts index f25a64a..71d11df 100644 --- a/src/admin/lib/queries/ai.ts +++ b/src/admin/lib/queries/ai.ts @@ -59,16 +59,3 @@ export const aiConversationMessagesOptions = (id: number) => staleTime: Infinity, gcTime: 0, }); - -export const aiHistoryOptions = () => - queryOptions({ - queryKey: ["ai", "history"], - queryFn: () => - jsonQuery<{ messages: StoredChatMessage[] }>("/api/admin/ai/history"), - // Within a mount the thread is seeded once and mutated locally, so don't - // refetch on focus (staleTime Infinity). But gcTime 0 drops the cache on - // unmount, so every remount re-reads the DB (the source of truth) — this - // is what keeps a cleared/appended thread from resurrecting stale messages. - staleTime: Infinity, - gcTime: 0, - }); diff --git a/src/admin/pages/Odin.tsx b/src/admin/pages/Odin.tsx index e751a72..dc118ae 100644 --- a/src/admin/pages/Odin.tsx +++ b/src/admin/pages/Odin.tsx @@ -2,7 +2,7 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { PageEnter } from "../ui"; import { useAuth } from "../context/AuthContext"; -import DashAssistant from "../components/dashboard/DashAssistant"; +import OdinChat from "../components/odin/OdinChat"; export default function Odin() { const { hasPermission } = useAuth(); @@ -19,8 +19,8 @@ export default function Odin() { return ( - - + + );