diff --git a/frontend-web/src/pages/AgentsOrbitPage.tsx b/frontend-web/src/pages/AgentsOrbitPage.tsx new file mode 100644 index 00000000..14686e01 --- /dev/null +++ b/frontend-web/src/pages/AgentsOrbitPage.tsx @@ -0,0 +1,247 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Bot } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { useAgents } from "@/core/agents"; +import type { Agent } from "@/core/agents"; +import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext"; + +import "@/styles/agents-orbit.css"; +import "@/styles/agents-orbit-dark.css"; +import "@/styles/agents-orbit-light.css"; + +/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */ +const DIAL_TRANSITION_MS = 850; + +/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */ +interface AgentGroup { + id: string; + name: string; + agents: Agent[]; +} + +function groupAgentsByType(agents: Agent[]): AgentGroup[] { + const map = new Map(); + for (const agent of agents) { + const tag = agent.tags?.[0]; + const id = tag?.id ?? "__uncategorized__"; + let group = map.get(id); + if (!group) { + group = { id, name: tag?.name ?? "未分类", agents: [] }; + map.set(id, group); + } + group.agents.push(agent); + } + return Array.from(map.values()).sort((a, b) => + a.name.localeCompare(b.name, "zh-Hans-CN"), + ); +} + +const mod = (value: number, n: number): number => ((value % n) + n) % n; + +/** + * 密码转盘模型: + * - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变); + * - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽; + * - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0); + * - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明), + * 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。 + */ +export default function AgentsOrbitPage() { + const router = useRouter(); + const { theme } = useTheme(); + const isDark = themeToBaseMode(theme) === "dark"; + const { agents, isLoading } = useAgents(); + const groups = useMemo(() => groupAgentsByType(agents), [agents]); + + const [activeId, setActiveId] = useState(null); + /** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */ + const [anim, setAnim] = useState | null>(null); + /** 归一化帧:瞬移到等价角度时禁用过渡。 */ + const [frozen, setFrozen] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + return () => { + if (timerRef.current != null) window.clearTimeout(timerRef.current); + }; + }, []); + + if (isLoading) { + return ( +
+
+
+ 正在加载智能体… +
+
+ ); + } + + if (groups.length === 0) { + return ( +
+
+ 还没有可展示的智能体 +
+
+ ); + } + + const n = groups.length; + const midSlot = Math.floor((n - 1) / 2); + const step = 180 / n; + /** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */ + const slotAngle = (slot: number) => -90 + (slot + 0.5) * step; + + const requestedIdx = activeId + ? groups.findIndex((g) => g.id === activeId) + : -1; + const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot; + const active = groups[activeIdx]; + /** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */ + const shift = midSlot - activeIdx; + const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)]; + + const handleTypeClick = (slot: number) => { + const group = groupAtSlot(slot); + if (group.id === active.id) return; + + const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转 + const angles: Record = {}; + for (let j = 0; j < n; j++) { + const g = groupAtSlot(j); + const nextSlot = j + k; + let angle = slotAngle(mod(nextSlot, n)); + if (nextSlot >= n) { + angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位 + } else if (nextSlot < 0) { + angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位 + } + angles[g.id] = angle; + } + + setActiveId(group.id); + setAnim(angles); + if (timerRef.current != null) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + // 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。 + setFrozen(true); + setAnim(null); + requestAnimationFrame(() => + requestAnimationFrame(() => setFrozen(false)), + ); + }, DIAL_TRANSITION_MS + 60); + }; + + const handleAgentClick = (agent: Agent) => { + router.push(`/page/workspace/agents/${agent.id}/chats/new`); + }; + + const themeClass = isDark ? "ao-dark" : "ao-light"; + + return ( +
= 8 ? " ao-crowded" : ""}`} + > +
+ 智能体 · 星轨转盘 + +
+ + {/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */} +
+
+
+
+
+
+
+
+ +
+
+ +
+
+ + {/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */} +
+ {groups.map((group, gi) => { + const slot = mod(gi + shift, n); + const angle = anim + ? (anim[group.id] ?? slotAngle(slot)) + : slotAngle(slot); + const isActive = group.id === active.id; + const transitionStyle = frozen ? { transition: "none" } : {}; + return ( +
+ +
+ ); + })} +
+
+
+ + {/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */} + +
+ ); +} diff --git a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx index fa1df75d..df8f0f3e 100644 --- a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx +++ b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx @@ -7,6 +7,7 @@ import { Clock3, LoaderCircle, MessageSquareText, + Network, PanelLeftClose, Pause, Play, @@ -42,9 +43,17 @@ import { OFFICIAL_DEERFLOW_ROUTE_PREFIX, } from "@/core/official-deerflow/routes"; +import MultiAgentTasksPage from "./official-multi-agent-tasks/MultiAgentTasksPage"; + import "./official-deerflow-entry.css"; -type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings"; +type OfficialPage = + | "chat" + | "agents" + | "agent-chat" + | "scheduled" + | "multi-agent-tasks" + | "settings"; function pageFromPath(pathname: string): OfficialPage { const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length); @@ -53,6 +62,7 @@ function pageFromPath(pathname: string): OfficialPage { } if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; + if (path.startsWith("/workspace/multi-agent-tasks")) return "multi-agent-tasks"; if (path.startsWith("/workspace/settings")) return "settings"; return "chat"; } @@ -78,6 +88,11 @@ function parseScheduledTaskId(pathname: string) { return match ? decodeURIComponent(match[1]!) : null; } +function parseMultiAgentTaskId(pathname: string) { + const match = pathname.match(/\/workspace\/multi-agent-tasks\/([^/]+)/); + return match ? decodeURIComponent(match[1]!) : null; +} + function readableError(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -658,6 +673,10 @@ export default function OfficialDeerFlowEntry() { () => parseScheduledTaskId(location.pathname), [location.pathname], ); + const multiAgentTaskId = useMemo( + () => parseMultiAgentTaskId(location.pathname), + [location.pathname], + ); const { data: threads = [], isLoading: threadsLoading, error: threadsError } = useThreads({ limit: 20, @@ -672,6 +691,7 @@ export default function OfficialDeerFlowEntry() { { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, + { icon: Network, label: "多智能体定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`, pages: ["multi-agent-tasks"] }, ] as const; return ( @@ -769,6 +789,9 @@ export default function OfficialDeerFlowEntry() { /> )} {page === "scheduled" && } + {page === "multi-agent-tasks" && ( + + )} {page === "settings" && }
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 4cf0488c..4ce5935b 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -39,6 +39,7 @@ import ChatPage from "./ChatPage"; import ArtifactLibraryPage from "./ArtifactLibraryPage"; import IframeChatPage from "./IframeChatPage"; import AgentsPage from "./AgentsPage"; +import AgentsOrbitPage from "./AgentsOrbitPage"; import AgentChatPage from "./AgentChatPage"; import SentimentAgentChatPage from "./SentimentAgentChatPage"; import NewAgentPage from "./NewAgentPage"; @@ -620,6 +621,16 @@ export default function WorkspaceRoutes({ } /> + {/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。 + 字面量路由,置于 agents/:agent_id/* 参数路由之前。 */} + + + + } + /> {/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发 外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */} ("all"); + const [editorTask, setEditorTask] = useState(null); + const [editorVisible, setEditorVisible] = useState(false); + const [busyTaskId, setBusyTaskId] = useState(null); + const [activeRunId, setActiveRunId] = useState(null); + + const selectedTask = tasks.find((task) => task.id === routeTaskId) ?? null; + + const openTask = useCallback( + (taskId: string) => { + setActiveRunId(null); + navigate( + `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(taskId)}`, + ); + }, + [navigate], + ); + + const closeTask = useCallback(() => { + setActiveRunId(null); + navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`); + }, [navigate]); + + const visibleTasks = useMemo(() => { + const keyword = search.trim().toLowerCase(); + return tasks.filter((task) => { + if (!matchFilter(task, filter)) return false; + if (!keyword) return true; + return ( + task.name.toLowerCase().includes(keyword) || + (task.description ?? "").toLowerCase().includes(keyword) || + task.seats.some((seat) => seat.name.toLowerCase().includes(keyword)) + ); + }); + }, [tasks, search, filter]); + + const stats = useMemo(() => { + const total = tasks.length; + const enabled = tasks.filter((task) => task.enabled).length; + const running = tasks.filter(isTaskLive).length; + const runTotal = tasks.reduce((sum, task) => sum + task.runs.length, 0); + return { total, enabled, running, runTotal }; + }, [tasks]); + + const openCreate = () => { + setEditorTask(null); + setEditorVisible(true); + }; + + const openEdit = (task: MatTask) => { + setEditorTask(task); + setEditorVisible(true); + }; + + const handleEditorSubmit = async (payload: MatTaskPayload) => { + try { + if (editorTask) { + await actions.update.mutateAsync({ id: editorTask.id, payload }); + void MessagePlugin.success("任务已更新"); + } else { + const created = await actions.create.mutateAsync(payload); + void MessagePlugin.success("任务已创建"); + if (created?.id) openTask(created.id); + } + setEditorVisible(false); + setEditorTask(null); + } catch { + void MessagePlugin.error("保存失败,请重试"); + } + }; + + const runTrigger = async (task: MatTaskView) => { + setBusyTaskId(task.id); + try { + await actions.trigger.mutateAsync(task.id); + setActiveRunId(null); + openTask(task.id); + void MessagePlugin.success("已触发执行"); + } catch { + void MessagePlugin.error("触发失败,请重试"); + } finally { + setBusyTaskId(null); + } + }; + + const runToggle = async (task: MatTaskView) => { + setBusyTaskId(task.id); + try { + await actions.setEnabled.mutateAsync({ id: task.id, enabled: !task.enabled }); + void MessagePlugin.success(task.enabled ? "任务已暂停" : "任务已恢复"); + } catch { + void MessagePlugin.error("操作失败,请重试"); + } finally { + setBusyTaskId(null); + } + }; + + const confirmDelete = (task: MatTaskView) => { + const dialog = DialogPlugin.confirm({ + header: "删除定时任务", + body: `确定删除「${task.name}」吗?其编排配置与运行记录将一并清除,且不可恢复。`, + confirmBtn: { content: "删除", theme: "danger" }, + cancelBtn: "取消", + onConfirm: () => { + dialog.destroy(); + void actions.remove.mutateAsync(task.id).then(() => { + if (selectedTask?.id === task.id) closeTask(); + void MessagePlugin.success("任务已删除"); + }); + }, + onClose: () => dialog.destroy(), + }); + }; + + const selectedStages = selectedTask ? expandStages(selectedTask) : []; + const selectedRuns = selectedTask?.runs ?? []; + const activeRun = + selectedRuns.find((run) => run.id === activeRunId) ?? selectedRuns[0] ?? null; + + return ( +
+
+
+

+

+

多智能体定时任务

+

+ 把多个智能体编排成协作编队:同阶段并行、阶段间串行,按计划自动执行并沉淀运行记录。 +

+
+
+
+
+ {stats.total} + 编排任务 +
+
+ {stats.enabled} + 已启用 +
+
+ {stats.running} + 执行中 +
+
+ {stats.runTotal} + 累计执行 +
+
+ +
+
+ +
+
+
+ } + value={search} + onChange={(value) => setSearch(String(value ?? ""))} + /> +
+ setFilter(value as StatusFilter)} + > + + + + + +
+ + {isLoading ? ( +
+
+ ) : visibleTasks.length ? ( +
+ {visibleTasks.map((task) => { + const badge = taskBadgeOf(task); + const live = isTaskLive(task); + const busy = busyTaskId === task.id; + return ( +
+
openTask(task.id)}> + +
+

{task.name}

+

+

+
+ + {badge.label} + +
+ {task.description && ( +

openTask(task.id)}> + {task.description} +

+ )} +
openTask(task.id)}> + +
+
+
+ + 下次 {task.enabled ? formatDateTime(task.nextRunAt) : "已暂停"} + + + 上次 {task.lastRun ? formatRelative(task.lastRun.started_at) : "未执行"} + {task.lastRun && task.lastRun.status !== "pending" && ( + + · {runStatusLabel(task.lastRun.status)} + + )} + +
+
+ + + + confirmDelete(task)} + > + + +
+
+
+ ); + })} +
+ ) : ( +
+ +

{tasks.length ? "没有符合条件的任务" : "还没有多智能体定时任务"}

+

+ {tasks.length + ? "换个关键词或筛选条件试试。" + : "创建一个编排任务,把多个智能体按并行 / 串行组织起来定时执行。"} +

+ {!tasks.length && ( + + )} +
+ )} + +

+

+
+ + + + + + +
+ ) : null + } + header={ + selectedTask ? ( +
+ +
+

{selectedTask.name}

+

{describeCron(selectedTask.cron_expr)} · {selectedTask.timezone}

+
+ {(() => { + const badge = taskBadgeOf(selectedTask); + return ( + + {badge.label} + + ); + })()} + void runToggle(selectedTask)} + /> +
+ ) : null + } + placement="right" + size="680px" + visible={!!selectedTask} + onClose={closeTask} + > + {selectedTask && ( +
+ {selectedTask.description &&

{selectedTask.description}

} + +
+
+ 下次执行 + + {selectedTask.enabled ? formatDateTime(selectedTask.nextRunAt) : "已暂停"} + +
+
+ 最近执行 + + {selectedTask.lastRun + ? `${formatRelative(selectedTask.lastRun.started_at)} · ${runStatusLabel(selectedTask.lastRun.status)}` + : "未执行"} + +
+
+ 累计执行 + {selectedTask.runs.length} 次 +
+
+ 智能体 / 阶段 + + {selectedTask.seats.length} 个 · {selectedStages.length} 阶段 + +
+
+ +
+
+

协作编排

+ {activeRun && (activeRun.status === "running" || activeRun.status === "pending") && ( + + 阶段 {Math.min(activeRun.stage_index + 1, activeRun.stage_total)} /{" "} + {activeRun.stage_total} 执行中… + + )} +
+
+ +
+ {activeRun && ( +
+ + 完成 + + + 执行中 + + + 等待 + + + 失败 + +
+ )} +
+ +
+
+

任务指令

+
+

{selectedTask.prompt}

+ {selectedTask.coordinator_prompt && ( + <> +
+
总控提示
+
+

{selectedTask.coordinator_prompt}

+ + )} +
+ +
+
+

运行记录

+ {!!selectedRuns.length && ( + 点击记录可在上方流程图回看各智能体状态 + )} +
+ {selectedRuns.length ? ( +
+ {selectedRuns.map((run) => { + const active = activeRun?.id === run.id; + const duration = run.finished_at + ? formatDuration( + new Date(run.finished_at).getTime() - + new Date(run.started_at).getTime(), + ) + : null; + return ( + + ); + })} +
+ ) : ( +

还没有执行记录,可点「立即执行」体验完整流程。

+ )} + {activeRun?.error && ( +

+

+ )} +
+
+ )} + + + { + setEditorVisible(false); + setEditorTask(null); + }} + onSubmit={handleEditorSubmit} + submitting={actions.create.isPending || actions.update.isPending} + task={editorTask} + visible={editorVisible} + /> + + ); +} diff --git a/frontend-web/src/pages/official-multi-agent-tasks/components/AgentFlow.tsx b/frontend-web/src/pages/official-multi-agent-tasks/components/AgentFlow.tsx new file mode 100644 index 00000000..f6757d9d --- /dev/null +++ b/frontend-web/src/pages/official-multi-agent-tasks/components/AgentFlow.tsx @@ -0,0 +1,107 @@ +import { MoveRight } from "lucide-react"; +import { Fragment } from "react"; + +import type { MatSeat, MatSeatRunState } from "../types"; +import { seatColorIndex } from "../utils"; + +export interface AgentFlowProps { + stages: string[][]; + seats: MatSeat[]; + stageGoals?: (string | null)[] | null; + /** 提供时在席位上渲染执行状态点(运行图例)。 */ + seatStates?: Record | null; + /** 当前执行到的阶段下标;提供时高亮进度。 */ + activeStageIndex?: number | null; + size?: "sm" | "md"; + showGoals?: boolean; +} + +const STATE_LABEL: Record = { + waiting: "等待", + running: "执行中", + done: "完成", + failed: "失败", +}; + +function initialsOf(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return "?"; + return trimmed.slice(0, 2).toUpperCase(); +} + +export default function AgentFlow({ + stages, + seats, + stageGoals, + seatStates, + activeStageIndex, + size = "md", + showGoals = false, +}: AgentFlowProps) { + const seatById = new Map(seats.map((seat) => [seat.agent_id, seat])); + + return ( +
+ {stages.map((stage, stageIndex) => { + const parallel = stage.length > 1; + const isActive = typeof activeStageIndex === "number" && activeStageIndex === stageIndex; + const isPast = + typeof activeStageIndex === "number" && activeStageIndex > stageIndex; + const goal = showGoals ? stageGoals?.[stageIndex] ?? null : null; + return ( + + {stageIndex > 0 && ( + + )} +
+
+ 阶段 {stageIndex + 1} + {parallel ? ( + 并行 ×{stage.length} + ) : ( + 串行 + )} +
+
+ {stage.map((agentId) => { + const seat = seatById.get(agentId); + const state = seatStates?.[agentId] ?? null; + return ( +
+ {initialsOf(seat?.name ?? agentId)} + {seat?.name ?? agentId} + {state && ( + + )} +
+ ); + })} +
+ {goal &&

{goal}

} +
+
+ ); + })} +
+ ); +} diff --git a/frontend-web/src/pages/official-multi-agent-tasks/components/SchedulePicker.tsx b/frontend-web/src/pages/official-multi-agent-tasks/components/SchedulePicker.tsx new file mode 100644 index 00000000..9b94da5f --- /dev/null +++ b/frontend-web/src/pages/official-multi-agent-tasks/components/SchedulePicker.tsx @@ -0,0 +1,76 @@ +import { Select } from "tdesign-react"; + +import type { ScheduleConfig, ScheduleFreq } from "@/components/workspace/scheduled-tasks/schedule-builder"; + +import { describeScheduleConfig } from "../utils"; + +const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; +const HOURS = Array.from({ length: 24 }, (_, index) => index); +const MINUTES = [0, 15, 30, 45]; +const DATES = Array.from({ length: 31 }, (_, index) => index + 1); + +const FREQ_OPTIONS: Array<{ label: string; value: ScheduleFreq }> = [ + { label: "每小时", value: "hourly" }, + { label: "每天", value: "daily" }, + { label: "每周", value: "weekly" }, + { label: "每月", value: "monthly" }, +]; + +export default function SchedulePicker({ + value, + onChange, +}: { + value: ScheduleConfig; + onChange: (config: ScheduleConfig) => void; +}) { + const set = (patch: Partial) => onChange({ ...value, ...patch }); + const toNumber = (input: unknown) => Number(input as string | number); + + return ( +
+
+ ({ label: `${minute} 分`, value: minute }))} + value={value.minute} + onChange={(next) => set({ minute: toNumber(next) })} + /> + )} + {value.freq === "weekly" && ( + ({ label: `第 ${date} 日`, value: date }))} + value={value.date} + onChange={(next) => set({ date: toNumber(next) })} + /> + )} + {value.freq !== "hourly" && ( + ({ label: `${minute} 分`, value: minute }))} + value={value.minute} + onChange={(next) => set({ minute: toNumber(next) })} + /> + )} +
+

{describeScheduleConfig(value)} 自动执行

+
+ ); +} diff --git a/frontend-web/src/pages/official-multi-agent-tasks/components/TaskEditorDialog.tsx b/frontend-web/src/pages/official-multi-agent-tasks/components/TaskEditorDialog.tsx new file mode 100644 index 00000000..69c3da51 --- /dev/null +++ b/frontend-web/src/pages/official-multi-agent-tasks/components/TaskEditorDialog.tsx @@ -0,0 +1,571 @@ +import { + ArrowDown, + ArrowUp, + ChevronDown, + ChevronUp, + Plus, + Search, + SplitSquareVertical, + Trash2, + X, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Dialog, + Input, + MessagePlugin, + Select, + Steps, + Switch, + Textarea, +} from "tdesign-react"; + +import { useAgents } from "@/core/agents"; +import { + type ScheduleConfig, + buildCron, + parseCron, +} from "@/components/workspace/scheduled-tasks/schedule-builder"; + +import AgentFlow from "./AgentFlow"; +import SchedulePicker from "./SchedulePicker"; +import type { MatSeat, MatStageGroup, MatTask, MatTaskPayload } from "../types"; +import { defaultScheduleConfig, expandStages } from "../utils"; + +const TIMEZONE_OPTIONS = ["Asia/Shanghai", "UTC", "America/New_York", "Europe/London"].map( + (zone) => ({ label: zone, value: zone }), +); + +const MAX_SEATS = 20; +const MIN_SEATS = 2; + +interface EditorDraft { + name: string; + description: string; + schedule: ScheduleConfig; + timezone: string; + enabled: boolean; + stageGroups: MatStageGroup[]; + prompt: string; + coordinatorPrompt: string; +} + +function draftFromTask(task: MatTask | null): EditorDraft { + if (!task) { + return { + name: "", + description: "", + schedule: defaultScheduleConfig(), + timezone: "Asia/Shanghai", + enabled: true, + stageGroups: [], + prompt: "", + coordinatorPrompt: "", + }; + } + const stages = expandStages(task); + const seatById = new Map(task.seats.map((seat) => [seat.agent_id, seat])); + return { + name: task.name, + description: task.description ?? "", + schedule: parseCron(task.cron_expr) ?? defaultScheduleConfig(), + timezone: task.timezone || "Asia/Shanghai", + enabled: task.enabled, + stageGroups: stages.map((stage, index) => ({ + seats: stage + .map((agentId) => seatById.get(agentId)) + .filter((seat): seat is MatSeat => !!seat), + goal: task.stage_goals?.[index] ?? "", + })), + prompt: task.prompt, + coordinatorPrompt: task.coordinator_prompt ?? "", + }; +} + +function draftToPayload(draft: EditorDraft): MatTaskPayload { + const groups = draft.stageGroups.filter((group) => group.seats.length > 0); + const seats = groups.flatMap((group) => group.seats); + const anyParallel = groups.some((group) => group.seats.length > 1); + const anyGoal = groups.some((group) => group.goal.trim().length > 0); + const explicit = anyParallel || anyGoal; + return { + name: draft.name.trim(), + description: draft.description.trim() || undefined, + prompt: draft.prompt.trim(), + coordinator_prompt: draft.coordinatorPrompt.trim() || undefined, + cron_expr: buildCron(draft.schedule), + timezone: draft.timezone, + enabled: draft.enabled, + seats, + stages: explicit ? groups.map((group) => group.seats.map((seat) => seat.agent_id)) : null, + stage_goals: explicit ? groups.map((group) => group.goal.trim() || null) : null, + }; +} + +export default function TaskEditorDialog({ + visible, + task, + submitting, + onClose, + onSubmit, +}: { + visible: boolean; + task: MatTask | null; + submitting: boolean; + onClose: () => void; + onSubmit: (payload: MatTaskPayload) => void; +}) { + const [step, setStep] = useState(0); + const [draft, setDraft] = useState(() => draftFromTask(task)); + const [poolSearch, setPoolSearch] = useState(""); + const [stageAddValues, setStageAddValues] = useState>({}); + + useEffect(() => { + if (visible) { + setStep(0); + setPoolSearch(""); + setDraft(draftFromTask(task)); + } + }, [visible, task]); + + const { agents, isLoading: agentsLoading } = useAgents({ search: poolSearch.trim() || undefined }); + + const usedAgentIds = useMemo( + () => new Set(draft.stageGroups.flatMap((group) => group.seats.map((seat) => seat.agent_id))), + [draft.stageGroups], + ); + const seatTotal = usedAgentIds.size; + + const updateDraft = (patch: Partial) => setDraft((prev) => ({ ...prev, ...patch })); + const updateGroups = ( + updater: (groups: MatStageGroup[]) => MatStageGroup[], + ) => updateDraft({ stageGroups: updater(draft.stageGroups) }); + + const addAgentAsNewStage = (seat: MatSeat) => { + if (usedAgentIds.has(seat.agent_id) || seatTotal >= MAX_SEATS) return; + updateGroups((groups) => [...groups, { seats: [seat], goal: "" }]); + }; + + const addAgentToStage = (seat: MatSeat, stageIndex: number) => { + if (usedAgentIds.has(seat.agent_id) || seatTotal >= MAX_SEATS) return; + updateGroups((groups) => + groups.map((group, index) => + index === stageIndex ? { ...group, seats: [...group.seats, seat] } : group, + ), + ); + }; + + const removeSeat = (stageIndex: number, seatIndex: number) => { + updateGroups((groups) => + groups + .map((group, index) => + index === stageIndex + ? { ...group, seats: group.seats.filter((_, seatIdx) => seatIdx !== seatIndex) } + : group, + ) + .filter((group) => group.seats.length > 0), + ); + }; + + const moveSeatAcrossStage = (stageIndex: number, seatIndex: number, delta: 1 | -1) => { + const target = stageIndex + delta; + if (target < 0 || target >= draft.stageGroups.length) return; + updateGroups((groups) => { + const next = groups.map((group) => ({ seats: [...group.seats], goal: group.goal })); + const seat = next[stageIndex]!.seats[seatIndex]; + if (!seat) return groups; + next[stageIndex]!.seats.splice(seatIndex, 1); + next[target]!.seats.push(seat); + return next.filter((group) => group.seats.length > 0); + }); + }; + + const splitSeatToStage = (stageIndex: number, seatIndex: number) => { + updateGroups((groups) => { + const next = groups.map((group) => ({ seats: [...group.seats], goal: group.goal })); + const seat = next[stageIndex]!.seats[seatIndex]; + if (!seat) return groups; + next[stageIndex]!.seats.splice(seatIndex, 1); + next.splice(stageIndex + 1, 0, { seats: [seat], goal: "" }); + return next.filter((group) => group.seats.length > 0); + }); + }; + + const moveStage = (stageIndex: number, delta: 1 | -1) => { + const target = stageIndex + delta; + if (target < 0 || target >= draft.stageGroups.length) return; + updateGroups((groups) => { + const next = [...groups]; + const [group] = next.splice(stageIndex, 1); + if (!group) return groups; + next.splice(target, 0, group); + return next; + }); + }; + + const deleteStage = (stageIndex: number) => { + updateGroups((groups) => groups.filter((_, index) => index !== stageIndex)); + }; + + const setStageGoal = (stageIndex: number, goal: string) => { + updateGroups((groups) => + groups.map((group, index) => (index === stageIndex ? { ...group, goal } : group)), + ); + }; + + const trySubmit = () => { + if (!draft.name.trim()) { + setStep(0); + void MessagePlugin.warning("请填写任务名称"); + return; + } + if (seatTotal < MIN_SEATS) { + setStep(1); + void MessagePlugin.warning(`编排至少需要 ${MIN_SEATS} 个智能体`); + return; + } + if (!draft.prompt.trim()) { + setStep(2); + void MessagePlugin.warning("请填写任务指令"); + return; + } + onSubmit(draftToPayload(draft)); + }; + + const previewStages = draft.stageGroups + .filter((group) => group.seats.length > 0) + .map((group) => group.seats.map((seat) => seat.agent_id)); + const previewSeats = draft.stageGroups.flatMap((group) => group.seats); + + return ( + +
+ + + + + + + {step === 0 && ( +
+ +