chore: ARTEX 初始化

ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-07-26 04:27:45 -04:00
commit 0ef8561411
287 files changed
+70178

No files matched your search

+878
View File
@@ -0,0 +1,878 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { EyeIcon, GitCompareIcon, InfoIcon, RotateCcwIcon, SaveIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep this list in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// AgentEditor is the tabbed editor for one agent, used inside the agents-page
// drawer (and reused full-page for deep links). Tabs: 配置与提示词 / MCP / Skill /
// Tools. Config + prompt save as before; visibility + tool bindings toggle live.
export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?: () => void }) {
const [detail, setDetail] = React.useState<AgentDetail | null>(null);
const [versions, setVersions] = React.useState<PromptVersion[]>([]);
const [variables, setVariables] = React.useState<PromptVar[]>([]);
const [mcp, setMcp] = React.useState<MCPServer[]>([]);
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
const [loaded, setLoaded] = React.useState(false);
const [viewVer, setViewVer] = React.useState<PromptVersion | null>(null);
const [diffVer, setDiffVer] = React.useState<PromptVersion | null>(null);
const [prompt, setPrompt] = React.useState("");
const [mcpVisible, setMcpVisible] = React.useState<number[]>([]);
const [skillVisible, setSkillVisible] = React.useState<string[]>([]);
const [preview, setPreview] = React.useState("");
const [maxTurns, setMaxTurns] = React.useState("0");
const [runSecs, setRunSecs] = React.useState("600");
const [webSearch, setWebSearch] = React.useState(false);
const [interactiveShell, setInteractiveShell] = React.useState(false);
const [wrapup, setWrapup] = React.useState("");
const [wrapupDefault, setWrapupDefault] = React.useState("");
const [wrapupTurns, setWrapupTurns] = React.useState("0");
const [wrapupTurnsDefault, setWrapupTurnsDefault] = React.useState(5);
// 任务级超时收尾词(仅 worker/planner)
const [ttSupported, setTtSupported] = React.useState(false);
const [ttWrapup, setTtWrapup] = React.useState("");
const [ttWrapupDefault, setTtWrapupDefault] = React.useState("");
const [ttTurns, setTtTurns] = React.useState("0");
const [ttTurnsDefault, setTtTurnsDefault] = React.useState(5);
const [settings, setSettings] = React.useState<Settings | null>(null);
React.useEffect(() => {
api.mcpServers().then(setMcp).catch(() => {});
api.skills().then(setSkills).catch(() => {});
api.tools().then(setTools).catch(() => {});
api.settings().then(setSettings).catch(() => {});
}, []);
// global gates: traffic tools need 流量捕获, web search needs the master switch.
const captureOn = !!settings?.traffic_capture;
const webSearchGlobalOn = !!settings?.web_search_enabled;
const reload = React.useCallback(() => {
api
.getAgent(agentKey)
.then((d) => {
setDetail(d);
setPrompt(d.prompt ?? "");
setVariables(d.variables ?? []);
setVersions(d.versions ?? []);
setMcpVisible(d.visibility?.mcp ?? []);
setSkillVisible(d.visibility?.skill ?? []);
setMaxTurns(String(d.agent?.max_turns ?? 0));
setRunSecs(String(d.agent?.run_seconds ?? 600));
setWebSearch(!!d.agent?.web_search);
setInteractiveShell(!!d.agent?.interactive_shell);
setWrapup(d.wrapup_prompt ?? "");
setWrapupDefault(d.wrapup_default ?? "");
setWrapupTurns(String(d.wrapup_max_turns ?? 0));
setWrapupTurnsDefault(d.wrapup_max_turns_default ?? 5);
setTtSupported(!!d.task_timeout_wrapup_supported);
setTtWrapup(d.task_timeout_wrapup_prompt ?? "");
setTtWrapupDefault(d.task_timeout_wrapup_default ?? "");
setTtTurns(String(d.task_timeout_wrapup_max_turns ?? 0));
setTtTurnsDefault(d.task_timeout_wrapup_max_turns_default ?? 5);
})
.catch(() => setDetail(null))
.finally(() => setLoaded(true));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
async function doPreview() {
try {
const r = await api.previewAgentPrompt(agentKey, prompt);
setPreview(r.error ? "渲染错误:" + r.error : r.rendered);
} catch (e) {
setPreview("预览失败:" + (e as Error).message);
}
}
async function savePrompt() {
try {
const r = await api.saveAgentPrompt(agentKey, prompt);
toast.success(`已保存为版本 v${r.version}`);
reload();
onSaved?.();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetPrompt() {
try {
const r = await api.resetAgentPrompt(agentKey);
toast.success(`已恢复为内置默认(v${r.version})`);
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveWrapup() {
try {
const turns = Math.max(0, Math.floor(Number(wrapupTurns) || 0));
await api.saveAgentWrapup(agentKey, wrapup, turns);
toast.success(wrapup.trim() || turns > 0 ? "收尾配置已保存(下次运行生效)" : "已清空,将使用内置默认");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetWrapup() {
try {
await api.resetAgentWrapup(agentKey);
toast.success("已恢复为内置默认");
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveTaskTimeoutWrapup() {
try {
const turns = Math.max(0, Math.floor(Number(ttTurns) || 0));
await api.saveAgentTaskTimeoutWrapup(agentKey, ttWrapup, turns);
toast.success("任务超时收尾配置已保存(下次运行生效)");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetTaskTimeoutWrapup() {
try {
await api.resetAgentTaskTimeoutWrapup(agentKey);
toast.success("已恢复为内置默认");
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveConfig() {
try {
const n = Math.max(0, Math.floor(Number(maxTurns) || 0));
const rs = Math.max(0, Math.floor(Number(runSecs) || 0));
await api.saveAgentConfig(agentKey, { max_turns: n, run_seconds: rs, web_search: webSearch, interactive_shell: interactiveShell });
toast.success("已保存运行配置(立即生效)");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
// applyVis optimistically updates, persists, and toasts success/failure. On
// failure it reverts to the prior selection so the UI never lies about state.
async function applyVis(nextMcp: number[], nextSkill: string[], okMsg: string) {
const prevMcp = mcpVisible;
const prevSkill = skillVisible;
setMcpVisible(nextMcp);
setSkillVisible(nextSkill);
try {
await api.setAgentVisibility(agentKey, nextMcp, nextSkill);
toast.success(okMsg);
onSaved?.(); // refresh the list so the card's MCP/Skill counts stay in sync
} catch (e) {
setMcpVisible(prevMcp);
setSkillVisible(prevSkill);
toast.error("保存失败:" + (e as Error).message);
}
}
function toggleMcp(id: number) {
const on = mcpVisible.includes(id);
const name = mcp.find((m) => m.id === id)?.name ?? String(id);
applyVis(
on ? mcpVisible.filter((x) => x !== id) : [...mcpVisible, id],
skillVisible,
`${on ? "已取消" : "已开启"} MCP「${name}」可见`,
);
}
function toggleSkill(name: string) {
const on = skillVisible.includes(name);
applyVis(
mcpVisible,
on ? skillVisible.filter((x) => x !== name) : [...skillVisible, name],
`${on ? "已取消" : "已开启"} Skill「${name}」可见`,
);
}
async function toggleTool(t: Tool) {
const on = t.agents.includes(agentKey);
const nextAgents = on ? t.agents.filter((k) => k !== agentKey) : [...t.agents, agentKey];
// optimistic update
setTools((ts) => ts.map((x) => (x.key === t.key ? { ...x, agents: nextAgents } : x)));
try {
await api.saveTool(t.key, {
description: t.description,
schema: t.schema,
agents: nextAgents,
enabled: t.enabled,
});
toast.success(`${on ? "已解绑" : "已绑定"}工具「${t.key}」`);
onSaved?.(); // refresh the list so the card's 工具 count stays in sync
} catch (e) {
toast.error("保存工具绑定失败:" + (e as Error).message);
reload();
api.tools().then(setTools).catch(() => {});
}
}
if (loaded && !detail) {
return <div className="text-muted-foreground p-6 text-center text-sm">未找到 Agent:{agentKey}</div>;
}
// config is meaningless for the conversational main agent and the fixed-budget
// goals decomposer; every other agent (workers, custom assistants) honors it.
const showConfig = agentKey !== "mainagent" && agentKey !== "goals";
// web search applies to every conversational/executing agent except the one-shot
// goals decomposer; it's gated by the global master switch.
const showWebSearch = agentKey !== "goals";
// interactive shell (持久 PTY 会话工具族) 同样对除 goals 外的 agent 开放;无全局门控。
const showInteractiveShell = agentKey !== "goals";
// triggers (P3) only attach to custom agents.
const isCustom = !!detail && !detail.agent?.builtin;
return (
<Tabs defaultValue="prompt" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 w-fit">
<TabsTrigger value="prompt">配置与提示词</TabsTrigger>
<TabsTrigger value="wrapup">收尾提示词</TabsTrigger>
<TabsTrigger value="mcp">MCP</TabsTrigger>
<TabsTrigger value="skill">Skill</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
{isCustom && <TabsTrigger value="triggers">触发</TabsTrigger>}
</TabsList>
{/* 配置 + 提示词 */}
<TabsContent value="prompt" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="grid gap-4">
{(showConfig || showWebSearch || showInteractiveShell) && (
<div className="grid gap-3 rounded-md border p-3">
<div className="flex flex-wrap items-end gap-3">
{showConfig && (
<>
<div className="grid gap-1.5">
<Label htmlFor="max-turns" className="text-xs">最大循环次数(0=不限)</Label>
<Input id="max-turns" type="number" min={0} className="h-8 w-32"
value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="run-seconds" className="text-xs">运行时长(秒,0=不限)</Label>
<Input id="run-seconds" type="number" min={0} className="h-8 w-32"
value={runSecs} onChange={(e) => setRunSecs(e.target.value)} />
</div>
</>
)}
<Button size="sm" variant="outline" onClick={saveConfig}>
<SaveIcon /> 保存配置
</Button>
</div>
{showWebSearch && (
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="web-search"
checked={webSearch}
disabled={!webSearchGlobalOn}
onCheckedChange={setWebSearch}
/>
<div className="grid gap-0.5">
<Label htmlFor="web-search" className="text-sm">网络搜索</Label>
<span className="text-muted-foreground text-xs">
{webSearchGlobalOn
? "为该 Agent 开启后(点上方保存生效),可用 web_search 联网检索"
: "需先在「系统配置」开启网络搜索并配置后端,才能在此启用"}
</span>
</div>
</div>
)}
{showInteractiveShell && (
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="interactive-shell"
checked={interactiveShell}
onCheckedChange={setInteractiveShell}
/>
<div className="grid gap-0.5">
<Label htmlFor="interactive-shell" className="text-sm">交互式 Shell</Label>
<span className="text-muted-foreground text-xs">
为该 Agent 开启后(点上方保存生效),可用持久 PTY 会话工具(shell_open/send/read/close/list)驱动 msfconsole/ssh/REPL 等交互程序
</span>
</div>
</div>
)}
</div>
)}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">变量(点击插入占位符,渲染时由运行时数据替换)</Label>
<div className="flex flex-wrap gap-2">
{variables.map((v) => (
<Tooltip key={v.name}>
<TooltipTrigger asChild>
<button type="button" onClick={() => setPrompt((p) => `${p}{{.${v.name}}}`)}
className="hover:bg-muted inline-flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 font-mono text-xs">
{`{{.${v.name}}}`}
<Badge variant="secondary" className="px-1 py-0 text-[10px]">{v.source}</Badge>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-medium">{v.description}</p>
<p className="text-muted-foreground mt-1">示例:{v.example}</p>
</TooltipContent>
</Tooltip>
))}
{variables.length === 0 && <span className="text-muted-foreground text-xs">(无变量)</span>}
</div>
</div>
<Textarea className="font-mono text-xs" rows={16} value={prompt}
placeholder="留空则使用内置默认提示词" onChange={(e) => setPrompt(e.target.value)} />
<div className="flex flex-wrap gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={doPreview}>
<EyeIcon /> 预览渲染
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>渲染预览</DialogTitle>
<DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{preview}
</pre>
</DialogContent>
</Dialog>
<Button size="sm" onClick={savePrompt}>
<SaveIcon /> 保存为新版本
</Button>
<Button variant="outline" size="sm" onClick={resetPrompt}>
<RotateCcwIcon /> 恢复默认
</Button>
</div>
<Separator />
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">版本历史</Label>
<ul className="grid gap-1">
{versions.map((ver, i) => (
<li key={ver.version} className="flex items-center gap-2 rounded-md px-1 py-0.5 text-xs hover:bg-muted/50">
<span className="font-mono shrink-0">v{ver.version}</span>
{i === 0 && <Badge variant="secondary" className="px-1.5 py-0 shrink-0">当前</Badge>}
<span className="text-muted-foreground truncate flex-1">{ver.note}</span>
{ver.ts && (
<span className="text-muted-foreground/60 shrink-0 tabular-nums">
{new Date(ver.ts).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
)}
<Button variant="ghost" size="icon-sm" className="size-6 shrink-0" onClick={() => setViewVer(ver)}>
<EyeIcon className="size-3" />
</Button>
{i > 0 && versions[0] && (
<Button variant="ghost" size="icon-sm" className="size-6 shrink-0" onClick={() => setDiffVer(ver)}>
<GitCompareIcon className="size-3" />
</Button>
)}
</li>
))}
{versions.length === 0 && (
<li className="text-muted-foreground text-xs">(暂无保存的版本,使用内置默认)</li>
)}
</ul>
</div>
{/* 查看版本 dialog */}
<Dialog open={!!viewVer} onOpenChange={(o) => { if (!o) setViewVer(null); }}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
v{viewVer?.version}
{viewVer?.version === versions[0]?.version && (
<Badge variant="secondary" className="ml-2 px-1.5 py-0 align-middle">当前</Badge>
)}
</DialogTitle>
<DialogDescription>
{viewVer?.note || "(无备注)"}
{viewVer?.ts && (
<span className="ml-2 text-muted-foreground/60">
{new Date(viewVer.ts).toLocaleString("zh-CN")}
</span>
)}
</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[55vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{viewVer?.template_text || "(空)"}
</pre>
<div className="flex gap-2 justify-end">
{viewVer && viewVer.version !== versions[0]?.version && (
<Button variant="outline" size="sm" onClick={() => {
if (viewVer) { setDiffVer(viewVer); setViewVer(null); }
}}>
<GitCompareIcon className="mr-1 size-3.5" /> 与当前版本对比
</Button>
)}
<Button size="sm" onClick={() => {
if (viewVer) { setPrompt(viewVer.template_text); setViewVer(null); toast.success(`已加载 v${viewVer.version} 到编辑器,确认后点「保存为新版本」`); }
}}>
加载到编辑器
</Button>
</div>
</DialogContent>
</Dialog>
{/* 版本对比 dialog */}
<Dialog open={!!diffVer} onOpenChange={(o) => { if (!o) setDiffVer(null); }}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>版本对比:v{diffVer?.version} → v{versions[0]?.version}(当前)</DialogTitle>
<DialogDescription>
<span className="inline-flex items-center gap-3 text-xs">
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-600 dark:text-red-400">- 删除</span>
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-600 dark:text-green-400">+ 新增</span>
</span>
</DialogDescription>
</DialogHeader>
<DiffView oldText={diffVer?.template_text ?? ""} newText={versions[0]?.template_text ?? ""} />
</DialogContent>
</Dialog>
</div>
</TabsContent>
{/* 收尾提示词 */}
<TabsContent value="wrapup" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="grid gap-3">
<p className="text-muted-foreground text-xs leading-relaxed">
当此 Agent 因<b>超时</b>或<b>步数耗尽</b>被终止时,系统会注入这段「收尾提示词」跑一轮收尾:
先把已识别但未落库的内容写回,再输出一句总结(避免烂尾)。留空则使用内置默认。
</p>
<div className="flex items-center gap-2">
<Label className="text-xs">收尾提示词正文</Label>
{wrapup.trim() ? (
<Badge variant="secondary" className="px-1.5 py-0">自定义</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0">使用内置默认</Badge>
)}
</div>
<Textarea
className="font-mono text-xs"
rows={10}
value={wrapup}
placeholder={wrapupDefault || "留空则使用内置默认收尾提示词"}
onChange={(e) => setWrapup(e.target.value)}
/>
<div className="grid gap-1.5">
<Label htmlFor="wrapup-turns" className="text-xs">
收尾轮数(收尾阶段自身最多跑几轮;0=用内置默认 {wrapupTurnsDefault} 轮)
</Label>
<Input id="wrapup-turns" type="number" min={0} className="h-8 w-32"
value={wrapupTurns} onChange={(e) => setWrapupTurns(e.target.value)} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveWrapup}>保存</Button>
<Button size="sm" variant="outline" onClick={resetWrapup}>恢复默认</Button>
</div>
{wrapupDefault && (
<>
<Separator />
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">内置默认(只读,供参考)</Label>
<pre className="text-muted-foreground max-h-40 overflow-y-auto rounded-md border bg-muted/30 p-2 text-xs whitespace-pre-wrap">
{wrapupDefault}
</pre>
</div>
</>
)}
{ttSupported && (
<>
<Separator className="my-2" />
<p className="text-muted-foreground text-xs leading-relaxed">
<b>任务超时收尾</b>(与上面的 per-run 收尾是<b>两套</b>):当<b>整个任务</b>到达超时上限、即将结束时注入。
与 per-run 语义常相反(如 planner:per-run 说“别停继续规划”,任务超时说“到点停止、做最后判定”)。留空则用内置默认。
</p>
<div className="flex items-center gap-2">
<Label className="text-xs">任务超时收尾提示词正文</Label>
{ttWrapup.trim() ? (
<Badge variant="secondary" className="px-1.5 py-0">自定义</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0">使用内置默认</Badge>
)}
</div>
<Textarea
className="font-mono text-xs"
rows={10}
value={ttWrapup}
placeholder={ttWrapupDefault || "留空则使用内置默认任务超时收尾提示词"}
onChange={(e) => setTtWrapup(e.target.value)}
/>
<div className="grid gap-1.5">
<Label htmlFor="tt-turns" className="text-xs">
收尾轮数(0=用内置默认 {ttTurnsDefault} 轮)
</Label>
<Input id="tt-turns" type="number" min={0} className="h-8 w-32"
value={ttTurns} onChange={(e) => setTtTurns(e.target.value)} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveTaskTimeoutWrapup}>保存</Button>
<Button size="sm" variant="outline" onClick={resetTaskTimeoutWrapup}>恢复默认</Button>
</div>
{ttWrapupDefault && (
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">内置默认(只读,供参考)</Label>
<pre className="text-muted-foreground max-h-40 overflow-y-auto rounded-md border bg-muted/30 p-2 text-xs whitespace-pre-wrap">
{ttWrapupDefault}
</pre>
</div>
)}
</>
)}
</div>
</TabsContent>
{/* MCP 可见性 */}
<TabsContent value="mcp" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选此 Agent 可见的 MCP 服务器。</p>
<div className="grid gap-2">
{mcp.map((m) => (
<label key={m.id} className="flex items-center gap-2 rounded-md border p-2 text-sm">
<Checkbox checked={mcpVisible.includes(m.id)} onCheckedChange={() => toggleMcp(m.id)} />
{m.name}
<span className="text-muted-foreground ml-auto text-xs">{m.transport}</span>
</label>
))}
{mcp.length === 0 && <span className="text-muted-foreground text-xs">(暂无 MCP)</span>}
</div>
</TabsContent>
{/* Skill 可见性 */}
<TabsContent value="skill" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选此 Agent 可见的 Skill。</p>
<div className="grid gap-2">
{skills.map((s) => (
<label key={s.name} className="flex items-center gap-2 rounded-md border p-2 text-sm">
<Checkbox checked={skillVisible.includes(s.name)} onCheckedChange={() => toggleSkill(s.name)} />
<span className="font-mono text-xs">{s.name}</span>
{s.description && <span className="text-muted-foreground ml-auto truncate text-xs">{s.description}</span>}
</label>
))}
{skills.length === 0 && <span className="text-muted-foreground text-xs">(暂无 Skill)</span>}
</div>
</TabsContent>
{/* Tools 绑定 */}
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选绑定给此 Agent 的内置工具。</p>
<div className="grid gap-2">
{tools.map((t) => {
const isTraffic = TRAFFIC_TOOL_KEYS.has(t.key);
const gated = isTraffic && !captureOn; // traffic tools need 流量捕获 on
return (
<label
key={t.key}
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm",
gated && "opacity-60",
)}
>
<Checkbox
checked={t.agents.includes(agentKey)}
disabled={gated}
onCheckedChange={() => toggleTool(t)}
/>
<span className="font-mono text-xs">{t.key}</span>
{isTraffic && (
<Badge variant="secondary" className="px-1 py-0 text-[9px]">流量</Badge>
)}
{!t.enabled && (
<Badge variant="outline" className="text-destructive px-1 py-0 text-[9px]">已停用</Badge>
)}
{gated ? (
<span className="text-muted-foreground ml-auto text-xs">需开启流量捕获</span>
) : (
t.description && (
<span className="text-muted-foreground ml-auto line-clamp-1 max-w-[55%] text-xs">
{t.description}
</span>
)
)}
</label>
);
})}
{tools.length === 0 && <span className="text-muted-foreground text-xs">(暂无工具)</span>}
</div>
</TabsContent>
{/* 触发(P3, 仅自定义 agent) */}
{isCustom && (
<TabsContent value="triggers" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<AgentTriggersTab agentKey={agentKey} />
</TabsContent>
)}
</Tabs>
);
}
// ---------- Diff helpers ----------
type DiffLine = { type: "same" | "add" | "del"; text: string };
function computeDiff(oldText: string, newText: string): DiffLine[] {
const a = oldText.split("\n");
const b = newText.split("\n");
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
const result: DiffLine[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
result.unshift({ type: "same", text: a[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({ type: "add", text: b[j - 1] });
j--;
} else {
result.unshift({ type: "del", text: a[i - 1] });
i--;
}
}
return result;
}
function DiffView({ oldText, newText }: { oldText: string; newText: string }) {
const lines = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
return (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-xs leading-5">
{lines.map((l, idx) => (
<div
key={idx}
className={cn(
"whitespace-pre-wrap px-1",
l.type === "del" && "bg-red-500/15 text-red-700 dark:text-red-400",
l.type === "add" && "bg-green-500/15 text-green-700 dark:text-green-400",
l.type === "same" && "text-muted-foreground",
)}
>
<span className="select-none mr-1 opacity-50">{l.type === "del" ? "-" : l.type === "add" ? "+" : " "}</span>
{l.text}
</div>
))}
</pre>
);
}
// AgentTriggersTab manages a custom agent's P3 triggers: list + add + delete.
// Each trigger fires (定时/发现finding/目标达成,可多选) → a new conversation runs
// in parallel with the base user message + auto context appended by the backend.
function AgentTriggersTab({ agentKey }: { agentKey: string }) {
const [triggers, setTriggers] = React.useState<AgentTrigger[]>([]);
const [intervalSec, setIntervalSec] = React.useState("0");
const [onFinding, setOnFinding] = React.useState(false);
const [onGoalMet, setOnGoalMet] = React.useState(false);
const [onTaskTimeout, setOnTaskTimeout] = React.useState(false);
const [intervalMsg, setIntervalMsg] = React.useState("");
const [findingMsg, setFindingMsg] = React.useState("");
const [goalMsg, setGoalMsg] = React.useState("");
const [taskTimeoutMsg, setTaskTimeoutMsg] = React.useState("");
const [saving, setSaving] = React.useState(false);
const reload = React.useCallback(() => {
api.agentTriggers(agentKey).then(setTriggers).catch(() => setTriggers([]));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
async function create() {
const n = Math.max(0, Math.floor(Number(intervalSec) || 0));
if (n === 0 && !onFinding && !onGoalMet && !onTaskTimeout) {
toast.error("至少选择一种触发条件");
return;
}
setSaving(true);
try {
await api.createTrigger(agentKey, {
enabled: true,
interval_sec: n,
on_finding: onFinding,
on_goal_met: onGoalMet,
on_task_timeout: onTaskTimeout,
interval_message: intervalMsg.trim(),
finding_message: findingMsg.trim(),
goal_message: goalMsg.trim(),
task_timeout_message: taskTimeoutMsg.trim(),
});
toast.success("已添加触发器");
setIntervalSec("0");
setOnFinding(false);
setOnGoalMet(false);
setOnTaskTimeout(false);
setIntervalMsg("");
setFindingMsg("");
setGoalMsg("");
setTaskTimeoutMsg("");
reload();
} catch (e) {
toast.error("添加失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function toggleEnabled(t: AgentTrigger) {
try {
await api.updateTrigger(t.id, {
enabled: !t.enabled,
interval_sec: t.interval_sec,
on_finding: t.on_finding,
on_goal_met: t.on_goal_met,
on_task_timeout: t.on_task_timeout,
interval_message: t.interval_message,
finding_message: t.finding_message,
goal_message: t.goal_message,
task_timeout_message: t.task_timeout_message,
});
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function del(id: number) {
try {
await api.deleteTrigger(id);
reload();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
function condLabel(t: AgentTrigger): string {
const parts: string[] = [];
if (t.interval_sec > 0) parts.push(`每 ${t.interval_sec}s`);
if (t.on_finding) parts.push("发现 finding");
if (t.on_goal_met) parts.push("目标达成");
if (t.on_task_timeout) parts.push("任务超时");
return parts.join(" · ") || "(无条件)";
}
return (
<div className="grid gap-4">
<p className="text-muted-foreground text-xs">
触发器让这个自定义 Agent 自动运行:每次触发都<b>新建一个会话并行运行</b>(在「对话」页可见)。
可多选触发条件;系统会把「本次为何触发 + 相关任务/finding/目标」自动附加到你写的基础消息后面。
</p>
{/* 新增触发器 */}
<div className="grid gap-3 rounded-md border p-3">
<Label className="text-muted-foreground text-xs">新增触发器(每种条件可填各自的用户消息)</Label>
{/* 定时 */}
<div className="grid gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="tr-interval" className="text-xs">定时触发:每</Label>
<Input id="tr-interval" type="number" min={0} className="h-8 w-24"
value={intervalSec} onChange={(e) => setIntervalSec(e.target.value)} />
<span className="text-muted-foreground text-xs">秒(0=关)</span>
</div>
{Number(intervalSec) > 0 && (
<Textarea className="text-xs" rows={2} value={intervalMsg}
placeholder="定时触发时发给 agent 的话,如:巡检所有任务" onChange={(e) => setIntervalMsg(e.target.value)} />
)}
</div>
{/* finding */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onFinding} onCheckedChange={(v) => setOnFinding(!!v)} /> 发现 finding 时触发
</label>
{onFinding && (
<Textarea className="text-xs" rows={2} value={findingMsg}
placeholder="发现 finding 时发给 agent 的话(系统会附带任务与 finding 详情)" onChange={(e) => setFindingMsg(e.target.value)} />
)}
</div>
{/* 目标达成 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onGoalMet} onCheckedChange={(v) => setOnGoalMet(!!v)} /> 目标达成时触发
</label>
{onGoalMet && (
<Textarea className="text-xs" rows={2} value={goalMsg}
placeholder="目标达成时发给 agent 的话(系统会附带任务与达成的目标)" onChange={(e) => setGoalMsg(e.target.value)} />
)}
</div>
{/* 任务超时 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onTaskTimeout} onCheckedChange={(v) => setOnTaskTimeout(!!v)} /> 任务超时时触发
</label>
{onTaskTimeout && (
<Textarea className="text-xs" rows={2} value={taskTimeoutMsg}
placeholder="任务超时时发给 agent 的话(系统会附带任务编号与目标)" onChange={(e) => setTaskTimeoutMsg(e.target.value)} />
)}
</div>
<div>
<Button size="sm" onClick={create} disabled={saving}>
<SaveIcon /> 添加触发器
</Button>
</div>
</div>
{/* 已有触发器 */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">已有触发器</Label>
{triggers.length === 0 && <span className="text-muted-foreground text-xs">(暂无)</span>}
{triggers.map((t) => (
<div key={t.id} className="flex items-start gap-2 rounded-md border p-2 text-sm">
<Switch checked={t.enabled} onCheckedChange={() => toggleEnabled(t)} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{condLabel(t)}</span>
{!t.enabled && (
<Badge variant="outline" className="text-destructive px-1 py-0 text-[9px]">已停用</Badge>
)}
</div>
<div className="text-muted-foreground grid gap-0.5 text-xs">
{t.interval_sec > 0 && t.interval_message && <div className="line-clamp-1">定时:{t.interval_message}</div>}
{t.on_finding && t.finding_message && <div className="line-clamp-1">finding:{t.finding_message}</div>}
{t.on_goal_met && t.goal_message && <div className="line-clamp-1">目标:{t.goal_message}</div>}
{t.on_task_timeout && t.task_timeout_message && <div className="line-clamp-1">超时:{t.task_timeout_message}</div>}
</div>
</div>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-destructive"
onClick={() => del(t.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
);
}