feat(odin): Phase 2a read-only agentic assistant + tool-trace UI

- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 23:31:50 +02:00
parent 2dbacc3bec
commit 674b44d047
26 changed files with 13728 additions and 2935 deletions

View File

@@ -122,6 +122,7 @@ export default function OdinChat() {
messagesData.messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
tools: m.meta?.tools,
})),
);
setReview([]);
@@ -148,6 +149,9 @@ export default function OdinChat() {
messages: msgs.map((m) => ({
role: m.role,
content: m.content.slice(0, MAX_STORE),
...(m.tools && m.tools.length > 0
? { meta: { tools: m.tools } }
: {}),
})),
}),
})
@@ -208,24 +212,8 @@ export default function OdinChat() {
const files = attachments.map((a) => a.file);
if (!text && files.length === 0) return;
// Guard: Odin is scoped to invoice processing only (for now). A text-only
// message makes NO AI call — it gets a canned reply locally, so open-ended
// chat can't burn API credits. The invoice path (attachments) runs normally.
// (Removing this guard re-enables the general /chat path below for Phase 2.)
if (files.length === 0) {
setTurns((t) => [
...t,
{ role: "user", content: text },
{
role: "assistant",
content:
"Momentálně umím zpracovat pouze přijaté faktury. Přiložte prosím fakturu (PDF) a já z ní načtu údaje k uložení.",
},
]);
setInput("");
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;
@@ -301,14 +289,17 @@ export default function OdinChat() {
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 }]);
const tools: ChatTurn["tools"] = Array.isArray(body.data.tool_trace)
? body.data.tool_trace
: undefined;
setTurns((t) => [...t, { role: "assistant", content: reply, tools }]);
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 },
{ role: "assistant", content: reply, tools },
]);
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
}
@@ -495,8 +486,7 @@ export default function OdinChat() {
color="text.secondary"
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
>
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
{usage.budget_usd.toFixed(2)}
Utraceno: ${usage.month_spend_usd.toFixed(2)}
</Typography>
)}
</Box>

View File

@@ -1,5 +1,6 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark";
@@ -197,6 +198,23 @@ export default function OdinThread({
bgcolor: isUser ? "primary.main" : "background.paper",
}}
>
{/* Tools the assistant consulted for this turn (Phase 2a). */}
{!isUser && t.tools && t.tools.length > 0 && (
<Box
sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mb: 0.75 }}
>
{t.tools.map((tool, j) => (
<Chip
key={`${tool.name}-${j}`}
size="small"
label={`🔍 ${tool.label}`}
color={tool.ok ? "default" : "warning"}
variant="outlined"
sx={{ fontSize: "0.7rem", height: 22 }}
/>
))}
</Box>
)}
{/* 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. */}

View File

@@ -1,6 +1,14 @@
export interface ToolTraceEntry {
name: string;
label: string;
ok: boolean;
}
export interface ChatTurn {
role: "user" | "assistant";
content: string;
/** Read-only tools the assistant called while producing this turn. */
tools?: ToolTraceEntry[];
}
export interface ReviewInvoice {

View File

@@ -23,6 +23,10 @@ export interface StoredChatMessage {
role: "user" | "assistant";
content: string;
created_at: string;
/** Parsed content_json — the assistant turn's tool trace, when present. */
meta?: {
tools?: { name: string; label: string; ok: boolean }[];
} | null;
}
export const aiUsageOptions = () =>

View File

@@ -117,8 +117,29 @@ const MODULE_LABELS: Record<string, string> = {
customers: "Zákazníci",
users: "Uživatelé",
settings: "Nastavení",
ai: "AI asistent",
};
// Display order of the permission-module groups in the role modal. Before
// this list the order fell out of DB insertion ids, which differ between dev
// (reseeded) and prod (migration order). Reorder by rearranging this array;
// modules missing from it (e.g. added by a future migration before this list
// is updated) fall to the very end so they stay visible.
const MODULE_ORDER = [
"ai",
"attendance",
"trips",
"vehicles",
"offers",
"orders",
"projects",
"invoices",
"warehouse",
"customers",
"users",
"settings",
];
interface Permission {
id: number;
name: string;
@@ -1243,8 +1264,12 @@ export default function Settings() {
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === "settings") return 1;
if (b === "settings") return -1;
const ai = MODULE_ORDER.indexOf(a);
const bi = MODULE_ORDER.indexOf(b);
const aKey = ai === -1 ? MODULE_ORDER.length : ai;
const bKey = bi === -1 ? MODULE_ORDER.length : bi;
if (aKey !== bKey) return aKey - bKey;
// Both unknown: stable fallback by DB insertion order.
const aMin = Math.min(...aPerms.map((p) => p.id));
const bMin = Math.min(...bPerms.map((p) => p.id));
return aMin - bMin;