"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(null); const [versions, setVersions] = React.useState([]); const [variables, setVariables] = React.useState([]); const [mcp, setMcp] = React.useState([]); const [skills, setSkills] = React.useState([]); const [tools, setTools] = React.useState([]); const [loaded, setLoaded] = React.useState(false); const [viewVer, setViewVer] = React.useState(null); const [diffVer, setDiffVer] = React.useState(null); const [prompt, setPrompt] = React.useState(""); const [mcpVisible, setMcpVisible] = React.useState([]); const [skillVisible, setSkillVisible] = React.useState([]); 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>([]); 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(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[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
未找到 Agent:{agentKey}
; } // 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 ( 配置与提示词 收尾提示词 MCP Skill Tools {isCustom && 触发} {/* 配置 + 提示词 */}
{(showLLM || showConfig || showWebSearch || showInteractiveShell) && (
{showLLM && (
为该 Agent 绑定固定 LLM 配置(点「保存配置」生效)。优先级:Agent 绑定 > 任务/会话指定 > 全局激活。
)}
{showConfig && ( <>
setMaxTurns(e.target.value)} />
setRunSecs(e.target.value)} />
)}
{showWebSearch && (
{webSearchGlobalOn ? "为该 Agent 开启后(点上方保存生效),可用 web_search 联网检索" : "需先在「系统配置」开启网络搜索并配置后端,才能在此启用"}
)} {showInteractiveShell && (
为该 Agent 开启后(点上方保存生效),可用持久 PTY 会话工具(shell_open/send/read/close/list)驱动 msfconsole/ssh/REPL 等交互程序
)}
)}
{variables.map((v) => (

{v.description}

示例:{v.example}

))} {variables.length === 0 && (无变量)}