feat(intercept): 增加审批记录详情与执行审计

This commit is contained in:
ji ruo committed 2026-09-10 15:10:50 +08:00
1 parent 8dae851b9b
commit 9ad323dc4c
22 files changed
+1687 -535

No files matched your search

@@ -1,241 +1,7 @@
"use client";
import * as React from "react";
import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ApprovalRecords } from "@/components/approval-records";
function fmtTime(s: string) {
if (!s) return "—";
return new Date(s).toLocaleString("zh-CN", {
month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function AgentChip({ name }: { name: string }) {
if (!name) return <span className="text-muted-foreground text-xs">—</span>;
const color =
name === "planner" ? "bg-amber-500" :
name === "mainagent" ? "bg-primary" :
"bg-sky-600";
return (
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium text-white ${color}`}>
{name}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function MatchCell({ row }: { row: InterceptApprovalRow }) {
const isModel = !row.rule_id;
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
return (
<div className="space-y-0.5">
{row.rule_name ? (
<span className="text-xs text-muted-foreground">{row.rule_name}</span>
) : isModel ? (
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
<BotIcon className="h-3 w-3" />
模型判定
</Badge>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
{reason && (
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
{reason}
</p>
)}
</div>
);
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
const short = s.length > 100 ? s.slice(0, 100) + "…" : s;
return (
<Tooltip>
<TooltipTrigger asChild>
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">{short}</code>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
</TooltipContent>
</Tooltip>
);
}
interface Props {
taskId: string;
}
export function InterceptTab({ taskId }: Props) {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [deciding, setDeciding] = React.useState<number | null>(null);
const load = React.useCallback(async () => {
try {
setRows(await api.interceptTask(taskId));
} catch {
// ignore
} finally {
setLoading(false);
}
}, [taskId]);
React.useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [load]);
async function decide(id: number, decision: "allowed" | "denied") {
if (deciding !== null) return;
setDeciding(id);
try {
await api.interceptDecide(id, decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
setRows((prev) =>
prev.map((r) => r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r),
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(null);
}
}
const pending = rows.filter((r) => r.status === "pending");
if (!loading && rows.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">此任务暂无拦截审批记录</p>
</div>
);
}
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col gap-4">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldAlertIcon className="h-4 w-4 text-muted-foreground" />
拦截审批
{pending.length > 0 && (
<Badge className="rounded-full bg-amber-500 text-white text-[10px] px-1.5">
{pending.length} 待审批
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className="mr-1 h-3.5 w-3.5" />
刷新
</Button>
</div>
{/* pending block */}
{pending.length > 0 && (
<div className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-2.5 text-xs font-medium text-amber-700 dark:text-amber-400">
<ShieldAlertIcon className="h-3.5 w-3.5" />
待处理({pending.length})— 超时 5 分钟后自动拒绝
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead className="w-28">Agent</TableHead>
<TableHead>工具</TableHead>
<TableHead>规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
<TableCell><AgentChip name={row.agent_name} /></TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><MatchCell row={row} /></TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "allowed")}>
<CheckIcon className="mr-1 h-3 w-3" />允许
</Button>
<Button size="sm" variant="destructive" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "denied")}>
<XIcon className="mr-1 h-3 w-3" />拒绝
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* history table */}
<div className="rounded-lg border">
<div className="border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
全部记录({rows.length})
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead className="w-28">Agent</TableHead>
<TableHead>工具</TableHead>
<TableHead>规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
<TableCell><AgentChip name={row.agent_name} /></TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><MatchCell row={row} /></TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.decided_at ? fmtTime(row.decided_at) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TooltipProvider>
);
export function InterceptTab({ taskId }: { taskId: string }) {
return <ApprovalRecords key={taskId} taskId={taskId} />;
}
@@ -1,263 +1,9 @@
"use client";
import * as React from "react";
import { BotIcon, CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
// ---- helpers ----------------------------------------------------------------
function fmtTime(s: string) {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function SourceCell({ row }: { row: InterceptApprovalRow }) {
if (row.task_id) {
return (
<div className="space-y-0.5">
<div className="font-mono text-xs font-medium">{row.task_id}</div>
<div className="text-[11px] text-muted-foreground">{row.agent_name || "—"}</div>
</div>
);
}
if (row.conversation_id) {
const agent = row.conv_agent_key || "—";
const title = row.conv_title || `对话 #${row.conversation_id}`;
return (
<div className="space-y-0.5">
<div className="font-medium">{title}</div>
<div className="text-[11px] text-muted-foreground">Agent: {agent}</div>
</div>
);
}
return <span className="text-muted-foreground">—</span>;
}
// MatchCell shows the matched rule name, or a "模型判定" badge when the decision
// came from the LLM fallback judge (rule_id null). The reason (rule message or
// model rationale, minus the [模型] prefix) is shown beneath.
function MatchCell({ row }: { row: InterceptApprovalRow }) {
const isModel = !row.rule_id;
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
return (
<div className="space-y-0.5">
{row.rule_name ? (
<span className="text-sm">{row.rule_name}</span>
) : isModel ? (
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
<BotIcon className="h-3 w-3" />
模型判定
</Badge>
) : (
<span className="text-muted-foreground">—</span>
)}
{reason && (
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
{reason}
</p>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
const short = s.length > 120 ? s.slice(0, 120) + "…" : s;
return (
<Tooltip>
<TooltipTrigger asChild>
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">
{short}
</code>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
</TooltipContent>
</Tooltip>
);
}
// ---- main component ---------------------------------------------------------
import { ApprovalRecords } from "@/components/approval-records";
export default function ApprovalsPage() {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [deciding, setDeciding] = React.useState<number | null>(null);
async function load() {
try {
const items = await api.interceptHistory();
setRows(items);
} catch (e) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
React.useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
async function decide(id: number, decision: "allowed" | "denied") {
if (deciding !== null) return;
setDeciding(id);
try {
await api.interceptDecide(id, decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
setRows((prev) =>
prev.map((r) =>
r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r,
),
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(null);
}
}
const pending = rows.filter((r) => r.status === "pending");
const decided = rows.filter((r) => r.status !== "pending");
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-1 flex-col gap-6 p-6">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardListIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold">审批记录</h1>
{pending.length > 0 && (
<Badge className="rounded-full bg-destructive text-destructive-foreground">{pending.length} 待审批</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className="mr-1.5 h-3.5 w-3.5" />
刷新
</Button>
</div>
{/* pending section */}
{pending.length > 0 && (
<section className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-3 text-sm font-medium text-amber-700 dark:text-amber-400">
<ShieldAlertIcon className="h-4 w-4" />
待处理 ({pending.length})
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>工具</TableHead>
<TableHead>来源</TableHead>
<TableHead>匹配规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-28 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground">{row.id}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><SourceCell row={row} /></TableCell>
<TableCell><MatchCell row={row} /></TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
className="h-7 px-2 text-xs"
disabled={deciding === row.id}
onClick={() => decide(row.id, "allowed")}
>
<CheckIcon className="mr-1 h-3 w-3" />
允许
</Button>
<Button
size="sm"
variant="destructive"
className="h-7 px-2 text-xs"
disabled={deciding === row.id}
onClick={() => decide(row.id, "denied")}
>
<XIcon className="mr-1 h-3 w-3" />
拒绝
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)}
{/* history section */}
<section className="rounded-lg border">
<div className="border-b px-4 py-3 text-sm font-medium text-muted-foreground">
全部记录 ({rows.length})
</div>
{rows.length === 0 && !loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">暂无审批记录</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>工具</TableHead>
<TableHead>来源</TableHead>
<TableHead>匹配规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
<TableCell className="text-muted-foreground">{row.id}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><SourceCell row={row} /></TableCell>
<TableCell><MatchCell row={row} /></TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.decided_at ? fmtTime(row.decided_at) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<div className="flex min-w-0 flex-1 flex-col p-4 sm:p-6">
<ApprovalRecords />
</div>
</TooltipProvider>
);
}
+586
View File
@@ -0,0 +1,586 @@
"use client";
import * as React from "react";
import {
BotIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardListIcon,
CopyIcon,
RefreshCwIcon,
ShieldAlertIcon,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(value?: string) {
if (!value) return "—";
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function source(row: InterceptApprovalRow) {
if (row.decision_source) return row.decision_source;
if (row.rule_id) return "rule";
return row.reason?.startsWith("[模型]") ? "model" : "unknown";
}
function StatusBadge({ status }: { status: string }) {
const labels: Record<string, string> = { pending: "待审批", allowed: "已允许", denied: "已拒绝", timeout: "已超时" };
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
if (status === "allowed") variant = "default";
if (status === "denied") variant = "destructive";
if (status === "pending") variant = "secondary";
return <Badge variant={variant}>{labels[status] ?? status}</Badge>;
}
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
const reason = row.reason?.replace(/^\[模型\]\s*/, "");
return (
<div className="flex min-w-0 flex-col gap-1">
{source(row) === "model" ? (
<Badge variant="outline">
<BotIcon />
模型判定
</Badge>
) : (
<span className="truncate">{row.rule_name || "规则未记录或已删除"}</span>
)}
{showReason ? (
<p className="truncate text-muted-foreground text-xs" title={reason}>
{reason || "未记录理由"}
</p>
) : null}
</div>
);
}
function CodeBlock({ label, text, truncated = false }: { label: string; text: string; truncated?: boolean }) {
async function copy() {
try {
await navigator.clipboard.writeText(text);
toast.success("已复制");
} catch {
toast.error("复制失败,请手动选择内容复制");
}
}
return (
<section className="flex min-w-0 flex-col gap-2" aria-label={label}>
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-muted-foreground text-xs">{label}</h3>
{text ? (
<Button variant="ghost" size="icon-xs" aria-label={`复制${label}`} onClick={() => void copy()}>
<CopyIcon />
</Button>
) : null}
</div>
<pre className="max-h-80 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted/60 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
{text || "未记录"}
</pre>
{truncated ? <p className="text-muted-foreground text-xs">内容已截断,以上为保存的片段。</p> : null}
</section>
);
}
const contextLabels: Record<string, string> = {
user: "用户消息",
assistant: "Agent 消息",
text: "Agent 消息",
tool_use: "工具请求",
tool_result: "工具输出",
};
const actionLabels: Record<string, string> = { allow: "允许", ask: "转人工审批", deny: "拒绝" };
const executionLabels: Record<InterceptAudit["execution_status"], string> = {
not_started: "尚未执行",
not_executed: "未执行",
awaiting_result: "已允许,等待执行结果",
succeeded: "执行成功",
failed: "执行失败",
unknown: "执行结果未知",
};
type Decide = (id: number, decision: "allowed" | "denied") => Promise<void>;
function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) {
if (row.status !== "pending") return null;
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy} onClick={() => void decide(row.id, "allowed")}>
<CheckIcon data-icon="inline-start" />
允许
</Button>
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void decide(row.id, "denied")}>
<XIcon data-icon="inline-start" />
拒绝
</Button>
</div>
);
}
function ApprovalDetail({
row,
busy,
decide,
revision,
}: {
row: InterceptApprovalRow;
busy: boolean;
decide: Decide;
revision: number;
}) {
const [detail, setDetail] = React.useState<InterceptDetail | null>(null);
const [error, setError] = React.useState("");
const [retry, setRetry] = React.useState(0);
const [more, setMore] = React.useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: Status, refresh and retry invalidate details without closing the panel.
React.useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function load() {
try {
const next = await api.interceptDetail(row.id);
if (cancelled) return;
setDetail(next);
setError("");
if (next.status === "pending" || next.audit?.execution_status === "awaiting_result")
timer = setTimeout(() => void load(), 5000);
} catch (e) {
if (!cancelled) setError((e as Error).message || "详情加载失败");
}
}
void load();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [row.id, row.status, revision, retry]);
// Row updates are authoritative until the lazy detail has caught up.
const current = detail?.status === row.status ? detail : row;
const audit = detail?.audit;
let execution = audit ? executionLabels[audit.execution_status] : "未记录";
if (row.status === "pending") execution = "尚未执行";
if (audit && audit.correlation !== "exact" && audit.effective_action === "allow") execution = "未关联执行结果";
const command = typeof row.tool_input?.command === "string" ? row.tool_input.command : undefined;
let initialLabel = source(row) === "model" ? "模型初判" : "规则初判";
if (audit?.model_fallback) initialLabel = "模型异常回退";
return (
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
<div className="grid min-w-0 gap-5 rounded-xl border bg-muted/20 p-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-3">
<CodeBlock
label={`${row.agent_name || row.conv_agent_key || "Agent"} · 工具请求`}
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/>
{command ? (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" />
查看命令内容
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<CodeBlock label="命令内容" text={command} />
</CollapsibleContent>
</Collapsible>
) : null}
<p className="text-muted-foreground text-xs">
执行结果:<span className="text-foreground">{detail ? execution : "加载中…"}</span>
</p>
</div>
<div className="flex min-w-0 flex-col gap-4 lg:border-l lg:pl-5">
<h3 className="font-medium text-muted-foreground text-xs">
{current.status === "pending" ? "审查状态" : "审批裁决"}
</h3>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={current.status} />
<MatchCell row={current} showReason={false} />
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
{current.reason?.replace(/^\[模型\]\s*/, "") || "未记录审批理由"}
</p>
{audit?.decision_reason ? <p className="text-sm">{audit.decision_reason}</p> : null}
{audit?.effective_action ? <p className="text-sm">最终动作:{actionLabels[audit.effective_action]}</p> : null}
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">来源</dt>
<dd className="break-words">
{row.conv_title || (row.task_id ? `任务 ${row.task_id}` : row.agent_name || "未记录")}
</dd>
<dt className="text-muted-foreground">申请时间</dt>
<dd>{fmtTime(row.created_at)}</dd>
<dt className="text-muted-foreground">决定时间</dt>
<dd>{fmtTime(current.decided_at)}</dd>
{audit?.rule_name ? (
<>
<dt className="text-muted-foreground">规则快照</dt>
<dd>{audit.rule_name}</dd>
</>
) : null}
{audit?.profile_id ? (
<>
<dt className="text-muted-foreground">审批模型配置</dt>
<dd>#{audit.profile_id}</dd>
</>
) : null}
</dl>
<DecisionActions row={current} busy={busy} decide={decide} />
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>
<div className="flex flex-wrap items-center gap-2">
<span>详情加载失败:{error}</span>
<Button variant="outline" size="sm" onClick={() => setRetry((v) => v + 1)}>
重试详情
</Button>
</div>
</AlertDescription>
</Alert>
) : null}
{!detail && !error ? <Skeleton className="h-8 w-60" /> : null}
{detail && !audit ? (
<Alert>
<AlertDescription>此记录未保存审批详情快照,无法还原当时的上下文、模型初判和执行输出。</AlertDescription>
</Alert>
) : null}
{audit ? (
<Collapsible open={more} onOpenChange={setMore}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" className={cn(more && "rotate-180")} />
{more ? "收起上下文" : "查看上下文与执行结果"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="flex min-w-0 flex-col gap-5">
<CodeBlock label="用户消息 / 当前轮输入" text={audit.user_message} truncated={audit.user_truncated} />
<section className="flex min-w-0 flex-col gap-3">
<h3 className="font-medium text-muted-foreground text-xs">可见会话上下文</h3>
<p className="text-muted-foreground text-xs">
保存于 {fmtTime(audit.captured_at)} 的会话记录片段。模型审批目前仅接收工具名称和参数。
</p>
{audit.context_truncated ? (
<p className="text-muted-foreground text-xs">仅保存最近的上下文,部分内容已截断。</p>
) : null}
{audit.context?.length ? (
audit.context.map((entry, index) => (
<CodeBlock
key={`${entry.kind}-${entry.tool_use_id || index}`}
label={`${contextLabels[entry.kind] ?? entry.kind}${entry.tool ? ` · ${entry.tool}` : ""}${entry.is_error ? " · 异常" : ""}`}
text={entry.text}
truncated={entry.truncated}
/>
))
) : (
<p className="text-muted-foreground text-sm">未记录可关联的上下文</p>
)}
</section>
<CodeBlock
label={`${initialLabel}:${actionLabels[audit.initial_action] ?? audit.initial_action}`}
text={audit.initial_reason.replace(/^\[模型\]\s*/, "")}
/>
<CodeBlock
label="执行输出"
text={audit.output ?? (audit.execution_status === "not_executed" ? "工具未执行。" : "尚无执行输出")}
truncated={audit.output_truncated}
/>
{audit.correlation !== "exact" ? (
<Alert>
<AlertDescription>
{audit.correlation === "ambiguous"
? "存在相同参数的并发调用,无法唯一关联工具调用;本记录不展示推测的执行结果。"
: "未记录可唯一关联的工具调用 ID。"}
</AlertDescription>
</Alert>
) : null}
<dl className="grid gap-2 text-muted-foreground text-xs [overflow-wrap:anywhere]">
<div>工具调用 ID:{audit.tool_use_id || "未记录"}</div>
<div>参数摘要 SHA-256:{audit.input_digest}</div>
<div>审查配置指纹 SHA-256:{audit.config_digest || "未记录"}</div>
{audit.execution_ended_at ? <div>结果记录时间:{fmtTime(audit.execution_ended_at)}</div> : null}
</dl>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
// Hidden cells do not occupy table columns. Keep the detail span in sync so
// expansion cannot create empty columns and leave a gap in the selected row.
const approvalBreakpoints = ["(min-width: 40rem)", "(min-width: 48rem)", "(min-width: 64rem)", "(min-width: 80rem)"];
function subscribeColumns(onChange: () => void) {
const queries = approvalBreakpoints.map((query) => window.matchMedia(query));
for (const query of queries) query.addEventListener("change", onChange);
return () => {
for (const query of queries) query.removeEventListener("change", onChange);
};
}
function visibleColumnCount() {
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
}
function serverColumnCount() {
return 9;
}
function ApprovalTable({
rows,
busy,
decide,
revision,
label,
}: {
rows: InterceptApprovalRow[];
busy: boolean;
decide: Decide;
revision: number;
label: string;
}) {
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
const prefix = React.useId();
function toggle(id: number) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Table className="table-fixed" aria-label={label}>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<span className="sr-only">展开详情</span>
</TableHead>
<TableHead className="hidden w-14 sm:table-cell">#</TableHead>
<TableHead className="w-28">工具</TableHead>
<TableHead className="hidden md:table-cell">来源</TableHead>
<TableHead className="hidden lg:table-cell">匹配规则</TableHead>
<TableHead className="hidden xl:table-cell">参数</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="hidden w-36 lg:table-cell">申请时间</TableHead>
<TableHead className="hidden w-36 xl:table-cell">决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const open = expanded.has(row.id);
const panelID = `${prefix}-${row.id}`;
return (
<React.Fragment key={row.id}>
<TableRow
data-state={open ? "selected" : undefined}
className="cursor-pointer"
onClick={() => toggle(row.id)}
>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${open ? "收起" : "展开"}审批 #${row.id}`}
aria-expanded={open}
aria-controls={open ? panelID : undefined}
onClick={(e) => {
e.stopPropagation();
toggle(row.id);
}}
>
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
<TableCell>
<code className="block truncate text-xs">{row.tool_name}</code>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-col gap-1">
<span className="truncate" title={row.conv_title || row.task_id}>
{row.conv_title || row.task_id || "—"}
</span>
<span className="truncate text-muted-foreground text-xs">
{row.agent_name || row.conv_agent_key}
</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<MatchCell row={row} />
</TableCell>
<TableCell className="hidden xl:table-cell">
<code className="block truncate text-muted-foreground text-xs">{JSON.stringify(row.tool_input)}</code>
</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
{fmtTime(row.created_at)}
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs xl:table-cell">
{fmtTime(row.decided_at)}
</TableCell>
</TableRow>
{open ? (
<TableRow className="hover:bg-transparent has-aria-expanded:bg-transparent">
<TableCell colSpan={columns} className="whitespace-normal p-0">
<section id={panelID} aria-label={`审批详情 #${row.id}`}>
<ApprovalDetail row={row} busy={busy} decide={decide} revision={revision} />
</section>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
}
export function ApprovalRecords({ taskId }: { taskId?: string }) {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [error, setError] = React.useState("");
const [deciding, setDeciding] = React.useState(false);
const [revision, setRevision] = React.useState(0);
const request = React.useRef(0);
const decisionLock = React.useRef(false);
const load = React.useCallback(
async (manual = false) => {
const id = ++request.current;
if (manual) setRefreshing(true);
try {
const items = taskId ? await api.interceptTask(taskId) : await api.interceptHistory();
if (id !== request.current) return;
setRows(items);
setError("");
if (manual) setRevision((v) => v + 1);
} catch (e) {
if (id === request.current) setError((e as Error).message || "加载失败");
} finally {
if (id === request.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[taskId],
);
React.useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5000);
return () => {
request.current++;
clearInterval(timer);
};
}, [load]);
const decide: Decide = async (id, decision) => {
if (decisionLock.current) return;
decisionLock.current = true;
setDeciding(true);
try {
await api.interceptDecide(id, decision);
// Invalidate a list request started before this decision.
request.current++;
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
);
setRevision((v) => v + 1);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
} catch (e) {
toast.error((e as Error).message);
await load(true);
} finally {
decisionLock.current = false;
setDeciding(false);
}
};
const pending = rows.filter((row) => row.status === "pending");
const title = taskId ? "拦截审批" : "审批记录";
return (
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ClipboardListIcon className="size-5 text-muted-foreground" />
<h1 className="font-semibold text-xl">{title}</h1>
{pending.length ? <Badge variant="secondary">{pending.length} 待审批</Badge> : null}
</div>
<Button variant="outline" size="sm" onClick={() => void load(true)} disabled={loading || refreshing}>
<RefreshCwIcon data-icon="inline-start" className={cn(refreshing && "animate-spin")} />
刷新
</Button>
</div>
<p className="text-muted-foreground text-sm">展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。</p>
{error ? (
<Alert variant="destructive">
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
</Alert>
) : null}
{pending.length ? (
<section className="overflow-hidden rounded-xl border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
<ShieldAlertIcon className="size-4" />
待处理({pending.length})<span className="text-muted-foreground text-xs">展开后允许或拒绝</span>
</div>
<ApprovalTable rows={pending} busy={deciding} decide={decide} revision={revision} label="待处理审批" />
</section>
) : null}
<section className="overflow-hidden rounded-xl border">
<div className="border-b px-4 py-3 font-medium text-sm">
{taskId ? "全部记录" : "最近记录"}({rows.length})
</div>
{loading ? (
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : null}
{!loading && !rows.length && !error ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardListIcon />
</EmptyMedia>
<EmptyTitle>暂无审批记录</EmptyTitle>
<EmptyDescription>规则或模型作出审批决定后,记录将显示在这里。</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{rows.length ? (
<ApprovalTable rows={rows} busy={deciding} decide={decide} revision={revision} label="审批记录列表" />
) : null}
</section>
</div>
);
}
+2
View File
@@ -38,6 +38,7 @@ import type {
FindingsPage,
IntentAsset,
InterceptApprovalRow,
InterceptDetail,
InterceptPending,
InterceptRule,
JudgeConfig,
@@ -957,6 +958,7 @@ export const api = {
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
interceptDecide: (id: number, decision: "allowed" | "denied") =>
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
interceptTask: (taskId: string) =>
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
+35
View File
@@ -17,6 +17,7 @@ import type {
Finding,
FindingAsset,
InterceptApprovalRow,
InterceptAudit,
InterceptPending,
InterceptRule,
LLMPoolStatus,
@@ -3469,6 +3470,40 @@ export const interceptHistory: InterceptApprovalRow[] = [
},
];
// Approval detail fixtures use harmless report-writing examples.
interceptHistory.unshift({
id: 94, task_id: "t-acme-web", agent_name: "work#1", tool_name: "Write",
tool_input: { path: "reports/summary.md", content: "# 检查摘要\n\n本轮验证已完成,整理已有证据与后续建议。" },
status: "allowed", decision_source: "model", reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。",
created_at: T("2026-07-26T08:00:00Z"), decided_at: T("2026-07-26T08:00:02Z"),
conv_title: "", conv_agent_key: "", rule_name: "",
});
export const interceptDetails: Record<number, InterceptAudit> = {
94: {
run_id: "demo-run-report", tool_use_id: "call-write-report", correlation: "exact",
input_digest: "a3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458ec1234",
user_message: "请整理已经完成的检查,将结论和证据索引写入 reports/summary.md。\n保留待验证项,不修改业务数据。",
context: [
{ kind: "user", text: "汇总本轮已有证据,生成检查摘要。" },
{ kind: "tool_use", tool: "Read", tool_use_id: "call-read-evidence", text: '{"path":"reports/evidence-index.json"}' },
{ kind: "tool_result", tool: "Read", tool_use_id: "call-read-evidence", text: "已读取 3 条证据索引。\n记录包含请求时间、结果摘要和本地文件位置。" },
],
captured_at: T("2026-07-26T08:00:00Z"), initial_action: "allow",
initial_reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。", effective_action: "allow",
config_digest: "b4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fd1234",
profile_id: 1, execution_status: "succeeded", output: "Successfully wrote reports/summary.md\n共写入 68 个字符。",
execution_ended_at: T("2026-07-26T08:00:03Z"),
},
93: {
run_id: "demo-run-review", tool_use_id: "call-pending-review", correlation: "exact",
input_digest: "c5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670ae1234",
user_message: "检查备份表的用途,涉及删除时需要先确认。", context: [],
captured_at: T("2026-07-25T15:14:00Z"), initial_action: "ask",
initial_reason: "[模型] 疑似备份表,无法确定是否生产数据。", profile_id: 1, execution_status: "not_started",
},
};
// ── Conversations (chat) ─────────────────────────────────────────────────────
export const conversations: Conversation[] = [
{
+28 -5
View File
@@ -33,6 +33,9 @@ const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
// initialization deterministic for tests/HMR while preserving state across mock calls.
const mockInterceptHistory = structuredClone(D.interceptHistory);
const mockInterceptPending = structuredClone(D.interceptPending);
const mockInterceptDetails = structuredClone(D.interceptDetails);
const mockTasks = structuredClone(D.tasks);
const mockFindings = structuredClone(D.findings);
const mockLLMRecords = structuredClone(D.llmRecords);
@@ -1981,13 +1984,33 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
return { ok: true, enabled: b.enabled ?? true };
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
if (path === "/intercept/pending" && m === "GET") return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") {
const id = Number(seg[2]);
const row = mockInterceptHistory.find((r) => r.id === id) ?? mockInterceptPending.find((r) => r.id === id);
if (!row || row.status !== "pending") throw new Error("审批已处理或不存在,请刷新记录");
if (b.decision !== "allowed" && b.decision !== "denied") throw new Error("无效审批动作");
row.status = b.decision;
row.decided_at = new Date().toISOString();
const detail = mockInterceptDetails[id];
if (detail) {
detail.effective_action = b.decision === "allowed" ? "allow" : "deny";
detail.decision_reason = b.decision === "allowed" ? "人工允许执行" : "人工拒绝执行";
detail.execution_status = b.decision === "allowed" ? "unknown" : "not_executed";
detail.output = b.decision === "allowed" ? "演示模式未执行工具。" : "";
}
return { ok: true };
}
if (seg[0] === "intercept" && seg[1] === "history" && seg.length === 3) {
const row = mockInterceptHistory.find((r) => r.id === Number(seg[2]));
if (!row) throw new Error("审批记录不存在");
return { ...row, audit: mockInterceptDetails[row.id] ?? null };
}
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
return D.interceptPending.find((p) => p.id === Number(seg[2])) ?? null;
if (path === "/intercept/history") return { items: D.interceptHistory };
return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null;
if (path === "/intercept/history") return { items: mockInterceptHistory };
if (seg[0] === "intercept" && seg[1] === "task")
return { items: D.interceptHistory.filter((r) => r.task_id === seg[2]) };
return { items: mockInterceptHistory.filter((r) => r.task_id === seg[2]) };
if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] };
if (path === "/intercept/judge" && m === "GET")
return {
+29
View File
@@ -1133,6 +1133,7 @@ export interface InterceptRule {
}
export interface InterceptPending {
decision_source?: "rule" | "model" | "unknown" | "";
id: number;
rule_id?: number;
conversation_id?: number;
@@ -1246,3 +1247,31 @@ export interface LLMTask {
task_id: string;
count: number;
}
// Immutable review snapshot plus separately recorded execution outcome.
export interface InterceptAudit {
run_id?: string;
tool_use_id?: string;
correlation: "exact" | "ambiguous" | "unavailable";
input_digest: string;
user_message: string;
user_truncated?: boolean;
context: { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[] | null;
context_truncated?: boolean;
captured_at: string;
model_fallback?: boolean;
initial_action: "allow" | "ask" | "deny";
initial_reason: string;
effective_action?: "allow" | "deny";
decision_reason?: string;
rule_name?: string;
config_digest?: string;
profile_id?: number;
execution_status: "not_started" | "not_executed" | "awaiting_result" | "succeeded" | "failed" | "unknown";
output?: string;
output_truncated?: boolean;
execution_ended_at?: string;
}
export interface InterceptDetail extends InterceptApprovalRow {
audit: InterceptAudit | null;
}