Installed PWAs (MIUI especially) compute svh/dvh against a viewport that includes system UI the layout viewport doesn't have, so the immersive page still scrolled in standalone mode. AppShell now measures window.innerHeight into --app-height (refreshed on resize/visualViewport, covering the minimize-restore stale-viewport Chrome bug and the keyboard), and the immersive shell + chat size from var(--app-height, 100svh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
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<string, number> = {};
|
|
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<number | null>(null);
|
|
const { data: messagesData } = useQuery({
|
|
...aiConversationMessagesOptions(activeId ?? 0),
|
|
enabled: activeId != null,
|
|
});
|
|
|
|
const [input, setInput] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
|
const [review, setReview] = useState<ReviewInvoice[]>([]);
|
|
const [attachments, setAttachments] = useState<StagedFile[]>([]);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const threadRef = useRef<HTMLDivElement>(null);
|
|
const seededId = useRef<number | null>(null);
|
|
|
|
// 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 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) => {
|
|
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<number | null> => {
|
|
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 = (
|
|
<OdinSidebar
|
|
conversations={conversations}
|
|
activeId={activeId}
|
|
busy={busy}
|
|
onSelect={onSelect}
|
|
onNew={onNew}
|
|
onRename={onRename}
|
|
onDelete={onDelete}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
// svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
|
|
// dvh-sized full-height layout always lets the page scroll by the
|
|
// browser-chrome height. svh sizes for the bar-visible viewport — the
|
|
// page never scrolls and the chat's message list scrolls internally.
|
|
// Desktop: svh === vh.
|
|
// Mobile is immersive (AppShell hides its header and main padding on
|
|
// /odin): the chat owns the whole viewport, full-bleed. --app-height
|
|
// is AppShell's live window.innerHeight measurement — standalone-PWA
|
|
// viewports misreport svh/dvh (MIUI), only the measured value fits.
|
|
height: {
|
|
xs: "var(--app-height, 100svh)",
|
|
md: "calc(100svh - 100px)",
|
|
},
|
|
display: "flex",
|
|
// Longhand on purpose: a responsive `border` shorthand lands in a
|
|
// media query AFTER borderColor and resets the color to black.
|
|
borderStyle: "solid",
|
|
borderWidth: { xs: 0, md: 1 },
|
|
borderColor: "divider",
|
|
borderRadius: { xs: 0, md: 3 },
|
|
bgcolor: "background.paper",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{isMobile ? (
|
|
<Drawer
|
|
open={sidebarOpen}
|
|
onClose={() => setSidebarOpen(false)}
|
|
ModalProps={{ keepMounted: true }}
|
|
>
|
|
{sidebar}
|
|
</Drawer>
|
|
) : (
|
|
sidebar
|
|
)}
|
|
|
|
{/* Chat column */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 1.5,
|
|
p: 2,
|
|
// Immersive mobile: respect the notch / home-indicator insets
|
|
// (env() is 0 outside standalone mode, so max() keeps the 16px).
|
|
pt: { xs: "max(16px, env(safe-area-inset-top))", md: 2 },
|
|
pb: { xs: "max(16px, env(safe-area-inset-bottom))", md: 2 },
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
{isMobile && (
|
|
<IconButton
|
|
onClick={goBack}
|
|
aria-label="Zpět"
|
|
size="small"
|
|
sx={{ ml: -0.5, flexShrink: 0 }}
|
|
>
|
|
<Box
|
|
component="svg"
|
|
viewBox="0 0 24 24"
|
|
sx={{ width: 22, height: 22 }}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="19" y1="12" x2="5" y2="12" />
|
|
<polyline points="12 19 5 12 12 5" />
|
|
</Box>
|
|
</IconButton>
|
|
)}
|
|
{isMobile && (
|
|
<IconButton
|
|
onClick={() => setSidebarOpen(true)}
|
|
aria-label="Konverzace"
|
|
size="small"
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
<Box
|
|
component="svg"
|
|
viewBox="0 0 24 24"
|
|
sx={{ width: 22, height: 22 }}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</Box>
|
|
</IconButton>
|
|
)}
|
|
<Typography
|
|
variant="subtitle1"
|
|
noWrap
|
|
sx={{ fontWeight: 600, minWidth: 0, flex: 1 }}
|
|
>
|
|
{activeTitle}
|
|
</Typography>
|
|
{usage && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
|
|
>
|
|
Utraceno: ${usage.month_spend_usd.toFixed(2)}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
<OdinThread
|
|
turns={turns}
|
|
busy={busy}
|
|
loading={messagesLoading}
|
|
greetingPhrase={greetingPhrase}
|
|
userName={userName}
|
|
threadRef={threadRef}
|
|
/>
|
|
|
|
{review.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
maxHeight: "35vh",
|
|
overflowY: "auto",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{review.map((inv) => (
|
|
<InvoiceReviewCard
|
|
key={inv.uid}
|
|
inv={inv}
|
|
busy={busy}
|
|
canSave={canSave}
|
|
onChange={patch}
|
|
onSave={saveInvoice}
|
|
onDiscard={(uid) =>
|
|
setReview((r) => r.filter((x) => x.uid !== uid))
|
|
}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
<OdinComposer
|
|
input={input}
|
|
attachments={attachments}
|
|
busy={busy}
|
|
canSubmit={canSubmit}
|
|
fileRef={fileRef}
|
|
onInput={setInput}
|
|
onFiles={onFiles}
|
|
onRemoveAttachment={removeAttachment}
|
|
onSubmit={submit}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|