mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
兜底审批(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 天每日消耗迷你柱图 + 刷新。
986 lines
36 KiB
TypeScript
986 lines
36 KiB
TypeScript
"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>
|
||
);
|
||
}
|