Files
ARTEX-chixuweihu/web/src/app/(main)/system/intercept/page.tsx
T
Autumn-27 86729b65dc feat(intercept): 模型兜底审批单独计量 token 并在配置页展示
兜底审批(LLM judge)调模型判定漏网命令,其 token 消耗此前无法单独查看:
usage 行要么无 worker 归属、要么被并进复用的模型 profile 桶里。

- llmrec 增加 WithWorker 上下文覆盖,reviewer 调用前标注 worker='judge',
  让审批调用的计量行可被单独筛出(复用现有 llm_usage.worker 列,无需迁移)。
- db.JudgeUsageStats 返回累计总量 + 近 N 天每日序列(WHERE worker='judge')。
- 新增 GET /api/intercept/judge/usage。
- 配置页「模型兜底审批」开关下方新增用量卡片:调用次数 / 输入 / 输出 /
  缓存读写 五项统计 + 近 30 天每日消耗迷你柱图 + 刷新。
2026-10-04 02:43:26 -04:00

986 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
BotIcon,
ListFilterIcon,
PencilIcon,
PlusIcon,
ShieldAlertIcon,
Trash2Icon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type {
InterceptAction,
InterceptRule,
JudgeConfig,
JudgeDayUsage,
JudgeUsage,
LLMProfile,
Tool,
} from "@/lib/types";
// fmtTokens 把 token 数压成紧凑写法(1.2k / 3.4M),用于审批用量统计。
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n);
}
// JudgeStat 是一块统计数字(标签 + 数值)。
function JudgeStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="mt-0.5 text-lg font-semibold tabular-nums">{value}</div>
</div>
);
}
// JudgeSparkbars 用纯 div 画近 N 天每日消耗(输入+输出)的迷你柱图,免图表库依赖。
function JudgeSparkbars({ daily }: { daily: JudgeDayUsage[] }) {
const max = Math.max(1, ...daily.map((d) => d.input_tokens + d.output_tokens));
return (
<div className="flex h-16 items-end gap-0.5">
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const h = Math.max(2, Math.round((total / max) * 100));
return (
<div
key={d.date}
title={`${d.date} · ${d.calls} 次 · ${fmtTokens(total)} tokens`}
className="min-w-[2px] flex-1 rounded-sm bg-violet-500/60 hover:bg-violet-500"
style={{ height: `${h}%` }}
/>
);
})}
</div>
);
}
// ---- tool scope ----
// SDK tools are intentionally not seeded into the DB (they apply to every agent
// and have no per-agent binding). We hardcode them here so they still appear in
// the scope dialog.
function sdkTool(key: string, description: string): Tool {
return { key, system: true, description, schema: {}, agents: [], enabled: true, kind: "builtin" };
}
const SDK_EXEC: Tool[] = [
sdkTool("Bash", "在 shell 中执行命令"),
sdkTool("WebFetch", "发起 HTTP/HTTPS 请求(含代理支持)"),
sdkTool("web_search", "网络搜索"),
sdkTool("shell_open", "开启持久 PTY 交互会话"),
sdkTool("shell_send", "向交互会话发送输入"),
sdkTool("shell_read", "读取交互会话输出"),
sdkTool("shell_close", "关闭交互会话"),
sdkTool("shell_list", "列出所有交互会话"),
];
const SDK_WRITE: Tool[] = [
sdkTool("Write", "写入文件"),
sdkTool("Edit", "编辑文件(精确替换)"),
sdkTool("MultiEdit", "批量编辑文件"),
];
const SDK_KEYS = new Set([...SDK_EXEC, ...SDK_WRITE].map((t) => t.key));
function groupTools(dbTools: Tool[]) {
const sys: Tool[] = [], custom: Tool[] = [];
for (const t of dbTools) {
if (SDK_KEYS.has(t.key)) continue; // already covered by hardcoded groups
if (t.system) sys.push(t);
else custom.push(t);
}
return [
{ label: "执行类", tools: SDK_EXEC },
{ label: "写入/编辑类", tools: SDK_WRITE },
{ label: "系统工具", tools: sys },
{ label: "自定义工具", tools: custom },
].filter((g) => g.tools.length > 0);
}
// ---- form state ----
type RuleForm = {
name: string;
enabled: boolean;
priority: number;
match_target: "tool_name" | "tool_input";
match_type: "string" | "regex";
pattern: string;
action: InterceptAction;
message: string;
timeout_enabled: boolean;
timeout_seconds: number;
timeout_action: "deny" | "allow";
};
const defaultForm = (): RuleForm => ({
name: "",
enabled: true,
priority: 0,
match_target: "tool_name",
match_type: "string",
pattern: "",
action: "deny",
message: "",
timeout_enabled: true,
timeout_seconds: 60,
timeout_action: "deny",
});
// ---- small components ----
function ActionBadge({ action }: { action: InterceptAction }) {
if (action === "allow") return <Badge variant="secondary">允许</Badge>;
if (action === "deny") return <Badge variant="destructive">禁止</Badge>;
return <Badge variant="outline" className="border-amber-400 text-amber-600">申请</Badge>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</Label>
{children}
</div>
);
}
// ---- LLM fallback judge card ----
const FOLLOW_ACTIVE = "0"; // profile_id 0 = 跟随激活/默认配置
const defaultJudge = (): JudgeConfig => ({
enabled: false,
profile_id: 0,
prompt: "",
timeout_seconds: 15,
fail_action: "allow",
ask_timeout_seconds: 300,
ask_timeout_action: "deny",
});
function JudgeCard() {
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [usage, setUsage] = React.useState<JudgeUsage | null>(null);
// 审批用量统计:失败不打断配置页,仅在开启时拉取。
const loadUsage = React.useCallback(async () => {
try {
setUsage(await api.interceptJudgeUsage(30));
} catch {
// 忽略:统计不可用不应影响配置编辑
}
}, []);
const load = React.useCallback(async () => {
setLoading(true);
try {
const [j, ps] = await Promise.all([api.interceptGetJudgeConfig(), api.llmProfiles()]);
setCfg(j);
setProfiles(ps);
} catch (e) {
toast.error("加载模型兜底配置失败: " + (e as Error).message);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
load();
}, [load]);
// 开启后(含初次加载把开关读为 true 时)拉取审批用量统计。
React.useEffect(() => {
if (cfg.enabled) loadUsage();
}, [cfg.enabled, loadUsage]);
function patch(p: Partial<JudgeConfig>) {
setCfg((c) => ({ ...c, ...p }));
}
async function save() {
setSaving(true);
try {
await api.interceptSetJudgeConfig(cfg);
toast.success("模型兜底配置已保存");
await load(); // 回读:提示词若清空则回填内置模板
} catch (e) {
toast.error("保存失败: " + (e as Error).message);
} finally {
setSaving(false);
}
}
async function restorePrompt() {
// 清空提示词并保存 → 服务端下次返回内置模板全文,回填到输入框。
setSaving(true);
try {
await api.interceptSetJudgeConfig({ ...cfg, prompt: "" });
const j = await api.interceptGetJudgeConfig();
setCfg(j);
toast.success("已恢复内置默认模板");
} catch (e) {
toast.error("恢复失败: " + (e as Error).message);
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{/* 启用开关 —— 独立高亮条 */}
<div
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
cfg.enabled ? "border-violet-400/50 bg-violet-50/40 dark:bg-violet-950/20" : "bg-muted/40"
}`}
>
<div className="flex items-center gap-2.5">
<BotIcon className={`h-5 w-5 shrink-0 ${cfg.enabled ? "text-violet-600" : "text-muted-foreground"}`} />
<div>
<p className="text-sm font-semibold leading-tight">模型兜底审批</p>
<p className="text-xs text-muted-foreground mt-0.5">
在<span className="font-medium text-foreground">拦截范围</span>内、且<span className="font-medium text-foreground">没有任何拦截规则命中</span>的命令,才由模型做语义判断(放行 / 转人工 / 拦截)
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">{cfg.enabled ? "已启用" : "未启用"}</span>
<Switch checked={cfg.enabled} disabled={loading} onCheckedChange={(v) => patch({ enabled: v })} />
</div>
</div>
{/* 审批 Token 用量统计(全局累计,独立于各模型配置) */}
{cfg.enabled && usage && (
<Card>
<CardContent className="p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium">审批 Token 消耗</p>
<p className="text-xs text-muted-foreground">
模型兜底审批累计用量,单独计量(worker=judge),不与各模型配置的统计混在一起
</p>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={loadUsage}>
刷新
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<JudgeStat label="审批调用" value={usage.calls.toLocaleString()} />
<JudgeStat label="输入 Token" value={fmtTokens(usage.input_tokens)} />
<JudgeStat label="输出 Token" value={fmtTokens(usage.output_tokens)} />
<JudgeStat label="缓存读" value={fmtTokens(usage.cache_read_tokens)} />
<JudgeStat label="缓存写" value={fmtTokens(usage.cache_write_tokens)} />
</div>
{usage.daily.length > 0 && (
<div className="mt-4">
<p className="mb-2 text-[10px] uppercase tracking-wider text-muted-foreground">
近 30 天每日消耗(输入 + 输出)
</p>
<JudgeSparkbars daily={usage.daily} />
</div>
)}
</CardContent>
</Card>
)}
{cfg.enabled && (
<div className="grid gap-4 lg:grid-cols-5">
{/* 左:提示词编辑器(直接展开,主区域) */}
<Card className="lg:col-span-3">
<CardContent className="flex h-full flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">审批提示词</p>
<p className="text-xs text-muted-foreground">模型据此判定 ALLOW / ASK / DENY,可直接编辑</p>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={restorePrompt} disabled={saving}>
恢复默认模板
</Button>
</div>
<Textarea
className="min-h-[22rem] flex-1 resize-none font-mono text-xs leading-relaxed"
value={cfg.prompt}
onChange={(e) => patch({ prompt: e.target.value })}
placeholder="留空使用内置模板"
spellCheck={false}
/>
<p className="text-right text-[11px] text-muted-foreground">{cfg.prompt.length} 字</p>
</CardContent>
</Card>
{/* 右:判定参数(设置栏) */}
<Card className="lg:col-span-2">
<CardContent className="space-y-5 p-4">
<div className="space-y-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">判定模型与策略</p>
<Field label="审批模型">
<Select value={String(cfg.profile_id || 0)} onValueChange={(v) => patch({ profile_id: Number(v) })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={FOLLOW_ACTIVE}>跟随激活配置</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="模型判定超时(秒)">
<Input
type="number"
min={1}
value={cfg.timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) patch({ timeout_seconds: n });
}}
/>
</Field>
<Field label="模型失败时(出错 / 超时 / 无法解析)">
<Select value={cfg.fail_action} onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="allow">放行</SelectItem>
<SelectItem value="ask">转人工审批</SelectItem>
<SelectItem value="deny">拦截</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Separator />
<div className="space-y-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">人工审批(模型判为「转人工」时)</p>
<Field label="审批等待超时(秒)">
<Input
type="number"
min={5}
value={cfg.ask_timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) patch({ ask_timeout_seconds: n });
}}
/>
</Field>
<Field label="超时后默认动作">
<Select value={cfg.ask_timeout_action} onValueChange={(v) => patch({ ask_timeout_action: v as JudgeConfig["ask_timeout_action"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deny">拦截</SelectItem>
<SelectItem value="allow">放行</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</CardContent>
</Card>
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={save} disabled={saving || loading}>
{saving ? "保存中…" : "保存配置"}
</Button>
</div>
</div>
);
}
// ---- page ----
export default function InterceptPage() {
const [rules, setRules] = React.useState<InterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<InterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const [regexErr, setRegexErr] = React.useState("");
const [regexWarn, setRegexWarn] = React.useState(false); // true = JS 无法解析但可能是合法 Go 语法
// ---- tool scope dialog ----
const [scopeOpen, setScopeOpen] = React.useState(false);
const [allTools, setAllTools] = React.useState<Tool[]>([]);
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
const [scopeLoading, setScopeLoading] = React.useState(false);
const [scopeSaving, setScopeSaving] = React.useState(false);
const [scopeTools, setScopeTools] = React.useState<string[]>([]); // 页头信息条:当前进入拦截的工具
// ---- data ----
const loadScope = React.useCallback(async () => {
try {
const cfg = await api.interceptGetToolConfig();
setScopeTools(cfg.enabled_tools);
} catch {
// 信息条非关键,失败静默
}
}, []);
const load = React.useCallback(async () => {
try {
const r = await api.interceptRules();
setRules(r);
} catch {
toast.error("加载拦截规则失败");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { load(); loadScope(); }, [load, loadScope]);
React.useEffect(() => {
if (form.match_type !== "regex" || !form.pattern) { setRegexErr(""); setRegexWarn(false); return; }
try {
new RegExp(form.pattern);
setRegexErr("");
setRegexWarn(false);
} catch {
// JS RegExp 不支持 Go RE2 扩展语法(如 (?i) 内联 flag)。
// 这里只是预览校验失败,不代表 Go 端无效;交给服务端最终验证。
setRegexErr("");
setRegexWarn(true);
}
}, [form.pattern, form.match_type]);
// ---- rule handlers ----
function set(patch: Partial<RuleForm>) { setForm(f => ({ ...f, ...patch })); }
function openNew() {
setEditing(null);
setForm(defaultForm());
setRegexErr("");
setOpen(true);
}
function openEdit(rule: InterceptRule) {
setEditing(rule);
setForm({
name: rule.name, enabled: rule.enabled, priority: rule.priority,
match_target: rule.match_target, match_type: rule.match_type,
pattern: rule.pattern, action: rule.action, message: rule.message,
timeout_enabled: rule.timeout_enabled, timeout_seconds: rule.timeout_seconds,
timeout_action: rule.timeout_action,
});
setRegexErr("");
setOpen(true);
}
async function handleSave() {
if (!form.name.trim()) { toast.error("名称不能为空"); return; }
if (!form.pattern.trim()) { toast.error("模式不能为空"); return; }
if (regexErr) { toast.error("正则表达式语法无效"); return; }
setSaving(true);
try {
if (editing) {
await api.updateInterceptRule(editing.id, form);
toast.success("规则已更新");
} else {
await api.createInterceptRule(form);
toast.success("规则已创建");
}
setOpen(false);
load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
try {
await api.deleteInterceptRule(id);
toast.success("规则已删除");
load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: InterceptRule) {
try {
await api.toggleInterceptRule(rule.id, !rule.enabled);
load();
} catch (e) {
toast.error((e as Error).message);
}
}
// ---- scope handlers ----
async function openScope() {
setScopeOpen(true);
setScopeLoading(true);
try {
const [tools, cfg] = await Promise.all([api.tools(), api.interceptGetToolConfig()]);
setAllTools(tools);
setEnabledTools(new Set(cfg.enabled_tools));
} catch (e) {
toast.error("加载失败: " + (e as Error).message);
} finally {
setScopeLoading(false);
}
}
function toggleTool(key: string, val: boolean) {
setEnabledTools(prev => {
const next = new Set(prev);
if (val) next.add(key); else next.delete(key);
return next;
});
}
async function saveScope() {
setScopeSaving(true);
try {
await api.interceptSetToolConfig([...enabledTools]);
toast.success("拦截范围已保存");
setScopeTools([...enabledTools]);
setScopeOpen(false);
} catch (e) {
toast.error("保存失败: " + (e as Error).message);
} finally {
setScopeSaving(false);
}
}
const toolGroups = React.useMemo(() => groupTools(allTools), [allTools]);
// ---- render ----
return (
<div className="flex flex-1 flex-col gap-5 p-6">
{/* ---- header ---- */}
<div className="flex items-center gap-2.5">
<ShieldAlertIcon className="h-5 w-5 shrink-0" />
<div>
<h1 className="text-lg font-semibold leading-tight">命令拦截</h1>
<p className="text-sm text-muted-foreground mt-0.5">
工具执行前先按拦截规则匹配;未命中的命令可交由模型兜底判定
</p>
</div>
</div>
{/* ---- 拦截范围信息条(规则匹配与模型兜底共用:不在范围内的工具两者都不介入)---- */}
<div
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-2.5 ${
scopeTools.length === 0
? "border-amber-400/60 bg-amber-50/50 dark:bg-amber-950/20"
: "bg-muted/40"
}`}
>
<div className="flex min-w-0 items-center gap-2 text-sm">
<ListFilterIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="shrink-0 font-medium">拦截范围</span>
{scopeTools.length === 0 ? (
<span className="text-amber-700 dark:text-amber-500">
未启用任何工具 — 拦截规则与模型兜底均不会生效
</span>
) : (
<>
<Badge variant="secondary" className="shrink-0">{scopeTools.length} 个工具</Badge>
<span className="truncate text-muted-foreground" title={scopeTools.join("、")}>
{scopeTools.join("、")}
</span>
</>
)}
</div>
<Button
variant={scopeTools.length === 0 ? "default" : "outline"}
size="sm"
className="shrink-0"
onClick={openScope}
>
<ListFilterIcon className="h-4 w-4" />
调整范围
</Button>
</div>
<Tabs defaultValue="rules" className="flex-1">
<TabsList>
<TabsTrigger value="rules">拦截规则</TabsTrigger>
<TabsTrigger value="judge">模型配置</TabsTrigger>
</TabsList>
{/* ---- tab: 拦截规则 ---- */}
<TabsContent value="rules" className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
按优先级(数字越大越先)逐条匹配,首条命中的规则生效
</p>
<Button onClick={openNew} size="sm" className="shrink-0">
<PlusIcon className="h-4 w-4" />
新建规则
</Button>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
) : rules.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无规则</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
新建第一条规则
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">优先级</TableHead>
<TableHead>名称</TableHead>
<TableHead className="w-[90px]">目标</TableHead>
<TableHead className="w-[80px]">类型</TableHead>
<TableHead>模式</TableHead>
<TableHead className="w-[72px]">策略</TableHead>
<TableHead className="w-[64px] text-center">启用</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<span className="font-mono text-xs tabular-nums">{rule.priority}</span>
</TableCell>
<TableCell className="font-medium text-sm">{rule.name}</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_target === "tool_name" ? "工具名" : "输入内容"}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_type === "regex" ? "正则" : "字符串"}
</span>
</TableCell>
<TableCell className="max-w-[220px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell>
<ActionBadge action={rule.action} />
</TableCell>
<TableCell className="text-center">
<Switch
checked={rule.enabled}
onCheckedChange={() => handleToggle(rule)}
/>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon" variant="ghost" className="h-7 w-7"
onClick={() => openEdit(rule)}
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon" variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule.id)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* ---- tab: 模型配置 ---- */}
<TabsContent value="judge" className="mt-4">
<JudgeCard />
</TabsContent>
</Tabs>
{/* ---- editor sheet ---- */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? "编辑规则" : "新建规则"}</SheetTitle>
<SheetDescription className="text-xs">
优先级越大越先匹配;首条命中规则生效,后续跳过
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label="名称">
<Input
placeholder="给规则起个名字"
value={form.name}
onChange={(e) => set({ name: e.target.value })}
/>
</Field>
<Field label="优先级(数字越大越先匹配)">
<Input
type="number"
value={form.priority}
onChange={(e) => set({ priority: parseInt(e.target.value) || 0 })}
/>
</Field>
<Separator />
<Field label="匹配目标">
<Select
value={form.match_target}
onValueChange={(v) => set({ match_target: v as RuleForm["match_target"] })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tool_name">工具名(tool_name)</SelectItem>
<SelectItem value="tool_input">输入内容(tool_input JSON)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="匹配类型">
<Select
value={form.match_type}
onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="string">字符串包含</SelectItem>
<SelectItem value="regex">正则表达式</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="模式">
<Input
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{regexErr && (
<p className="text-xs text-destructive mt-1">{regexErr}</p>
)}
{regexWarn && (
<p className="text-xs text-amber-600 mt-1">包含 Go RE2 扩展语法(如 <code className="font-mono">(?i)</code>),浏览器无法预览,提交后由服务端验证</p>
)}
</Field>
<Separator />
<Field label="拦截策略">
<Select
value={form.action}
onValueChange={(v) => set({ action: v as InterceptAction })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="allow">允许 — 直接放行,跳过后续规则</SelectItem>
<SelectItem value="deny">禁止 — 阻断,返回拒绝消息给模型</SelectItem>
<SelectItem value="ask">向用户申请 — 等待审批</SelectItem>
</SelectContent>
</Select>
</Field>
{form.action !== "allow" && (
<Field label={form.action === "deny" ? "拒绝消息(返回给模型)" : "审批说明(可选)"}>
<Textarea
placeholder={form.action === "deny" ? "操作被安全策略阻止" : ""}
value={form.message}
onChange={(e) => set({ message: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
)}
{form.action === "ask" && (
<>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">启用审批超时</p>
<p className="text-xs text-muted-foreground">超时后自动处置,不再等待</p>
</div>
<Switch
checked={form.timeout_enabled}
onCheckedChange={(v) => set({ timeout_enabled: v })}
/>
</div>
{form.timeout_enabled && (
<div className="flex items-end gap-3">
<Field label="超时时间(秒)">
<Input
type="number"
min={5}
className="w-28"
value={form.timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) set({ timeout_seconds: n });
}}
/>
</Field>
<Field label="超时动作">
<Select
value={form.timeout_action}
onValueChange={(v) => set({ timeout_action: v as "deny" | "allow" })}
>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deny">自动拒绝</SelectItem>
<SelectItem value="allow">自动允许</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
</>
)}
<Separator />
<div className="flex items-center gap-3">
<Switch
id="rule-enabled"
checked={form.enabled}
onCheckedChange={(v) => set({ enabled: v })}
/>
<Label htmlFor="rule-enabled" className="cursor-pointer">启用此规则</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving || !!regexErr}>
{saving ? "保存中…" : "保存"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ---- scope dialog ---- */}
<Dialog open={scopeOpen} onOpenChange={setScopeOpen}>
<DialogContent className="sm:max-w-lg flex flex-col overflow-hidden p-0 gap-0" style={{ maxHeight: "min(80vh, 560px)" }}>
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<ListFilterIcon className="h-4 w-4" />
拦截范围
</DialogTitle>
<DialogDescription className="text-xs">
只有启用拦截的工具才会进入规则匹配;其余工具直接放行
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-5">
{scopeLoading ? (
<p className="text-sm text-muted-foreground py-4">加载中…</p>
) : (
toolGroups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <Separator className="mb-5" />}
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{group.label}
</p>
<div className="space-y-0.5">
{group.tools.map((t) => (
<div key={t.key} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Switch
id={`scope-${t.key}`}
checked={enabledTools.has(t.key)}
onCheckedChange={(v) => toggleTool(t.key, v)}
/>
<label htmlFor={`scope-${t.key}`} className="flex-1 min-w-0 cursor-pointer">
<div className="flex items-center gap-1.5">
<span className="font-mono text-sm">{t.key}</span>
{(t.kind && t.kind !== "builtin") && (
<Badge variant="outline" className="px-1 py-0 text-[10px]">{t.kind}</Badge>
)}
</div>
{t.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1">{t.description}</p>
)}
</label>
</div>
))}
</div>
</div>
))
)}
</div>
<div className="shrink-0 border-t px-6 py-3 flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setScopeOpen(false)}>取消</Button>
<Button size="sm" onClick={saveScope} disabled={scopeSaving || scopeLoading}>
{scopeSaving ? "保存中…" : "保存"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}