Files
ARTEX/web/src/components/agent-editor.tsx
T

1160 lines
52 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 { EyeIcon, GitCompareIcon, InfoIcon, PencilIcon, RotateCcwIcon, SaveIcon, Trash2Icon, XIcon } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { Markdown } from "@/components/markdown";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { Agent, 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");
// "" = 跟随(未绑定);否则为 profile id 字符串
const [llmProfileId, setLlmProfileId] = React.useState("");
const [llmProfiles, setLlmProfiles] = React.useState<NonNullable<AgentDetail["llm_profiles"]>>([]);
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));
setLlmProfileId(d.agent?.llm_profile_id != null ? String(d.agent.llm_profile_id) : "");
setLlmProfiles(d.llm_profiles ?? []);
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 {
// 只提交本 agent 实际展示的字段,避免把未显示项(如 goals 的 max_turns)覆盖成默认。
const patch: Parameters<typeof api.saveAgentConfig>[1] = {
llm_profile_id: llmProfileId === "" ? null : Number(llmProfileId),
};
if (showConfig) {
patch.max_turns = Math.max(0, Math.floor(Number(maxTurns) || 0));
patch.run_seconds = Math.max(0, Math.floor(Number(runSecs) || 0));
}
if (showWebSearch) patch.web_search = webSearch;
if (showInteractiveShell) patch.interactive_shell = interactiveShell;
await api.saveAgentConfig(agentKey, patch);
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";
// 每个 agent(含 goals/mainagent)都跑在某个 LLM 上,故「默认模型」绑定对所有 agent 开放。
const showLLM = true;
// 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">
{(showLLM || showConfig || showWebSearch || showInteractiveShell) && (
<div className="grid gap-3 rounded-md border p-3">
{showLLM && (
<div className="grid gap-1.5">
<Label htmlFor="llm-profile" className="text-xs">默认模型(LLM 配置)</Label>
<div className="flex flex-wrap items-center gap-3">
<Select value={llmProfileId || "__follow__"} onValueChange={(v) => setLlmProfileId(v === "__follow__" ? "" : v)}>
<SelectTrigger id="llm-profile" className="h-8 w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__follow__">跟随任务 / 全局激活配置</SelectItem>
{llmProfiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}({p.model}){p.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground max-w-md text-xs">
为该 Agent 绑定固定 LLM 配置(点「保存配置」生效)。优先级:Agent 绑定 &gt; 任务/会话指定 &gt; 全局激活。
</span>
</div>
</div>
)}
<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>
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3">
<Markdown text={preview} />
</div>
</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} agent={detail?.agent} />
</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, agent }: { agentKey: string; agent?: Agent }) {
const [triggers, setTriggers] = React.useState<AgentTrigger[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
// 触发后处理策略(每 agent);初值来自 agent detail,改动即保存。
const [runMode, setRunMode] = React.useState<"serial" | "parallel">(agent?.trigger_run_mode ?? "serial");
const [mergeMode, setMergeMode] = React.useState<"by_task" | "all" | "none">(agent?.trigger_merge_mode ?? "all");
const [maxParallel, setMaxParallel] = React.useState(String(agent?.trigger_max_parallel ?? 5));
React.useEffect(() => {
setRunMode(agent?.trigger_run_mode ?? "serial");
setMergeMode(agent?.trigger_merge_mode ?? "all");
setMaxParallel(String(agent?.trigger_max_parallel ?? 5));
}, [agent?.trigger_run_mode, agent?.trigger_merge_mode, agent?.trigger_max_parallel]);
async function saveBehavior(patch: {
trigger_run_mode?: "serial" | "parallel";
trigger_merge_mode?: "by_task" | "all" | "none";
trigger_max_parallel?: number;
}) {
try {
await api.saveAgentConfig(agentKey, patch);
} catch (e) {
toast.error("保存策略失败:" + (e as Error).message);
}
}
const [onInterval, setOnInterval] = React.useState(false);
const [intervalSec, setIntervalSec] = React.useState("60");
const [onFinding, setOnFinding] = React.useState(false);
const [onGoalMet, setOnGoalMet] = React.useState(false);
const [onTaskTimeout, setOnTaskTimeout] = React.useState(false);
const [onToolCall, setOnToolCall] = React.useState(false);
const [onTaskCreate, setOnTaskCreate] = React.useState(false);
const [intervalMsg, setIntervalMsg] = React.useState("");
const [findingMsg, setFindingMsg] = React.useState("");
const [goalMsg, setGoalMsg] = React.useState("");
const [taskTimeoutMsg, setTaskTimeoutMsg] = React.useState("");
const [toolCallMsg, setToolCallMsg] = React.useState("");
const [taskCreateMsg, setTaskCreateMsg] = React.useState("");
const [toolNames, setToolNames] = React.useState<string[]>([]);
const [saving, setSaving] = React.useState(false);
// null = 新增模式;非 null = 正在编辑该 id 的触发器。
const [editingId, setEditingId] = React.useState<number | null>(null);
const reload = React.useCallback(() => {
api.agentTriggers(agentKey).then(setTriggers).catch(() => setTriggers([]));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
React.useEffect(() => {
api.tools().then(setTools).catch(() => setTools([]));
}, []);
function toggleTool(key: string) {
setToolNames((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}
// resetForm 清空表单并回到「新增」模式。
function resetForm() {
setEditingId(null);
setOnInterval(false);
setIntervalSec("60");
setOnFinding(false);
setOnGoalMet(false);
setOnTaskTimeout(false);
setOnToolCall(false);
setOnTaskCreate(false);
setIntervalMsg("");
setFindingMsg("");
setGoalMsg("");
setTaskTimeoutMsg("");
setToolCallMsg("");
setTaskCreateMsg("");
setToolNames([]);
}
// startEdit 把某条已有触发器灌进表单,进入「编辑」模式。
function startEdit(t: AgentTrigger) {
setEditingId(t.id);
setOnInterval(t.interval_sec > 0);
setIntervalSec(t.interval_sec > 0 ? String(t.interval_sec) : "60");
setOnFinding(t.on_finding);
setOnGoalMet(t.on_goal_met);
setOnTaskTimeout(t.on_task_timeout);
setOnToolCall(t.on_tool_call);
setOnTaskCreate(t.on_task_create);
setIntervalMsg(t.interval_message);
setFindingMsg(t.finding_message);
setGoalMsg(t.goal_message);
setTaskTimeoutMsg(t.task_timeout_message);
setToolCallMsg(t.tool_call_message);
setTaskCreateMsg(t.task_create_message);
setToolNames(t.tool_names ?? []);
}
// submit 依 editingId 走「新增」或「保存修改」;编辑时保留该触发器的启用状态。
async function submit() {
const n = onInterval ? Math.max(1, Math.floor(Number(intervalSec) || 0)) : 0;
if (n === 0 && !onFinding && !onGoalMet && !onTaskTimeout && !onToolCall && !onTaskCreate) {
toast.error("至少选择一种触发条件");
return;
}
if (onToolCall && toolNames.length === 0) {
toast.error("工具调用触发至少选择一个工具");
return;
}
const body = {
interval_sec: n,
on_finding: onFinding,
on_goal_met: onGoalMet,
on_task_timeout: onTaskTimeout,
on_tool_call: onToolCall,
on_task_create: onTaskCreate,
interval_message: intervalMsg.trim(),
finding_message: findingMsg.trim(),
goal_message: goalMsg.trim(),
task_timeout_message: taskTimeoutMsg.trim(),
tool_call_message: toolCallMsg.trim(),
task_create_message: taskCreateMsg.trim(),
tool_names: onToolCall ? toolNames : [],
};
setSaving(true);
try {
if (editingId != null) {
const cur = triggers.find((x) => x.id === editingId);
await api.updateTrigger(editingId, { ...body, enabled: cur?.enabled ?? true });
toast.success("已保存修改");
} else {
await api.createTrigger(agentKey, { ...body, enabled: true });
toast.success("已添加触发器");
}
resetForm();
reload();
} catch (e) {
toast.error((editingId != null ? "保存失败:" : "添加失败:") + (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,
on_tool_call: t.on_tool_call,
on_task_create: t.on_task_create,
interval_message: t.interval_message,
finding_message: t.finding_message,
goal_message: t.goal_message,
task_timeout_message: t.task_timeout_message,
tool_call_message: t.tool_call_message,
task_create_message: t.task_create_message,
tool_names: t.tool_names,
});
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function del(id: number) {
try {
await api.deleteTrigger(id);
if (editingId === id) resetForm();
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("任务超时");
if (t.on_tool_call) parts.push(`工具调用(${t.tool_names.length})`);
if (t.on_task_create) 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="flex flex-wrap items-center gap-4">
<div className="grid gap-1">
<Label className="text-xs">运行模式</Label>
<Select
value={runMode}
onValueChange={(v) => {
const rm = v as "serial" | "parallel";
setRunMode(rm);
saveBehavior({ trigger_run_mode: rm });
}}
>
<SelectTrigger size="sm" className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="serial">串行(排队,一次一个)</SelectItem>
<SelectItem value="parallel">并行(各自并发会话)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label className="text-xs">合并模式</Label>
<Select
value={mergeMode}
disabled={runMode === "parallel"}
onValueChange={(v) => {
const mm = v as "by_task" | "all" | "none";
setMergeMode(mm);
saveBehavior({ trigger_merge_mode: mm });
}}
>
<SelectTrigger size="sm" className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="by_task">按任务合并</SelectItem>
<SelectItem value="all">全部合并成一条</SelectItem>
<SelectItem value="none">不合并</SelectItem>
</SelectContent>
</Select>
</div>
{runMode === "parallel" && (
<div className="grid gap-1">
<Label htmlFor="tr-maxpar" className="text-xs">最大并发(0=不限)</Label>
<Input
id="tr-maxpar"
type="number"
min={0}
className="h-8 w-28"
value={maxParallel}
onChange={(e) => setMaxParallel(e.target.value)}
onBlur={() => {
const n = Math.max(0, Math.floor(Number(maxParallel) || 0));
setMaxParallel(String(n));
saveBehavior({ trigger_max_parallel: n });
}}
/>
</div>
)}
</div>
<p className="text-muted-foreground text-xs">
{runMode === "parallel"
? "并行:每次触发立即各开一个会话并发运行,不合并;超过最大并发的触发排队等空位。"
: mergeMode === "by_task"
? "串行·按任务合并:同 agent 一次跑一个;排队中同一任务的事件触发合并成一条会话。"
: mergeMode === "all"
? "串行·全部合并:同 agent 一次跑一个;取队列时把当前排队的所有触发合并成一条会话。"
: "串行·不合并:同 agent 一次跑一个;每条触发各自一个会话。"}
</p>
</div>
{/* 新增 / 编辑触发器 */}
<div className="grid gap-3 rounded-md border p-3">
<Label className="text-muted-foreground text-xs">
{editingId != null
? `编辑触发器 #${editingId}(改完点「保存修改」)`
: "新增触发器(每种条件可填各自的用户消息)"}
</Label>
{/* 定时 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onInterval} onCheckedChange={(v) => setOnInterval(!!v)} /> 定时触发
</label>
{onInterval && (
<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={1} className="h-8 w-24"
value={intervalSec} onChange={(e) => setIntervalSec(e.target.value)} />
<span className="text-muted-foreground text-xs">秒</span>
</div>
<Textarea className="text-xs" rows={2} value={intervalMsg}
placeholder="定时触发时发给 agent 的话,如:巡检所有任务" onChange={(e) => setIntervalMsg(e.target.value)} />
</div>
)}
</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 className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onToolCall} onCheckedChange={(v) => setOnToolCall(!!v)} /> 工具调用时触发
</label>
{onToolCall && (
<div className="grid gap-1.5">
<div className="text-muted-foreground text-xs">
选择要监听的工具(至少一个);任务执行中这些工具每次<b>调用完成</b>都会触发。已选 {toolNames.length} 个。
</div>
<div className="max-h-40 overflow-y-auto rounded-md border p-2">
{tools.length === 0 && <span className="text-muted-foreground text-xs">(工具列表为空)</span>}
<div className="grid gap-1">
{tools.map((tool) => (
<label key={tool.key} className="flex items-start gap-2 text-xs">
<Checkbox className="mt-0.5" checked={toolNames.includes(tool.key)}
onCheckedChange={() => toggleTool(tool.key)} />
<span className="min-w-0">
<span className="font-medium">{tool.key}</span>
{tool.description && <span className="text-muted-foreground line-clamp-1"> {tool.description}</span>}
</span>
</label>
))}
</div>
</div>
<Textarea className="text-xs" rows={2} value={toolCallMsg}
placeholder="工具被调用时发给 agent 的话(系统会附带任务信息、工具入参与返回内容)" onChange={(e) => setToolCallMsg(e.target.value)} />
</div>
)}
</div>
{/* 任务创建 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onTaskCreate} onCheckedChange={(v) => setOnTaskCreate(!!v)} /> 任务创建时触发
</label>
{onTaskCreate && (
<Textarea className="text-xs" rows={2} value={taskCreateMsg}
placeholder="任务被创建时发给 agent 的话(系统会附带任务编号与目标)" onChange={(e) => setTaskCreateMsg(e.target.value)} />
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={submit} disabled={saving}>
<SaveIcon /> {editingId != null ? "保存修改" : "添加触发器"}
</Button>
{editingId != null && (
<Button size="sm" variant="ghost" onClick={resetForm} disabled={saving}>
<XIcon /> 取消编辑
</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={cn(
"flex items-start gap-2 rounded-md border p-2 text-sm",
editingId === t.id && "border-primary bg-primary/5",
)}
>
<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>}
{t.on_task_create && t.task_create_message && <div className="line-clamp-1">任务创建:{t.task_create_message}</div>}
{t.on_tool_call && (
<>
<div className="line-clamp-1">工具:{t.tool_names.join("、") || "(未选)"}</div>
{t.tool_call_message && <div className="line-clamp-1">消息:{t.tool_call_message}</div>}
</>
)}
</div>
</div>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-foreground"
onClick={() => startEdit(t)} title="编辑">
<PencilIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-destructive"
onClick={() => del(t.id)} title="删除">
<Trash2Icon className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
);
}