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:
@@ -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>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user