From 8905fe5e60db94f3edd70d6c11e296716038e14d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 25 Sep 2026 12:47:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=20agent=20=E8=8A=82=E7=82=B9=E5=9C=A8?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E4=B8=8A=E5=B1=95=E5=BC=80=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=EF=BC=8C=E8=BF=90=E8=A1=8C=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=8A=A8=E6=95=88=E7=BB=9F=E4=B8=80=E6=88=90=E4=B8=80=E5=A5=97?= =?UTF-8?q?=E8=AF=AD=E6=B1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多 agent 节点原本是个黑盒:卡片转三十秒圈,然后吐一段文本。"它同时派了三个 人"、"谁还在跑"、"并行到底省了多少"全都要点开右栏才知道,而右栏是线性的 时间线,看不出并发的形状。 现在它跑起来会在卡片里展开一块协作矩阵。三条设计上的选择: - 花名册固定在原地,不按派单顺序重排——几轮下来眼睛知道谁在哪一行 - 条形长度按**这一轮里最慢的那个人**归一化。三个人并排三条长短不一的条, 墙钟为什么等于最长那条、而不是三条之和,不用解释就看懂了;页脚给出 并行一共省下多少 - 本轮没被派到的人不显示成"待命",而是留着上一轮的成绩(压暗)——它已经 交回来了,说它待命是错的,看的人会以为它还没干活 矩阵和右栏泳道读的是同一份翻译:把 decodeRun 里那段攒泳道的逻辑抽成 reduceTeam(不可变,每次返回新对象,画布那份挂在 zustand 上需要新引用)。 两边各写一遍的话,同一次运行在右栏和画布上会给出不同的并行度——而"几个人 同时在跑、省了多少"正是这个节点唯一值得看的东西。抽完之后 check-decode 一条没红。 其余节点按同一套语汇动:边框上转一圈光弧 + 扫描线 = 正在跑,绿色扫一遍 = 刚跑完(只扫一次——常驻动画会让整张画布一直在动),短促抖动 = 刚失败, 琥珀呼吸 = 在等人。循环卡片显示"第 1/5 轮";分支跑完点亮命中的出口、压暗 其余,实际走过的边留在画布上,那就是这次的执行路径。连线上的虚线换成顺着 路径跑的光点:虚线只让人觉得"在动",光点能看出往哪走,速度按路径长度归一化 (长边上的光点走得慢会像卡住了)。 原来那个往外扩的声纳圈(pulse-ring)删掉了。同一个意思说两遍只会更吵,而且 声纳扩出去会和邻居卡片的边框糊在一起。 一个前提先验证过:offset-path 在 SVG 子元素上能不能对齐路径。测下来和 getPointAtLength 的位置差 0px,才敢用它。 排版要按**展开后**的高度给多 agent 节点留位置(layout.py 的 _height 按名册 人数算)。不留的话它一跑起来就盖住下面那张卡,而且运行结束后矩阵不会收起来 (那正是要回看的东西),重叠是永久的。 前庭敏感的人由 prefers-reduced-motion 兜住:光弧、扫描线、流光条、光点整体 不绘制,而不是把时长压到 0.01ms——那样它们会停在动画的最后一帧,光弧卡在 角落、光点全堆在起点,看着像渲染坏了。静态下"谁在跑"由实心圆点和"进行中" 三个字承担。 新增 scripts/check-canvas-fx.mjs:把脚本化的事件灌进 store(走的是页面上那一份 applyEvent / NodeCard / FlowEdge,只有事件来源是脚本),断言协作矩阵真的长出来、 并行度算对、条形长度和耗时成正比、分支命中的出口被点亮、关掉动效时光效确实不 绘制。真跑一张多 agent 的图要连模型、要等十几秒,而且撞不上"三个人同时还在跑" 那一瞬间——那样的检查会变成偶发失败。另外用仓库里 fixtures.json 那份真实导出 的事件验过一遍,含老运行那种只有中文消息串的 log(走正则兜底)。 顺手修掉检查脚本互相下绊子:check-canvas-fx 原来会留下一张没坐标的工作流 (节点全叠在原点),而 check-ui 打开 /studio 会落到"最近更新"的那张上,于是 点节点被挡住。现在落库前先让后端排好版。 292 passed(新增 2 条:展开的多 agent 卡片必须在排版里留出位置)。 --- README.md | 29 ++- backend/app/engine/layout.py | 12 + backend/tests/test_layout.py | 47 +++- frontend/src/canvas/FlowCanvas.tsx | 36 ++- frontend/src/canvas/FlowEdge.tsx | 25 +- frontend/src/canvas/NodeCard.tsx | 63 ++++- frontend/src/canvas/TeamMatrix.tsx | 134 +++++++++++ frontend/src/canvas/routing.ts | 21 +- frontend/src/index.css | 356 ++++++++++++++++++++++++++++- frontend/src/run/decode.ts | 186 +++++++-------- frontend/src/store/studio.ts | 26 ++- frontend/src/types.ts | 47 +++- scripts/check-canvas-fx.mjs | 241 +++++++++++++++++++ 13 files changed, 1103 insertions(+), 120 deletions(-) create mode 100644 frontend/src/canvas/TeamMatrix.tsx create mode 100644 scripts/check-canvas-fx.mjs diff --git a/README.md b/README.md index d2189434..03e015ca 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ conda env create -f environment.yml # 首次:创建 agentlab 环境并安装 | 领域 | 能力 | |---|---| -| **可视化编排** | 16 种节点拖拽连线;运行时节点实时高亮、边上数据流动画、卡片内直接呈现流式 token | +| **可视化编排** | 16 种节点拖拽连线;运行时节点实时高亮、边上光点流动画、卡片内直接呈现流式 token;多 agent 节点在卡片里展开协作矩阵(谁在跑、几个人并行、省下多少) | | **对话式取数** | 「问数据」页:自然语言提问,系统自行接入数据源、生成工作流、执行并给出结论;对话持久化、可分享链接 | | **多模型接入** | Anthropic / OpenAI / 任意 OpenAI 兼容服务(DeepSeek、Kimi、通义、智谱、硅基流动、Ollama 等)+ Mock | | **工具链** | 15 个内置工具 + 自定义工具(HTTP 模板 / 沙箱 Python)+ MCP server 接入 | @@ -248,6 +248,24 @@ scripts/check-*.mjs 前端验证脚本 前端侧对应地只有一个翻译层 `run/decode.ts`:画布右栏、问数据页、运行详情页共用它。 分设多套的后果是同一次运行在三个页面讲出三个版本,而使用者无从判断哪个为真。 +### 画布上的动效是"正在发生的事",不是装饰 + +画布上每一个会动的东西都对应一件真实事件:边框上转一圈光弧 = 这个节点正在跑, +扫描线扫过 = 正在产出,绿色扫一遍 = 刚跑完,抖动 = 刚失败,琥珀呼吸 = 在等人, +边上的光点 = 数据正从这边流向那边。跑一张十几个节点的图,一眼要能看出"谁在动"—— +纯装饰的动效做不到这件事,只会让画布变吵。 + +多 agent 节点会**在卡片里展开协作矩阵**:花名册固定不重排,被派出去的人亮起来并 +显示当前任务,条形长度按"这一轮里最慢的那个人"归一化——三个人并排三条长短不一的 +条,墙钟为什么等于最长那条而不是三条之和,不用解释就看懂了;页脚给出并行一共省下 +多少。分支节点跑完会点亮命中的那个出口、压暗其余,实际走过的边留在画布上,就是 +这一次的执行路径。 + +矩阵和右栏泳道读的是**同一份** `reduceTeam`(`run/decode.ts`),两处不会给出不同的 +并行度。系统里关掉动效时(`prefers-reduced-motion`)光效整体不绘制而不是停住—— +停在最后一帧的光弧和堆在起点的光点看着像渲染坏了;静态下"谁在跑"由实心圆点和 +"进行中"三个字承担。 + ### 沙箱:双档隔离,以及必须声明的缺口 轻量档使用操作系统自带的访问控制:macOS 的 Seatbelt(`sandbox-exec`)、Linux 的 bubblewrap, @@ -391,7 +409,7 @@ agent 侧同样会收到实情,并被告知可直接查询 `information_schema ## 开发与验证 ```bash -# 后端:285 个测试 +# 后端:292 个测试 cd backend && pytest tests # 前端类型检查 @@ -402,6 +420,7 @@ node scripts/check-decode.mjs # 事件 → 步骤的翻译是否正确 node scripts/check-stream.mjs # 助手流组件渲染是否正确 node scripts/check-ui.mjs # 真浏览器端到端:URL 寻址、会话、运行详情 node scripts/check-canvas-layout.mjs # 排版与连线几何:线有没有叠、有没有穿过卡片 +node scripts/check-canvas-fx.mjs # 运行态动效:协作矩阵、并行度、命中的出口、关掉动效 node scripts/e2e-check.mjs # 冒烟:打开画布 → 选模板 → 运行 → 观察高亮 ``` @@ -409,6 +428,12 @@ node scripts/e2e-check.mjs # 冒烟:打开画布 → 选模板 → `check-ui` 守「整条路走得通不通」。三者可各自通过而合起来是坏的——例如 Step 字段全部正确, 组件却将其渲染为一屏转义 JSON。 +`check-canvas-fx` 守的是「事件对了但界面没跟上」:把脚本化的事件灌进 store(走的是 +页面上那一份 applyEvent / NodeCard / FlowEdge,只有事件来源是脚本),断言协作矩阵 +真的长出来了、并行度算对了、条形长度和耗时成正比、分支命中的出口被点亮、系统关了 +动效时光效确实不绘制。真跑一张多 agent 的图要连模型、要等十几秒,而且撞不上 +"三个人同时还在跑"那一瞬间——那样的检查会变成偶发失败。 + `check-canvas-layout` 单独守「图好不好看」里唯一能算的那部分:它把夹具图交给后端 `/api/copilot/layout` 排版,再直接 `import('/src/canvas/routing.ts')` 用页面上正在跑的那份 走线代码算路径,然后按几何断言——没有两条线压在同一段上、没有线从别人的卡片中间穿过去、 diff --git a/backend/app/engine/layout.py b/backend/app/engine/layout.py index b34841bf..14a52993 100644 --- a/backend/app/engine/layout.py +++ b/backend/app/engine/layout.py @@ -52,6 +52,18 @@ BRANCH_FALLBACK = "default" def _height(node: GraphNode) -> float: + """节点高度估算。 + + 多 agent 节点跑起来会在卡片里展开一块协作矩阵(前端 canvas/TeamMatrix): + 名册有几个人就多几行。排版必须按**展开后**的高度留位置——它会盖住下面那张 + 卡,而且运行结束后矩阵不会收起来(那正是要回看的东西),重叠是永久的。 + """ + if node.type == NodeType.SUPERVISOR: + n = len(node.config.get("agents") or []) + if n: + # 标题 34 + 摘要 28 + 矩阵(表头 19 + n 行 21 + 页脚 25 + 内外边距 14) + # + 正在跑的那个人多半还会带一行当前任务 26 + return 146 + 21 * n + 26 return NODE_H diff --git a/backend/tests/test_layout.py b/backend/tests/test_layout.py index 357c7679..e1938bb3 100644 --- a/backend/tests/test_layout.py +++ b/backend/tests/test_layout.py @@ -8,7 +8,7 @@ from __future__ import annotations -from app.engine.layout import NODE_W, auto_layout +from app.engine.layout import NODE_H, NODE_W, auto_layout from app.engine.schema import GraphSpec @@ -178,3 +178,48 @@ def test_self_loop_is_kept() -> None: spec = auto_layout(_graph([{"id": "a", "type": "llm"}], [("a", "a")])) assert spec.nodes[0].position.x >= 0 assert len(spec.edges) == 1 + + + +#: 前端 TeamMatrix 展开后的卡片高度:标题 34 + 摘要 28 + 表头 19 + n 行 21 +#: + 页脚 25 + 内外边距 14 + 在跑那人的任务行 26 +def _expanded_height(agents: int) -> float: + return 146 + 21 * agents + 26 + + +def _two_rows(agents: int) -> tuple[float, float]: + """同一列里放一个多 agent 节点和一个普通节点,返回两者的中心 y。""" + spec = auto_layout(_graph( + [ + {"id": "team", "type": "supervisor", + "config": {"agents": [{"name": f"a{i}"} for i in range(agents)]}}, + {"id": "peer", "type": "llm"}, + ], + [], + )) + return tuple(sorted(n.position.y for n in spec.nodes)) # type: ignore[return-value] + + +def test_supervisor_reserves_room_for_its_expanded_matrix() -> None: + """多 agent 节点跑起来会在卡片里展开协作矩阵,排版要按展开后的高度留位置。 + + 不然它一跑起来就盖住下面那张卡;而且运行结束后矩阵不会收起来(那正是要 + 回看的东西),重叠是永久的。 + """ + for agents in (2, 4, 6): + top, bottom = _two_rows(agents) + # 展开后的那张卡占的区间,必须和下面那张卡留出间距 + gap = (bottom - NODE_H / 2) - (top + _expanded_height(agents) / 2) + assert gap >= 50, f"{agents} 人矩阵会压到下面那张卡,只剩 {gap:.0f}px" + + # 名册越大留得越多;没有成员就不该白占地方 + assert _two_rows(6)[1] - _two_rows(6)[0] > _two_rows(2)[1] - _two_rows(2)[0] + + +def test_plain_nodes_keep_the_compact_row_gap() -> None: + """没有展开行为的节点不该跟着变高,否则整张图会白白被撑开。""" + ys = sorted(n.position.y for n in auto_layout(_graph( + [{"id": "a", "type": "llm"}, {"id": "b", "type": "llm"}, {"id": "c", "type": "llm"}], + [], + )).nodes) + assert ys[1] - ys[0] <= 140, f"普通节点之间隔了 {ys[1] - ys[0]:.0f}px" diff --git a/frontend/src/canvas/FlowCanvas.tsx b/frontend/src/canvas/FlowCanvas.tsx index 99e09e22..2cccc365 100644 --- a/frontend/src/canvas/FlowCanvas.tsx +++ b/frontend/src/canvas/FlowCanvas.tsx @@ -26,21 +26,45 @@ function CanvasInner() { const edges = useStudio((s) => s.edges) const activeEdges = useStudio((s) => s.activeEdges) const fitRequest = useStudio((s) => s.fitRequest) + // 只订阅一个字符串。直接订阅 runtime 的话每个 token 事件都会换一个新对象, + // 整张画布跟着重渲染一次——跑长文本时这是白烧的 + const takenKey = useStudio((s) => Object.entries(s.runtime) + .filter(([, r]) => r.takenHandle) + .map(([id, r]) => `${id}:${r.takenHandle}`) + .join('|')) const { onNodesChange, onEdgesChange, onConnect, addNode, select } = useStudio() // 走线方案整张图算一次。节点尺寸要等 React Flow 量完才有,量到之后 // nodes 会变,这里跟着重算,端口和车道就落到实测尺寸上。 const routes = useMemo(() => buildRoutes(nodes, edges), [nodes, edges]) + // 实际走过的边:分支节点记下命中的出口,据此把那条边挑出来。跑完之后画布上 + // 留下的就是**这次的执行路径**,而不是一张"所有可能性"的图 + const takenEdges = useMemo(() => { + const hit = new Set() + const takenOf = new Map() + for (const part of takenKey.split('|')) { + const at = part.indexOf(':') + if (at > 0) takenOf.set(part.slice(0, at), part.slice(at + 1)) + } + if (!takenOf.size) return hit + for (const e of edges) { + if (e.sourceHandle && takenOf.get(e.source) === e.sourceHandle) hit.add(e.id) + } + return hit + }, [takenKey, edges]) + // 正在流动的边加动画,让"数据走到哪了"看得见 const decorated = useMemo( () => - edges.map((e) => - activeEdges.includes(e.id) - ? { ...e, type: 'flow', className: 'edge-active', animated: true } - : { ...e, type: 'flow', className: undefined, animated: false }, - ), - [edges, activeEdges], + edges.map((e) => { + const active = activeEdges.includes(e.id) + const taken = takenEdges.has(e.id) + const className = [active && 'edge-active', taken && !active && 'edge-taken'] + .filter(Boolean).join(' ') || undefined + return { ...e, type: 'flow', className, animated: active } + }), + [edges, activeEdges, takenEdges], ) // 自动排版 / Copilot 生成完之后把视角拉回图上。位置全变了却不重新取景, diff --git a/frontend/src/canvas/FlowEdge.tsx b/frontend/src/canvas/FlowEdge.tsx index 90716440..d6ef8127 100644 --- a/frontend/src/canvas/FlowEdge.tsx +++ b/frontend/src/canvas/FlowEdge.tsx @@ -17,7 +17,7 @@ export const RoutingContext = createContext | null>(null) */ export function FlowEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, - label, labelStyle, markerEnd, markerStart, interactionWidth, style, + label, labelStyle, markerEnd, markerStart, interactionWidth, style, animated, }: EdgeProps) { const routes = useContext(RoutingContext) const route = routes?.get(id) @@ -27,6 +27,11 @@ export function FlowEdge({ }) const path = route?.path || fallback + // 光点按路径长度定速度:长边上的光点走得慢会像卡住了,所以让**线速度** + // 恒定,而不是让每条边都用同一个周期 + const length = route?.length || 400 + const seconds = Math.min(3, Math.max(0.8, length / 320)) + return ( <> + {/* 数据在流动:光点顺着线走。虚线只会让人觉得"在动",光点能看出往哪走。 + 只在真正活跃的边上出现——每条边都跑光点的话,画布会变成一片流星雨 */} + {animated && ( + <> + + + + )} {label != null && label !== '' && (
) { const streamed = runtime?.tokens || runtime?.thinking const summary = summarize(data.nodeType, data.config) + // 协作矩阵只在多 agent 节点上展开,而且只在它真的在协作时展开—— + // 没跑过的图不该凭空长出一块东西 + const roster = (data.config.agents ?? []) as { name?: string; description?: string }[] + const team = runtime?.team + const showTeam = data.nodeType === 'supervisor' && !!team + && (team.rounds.length > 0 || status === 'running') + + // 头部那枚轮次徽标:循环看第几/共几轮,多 agent 看第几轮 + const iteration = runtime?.iteration + const maxIterations = Number(data.config.max_iterations) || 0 + const maxRounds = Number(data.config.max_rounds) || 0 + const roundChip = data.nodeType === 'loop' && iteration + ? { text: maxIterations ? `第 ${iteration}/${maxIterations} 轮` : `第 ${iteration} 轮`, muted: false } + : showTeam && team + ? { text: `第 ${(team.rounds[team.rounds.length - 1]?.round ?? 0) + 1} 轮`, muted: false } + : null + + // 分支实际走了哪条出口。跑完之后一眼能看出选的是哪条 + const taken = runtime?.takenHandle + return (
) { )} style={{ width: NODE_WIDTH, borderColor: selected ? 'var(--accent)' : undefined }} > + {/* 光效层。独立一层,见 index.css 里 .node-fx 的说明 */} + - {/* 摘要 */} - {summary && ( + {/* 摘要。多 agent 节点展开协作矩阵之后就不再显示——那一行就是成员名单, + 矩阵里已经逐个列出来了,同一份东西说两遍只是让卡片更高 */} + {summary && !showTeam && (
{summary}
@@ -147,6 +185,16 @@ function NodeCardImpl({ id, data, selected }: NodeProps) {
)} + {/* 协作矩阵:多 agent 节点的内部编排摊开在卡片里 */} + {showTeam && team && ( + a.name).map((a) => ({ name: a.name!, description: a.description }))} + team={team} + live={status === 'running'} + maxRounds={maxRounds} + /> + )} + {/* 底部状态条 */} {(runtime?.durationMs != null || runtime?.error) && (
@@ -161,20 +209,27 @@ function NodeCardImpl({ id, data, selected }: NodeProps) {
)} - {/* 出口:有名字的分支把标签显示出来,不用点开就知道哪条是哪条 */} + {/* 出口:有名字的分支把标签显示出来,不用点开就知道哪条是哪条。 + 跑过之后,命中的那条亮起来、落空的压暗——六出口的分支节点跑完, + 不这样的话谁也说不清它到底选了哪条 */} {handles.map((handle, i) => { const top = handles.length === 1 ? '50%' : `${((i + 1) / (handles.length + 1)) * 100}%` + const hit = taken ? taken === handle.id : null return (
{handle.label && ( {handle.label} diff --git a/frontend/src/canvas/TeamMatrix.tsx b/frontend/src/canvas/TeamMatrix.tsx new file mode 100644 index 00000000..ca291c77 --- /dev/null +++ b/frontend/src/canvas/TeamMatrix.tsx @@ -0,0 +1,134 @@ +import clsx from 'clsx' +import { formatDuration } from '../run/decode' +import type { TeamMember, TeamRun } from '../types' + +/** + * 协作矩阵:supervisor 节点跑起来时在卡片内部展开的那块东西。 + * + * 多 agent 节点在画布上原本是个黑盒——卡片转 30 秒圈,然后吐一段文本。 + * "它同时派了三个人"、"谁还在跑"、"并行到底省了多少"全都要点开右栏才知道, + * 而右栏是线性的时间线,看不出并发的形状。这块东西就是把它摊开: + * + * - 花名册固定在原地(不按派单顺序重排)——几轮下来眼睛知道谁在哪一行 + * - 条形长度按**这一轮里最慢的那个人**归一化。三个人并排三条长短不一的条, + * 墙钟为什么等于最长那条、而不是三条之和,不用解释就看懂了 + * - 本轮没被派到的人不显示成"待命",而是留着它上一轮的耗时(压暗)—— + * 它已经交回来了,说它"待命"是错的,而且看的人会以为它还没干活 + * + * 数据来自 decode.ts 的 reduceTeam:右栏泳道用的是同一份,两处不会打架。 + */ + +interface Roster { + name: string + description?: string +} + +interface Props { + roster: Roster[] + team: TeamRun + /** 还在跑。用来决定措辞是"正在协作"还是"已收尾" */ + live: boolean + maxRounds?: number +} + +/** 当前该看哪一轮:有在跑的就看那一轮,否则看最后一轮 */ +function currentRound(team: TeamRun) { + const running = team.rounds.filter((r) => r.members.some((m) => m.status === 'running')) + return running.length ? running[running.length - 1] : team.rounds[team.rounds.length - 1] +} + +export function TeamMatrix({ roster, team, live, maxRounds }: Props) { + const round = currentRound(team) + // 事件里出现过、但花名册里没有的名字也要显示。配置被人改过、或者调度者报了 + // 一个不存在的成员时,这里至少能看出"确实派过这么个人" + const extra = team.members.filter((n) => !roster.some((r) => r.name === n)) + const names = [...roster.map((r) => r.name), ...extra] + if (!names.length) return null + + /** 每个人这次要显示的那条记录:本轮优先,否则它在最近一轮里的成绩 */ + const rows = names.map((name) => { + const inRound = round?.members.find((m) => m.agent === name) + let last: TeamMember | undefined + let lastRound = 0 + for (const r of team.rounds) { + const m = r.members.find((x) => x.agent === name) + if (m) { last = m; lastRound = r.round } + } + return { + name, + active: !!inRound, + member: inRound ?? last, + roundNo: inRound ? (round?.round ?? 0) : lastRound, + hint: roster.find((r) => r.name === name)?.description, + } + }) + + // 条形按同一批数归一化,长短才可比 + const busiest = Math.max(1, ...rows.map((r) => r.member?.ms ?? 0)) + const parallel = round?.parallel ?? 1 + + return ( +
+
+ + 第 {(round?.round ?? 0) + 1} 轮 + {maxRounds ? ` / 上限 ${maxRounds}` : ''} + + + {parallel > 1 ? `${parallel} 人同时进行` : live ? '串行推进' : '已收尾'} +
+ + {rows.map(({ name, active, member, roundNo, hint }) => { + const running = member?.status === 'running' + const done = member?.status === 'done' + return ( +
+
+ + {name} + + {running ? ( + + ) : done && member ? ( + + ) : null} + + + {running ? '进行中' + : done && member ? formatDuration(member.ms) + : '待命'} + +
+ {running && member?.instruction && ( +
{member.instruction}
+ )} +
+ ) + })} + +
+ {team.rounds.length} 轮 + + {/* 名册就在上面几行里,再数一遍人数是废话。这里只留一个真正要算的数: + 并行到底省了多少;一次都没并发过就直说串行 */} + 0 && 'team-saved')}> + {team.savedMs > 0 ? `并行省下 ${formatDuration(team.savedMs)}` : '全程串行'} + +
+
+ ) +} diff --git a/frontend/src/canvas/routing.ts b/frontend/src/canvas/routing.ts index 188a4980..7dca25e6 100644 --- a/frontend/src/canvas/routing.ts +++ b/frontend/src/canvas/routing.ts @@ -51,6 +51,9 @@ export interface Route { path: string labelX: number labelY: number + /** 路径长度。边上的流光按它定速度——短线走得快、长线走得慢, + * 视觉速度才是一致的;否则长边上的光点会慢得像卡住了 */ + length: number } /** buildRoutes 需要的节点信息,结构上就是 store 里的 FlowNode。 */ @@ -295,8 +298,8 @@ function roundedPath(points: Point[]): string { return d } -/** 折线中点,标签摆这儿。 */ -function polylineMidpoint(points: Point[]): Point { +/** 折线中点,标签摆这儿。顺带把总长算出来(流光的速度要用)。 */ +function polylineMidpoint(points: Point[]): { mid: Point; length: number } { const lengths: number[] = [] let total = 0 for (let i = 1; i < points.length; i++) { @@ -304,19 +307,22 @@ function polylineMidpoint(points: Point[]): Point { lengths.push(len) total += len } - if (total === 0) return points[0] + if (total === 0) return { mid: points[0], length: 0 } let target = total / 2 for (let i = 0; i < lengths.length; i++) { if (target <= lengths[i]) { const t = lengths[i] === 0 ? 0 : target / lengths[i] return { - x: points[i].x + (points[i + 1].x - points[i].x) * t, - y: points[i].y + (points[i + 1].y - points[i].y) * t, + length: total, + mid: { + x: points[i].x + (points[i + 1].x - points[i].x) * t, + y: points[i].y + (points[i + 1].y - points[i].y) * t, + }, } } target -= lengths[i] } - return points[points.length - 1] + return { mid: points[points.length - 1], length: total } } export function buildRoutes( @@ -490,11 +496,12 @@ export function buildRoutes( { x: plan.tx, y: plan.ty }, ] } - const mid = polylineMidpoint(points) + const { mid, length } = polylineMidpoint(points) routes.set(plan.edge.id, { path: roundedPath(points), labelX: mid.x, labelY: mid.y, + length, }) } diff --git a/frontend/src/index.css b/frontend/src/index.css index a006ed99..74709e26 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -181,14 +181,11 @@ pre, } } -/* 节点运行状态 */ -@keyframes pulse-ring { - 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--nt, #4f8cff) 55%, transparent); } - 70% { box-shadow: 0 0 0 9px transparent; } - 100% { box-shadow: 0 0 0 0 transparent; } -} +/* 节点运行状态。 + 以前这里是往外扩的声纳圈(pulse-ring)。换成"边框上转一圈光弧 + 一道扫描 + 线"之后声纳就多余了——同一个意思说两遍只会更吵,而且声纳往外扩会和邻居 + 卡片的边框糊在一起。 */ .node-running { - animation: pulse-ring 1.4s ease-out infinite; border-color: var(--nt, #4f8cff) !important; } .node-done { @@ -201,6 +198,340 @@ pre, border-color: var(--warn) !important; } +/* -------------------------------------------------------------------------- + 运行时的光效语汇。 + + 一条原则:**每个动效都要对应一件真实发生的事**。光环转 = 这个节点正在跑, + 扫描线过 = 正在产出,绿色扫过 = 刚跑完,抖动 = 刚失败,琥珀呼吸 = 在等人。 + 纯装饰的动效只会让画布变吵——跑一张十几个节点的图,一眼能看出"谁在动" + 才是这套东西的用处。 + + 颜色一律走 --nt / --accent / --ok 这些 token,浅色深色都能看。 + 前庭敏感的人由文件末尾那段 prefers-reduced-motion 统一停掉(这些全是 CSS + 动画,所以那段规则天然覆盖得到;不覆盖的 SMIL 一个都没用)。 + -------------------------------------------------------------------------- */ + +/* 光环的角度要能插值,得先声明成一个 属性 */ +@property --fx-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} + +/* 光效层。做成独立一层而不是伪元素:卡片自己的背景会盖住伪元素, + 而 overflow:hidden 又不能加在卡片上——分支节点的"yes/no"标签是挂在 + 卡片外面的,一裁就没了 */ +.node-fx { + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + pointer-events: none; +} +/* 只负责裁剪的那一层:正好是卡片框,扫描线不会漏到邻居卡片上 */ +.fx-clip { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: inherit; +} + +/* 正在跑:一圈绕着卡片转的光弧 */ +@keyframes fx-halo { + to { --fx-angle: 360deg; } +} +.fx-halo { + position: absolute; + inset: -1.5px; + border-radius: inherit; + padding: 1.5px; + background: conic-gradient( + from var(--fx-angle), + transparent 0deg, + transparent 300deg, + color-mix(in srgb, var(--nt) 70%, transparent) 340deg, + var(--nt) 358deg, + transparent 360deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + animation: fx-halo 2.2s linear infinite; +} + +/* 正在跑:一条光带从上往下扫过卡片,像在读取/产出 */ +@keyframes fx-scan { + 0% { top: -70px; opacity: 0; } + 15% { opacity: 1; } + 85% { opacity: 1; } + 100% { top: 100%; opacity: 0; } +} +.fx-scan { + position: absolute; + left: 0; + right: 0; + height: 48px; + background: linear-gradient( + to bottom, + transparent, + color-mix(in srgb, var(--nt) 8%, transparent), + transparent + ); + animation: fx-scan 2.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 刚跑完:绿色扫过一遍。只跑一次,跑完停在画面外—— + 常驻的完成动画会让整张画布一直在动,而"完成了"这件事只需要说一次 */ +@keyframes fx-done { + from { transform: translateX(-110%); } + to { transform: translateX(110%); } +} +.fx-done { + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--ok) 30%, transparent), + transparent + ); + animation: fx-done 0.85s cubic-bezier(0.32, 0.72, 0, 1) 1 forwards; +} + +/* 刚失败:短促地抖一下。幅度压在 3px 以内——再大就不像"出错"像"地震" */ +@keyframes fx-shake { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-3px); } + 30% { transform: translateX(3px); } + 45% { transform: translateX(-2px); } + 60% { transform: translateX(2px); } + 80% { transform: translateX(-1px); } +} +.node-failed { + animation: fx-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) 1; +} + +/* 在等人:琥珀色的呼吸。用 box-shadow 而不是边框颜色——边框那圈已经 + 被状态色占着了,两个都改会互相盖 */ +@keyframes fx-breathe { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 40%, transparent); } + 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--warn) 0%, transparent); } +} +.node-waiting { + animation: fx-breathe 2.2s ease-in-out infinite; +} + +/* 头部那枚"第 N 轮"徽标 */ +.round-chip { + display: inline-flex; + align-items: center; + gap: 3px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--nt) 45%, transparent); + background: color-mix(in srgb, var(--nt) 12%, transparent); + color: var(--nt); + padding: 0 5px; + font-size: 9px; + font-variant-numeric: tabular-nums; + line-height: 14px; + white-space: nowrap; +} +.round-chip-muted { + border-color: var(--border); + background: transparent; + color: var(--text-faint); +} + +/* -------------------------------------------------------------------------- + 协作矩阵:supervisor 节点展开出来的内部编排。 + + 它要回答三个问题:这一轮派了几个人、谁还在跑、并行到底省了多少。 + 条形长度按"这一轮里最长的那个人"归一化——三个人并排三条长短不一的条, + 一眼就能看出墙钟为什么等于最长那条而不是三条之和。 + -------------------------------------------------------------------------- */ +.team-matrix { + margin: 0 8px 8px; + border-top: 1px dashed color-mix(in srgb, var(--nt) 35%, transparent); + padding-top: 6px; +} +.team-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 5px; + font-size: 9.5px; + color: var(--text-faint); + letter-spacing: 0.02em; +} +.team-head-rule { + flex: 1; + height: 1px; + background: linear-gradient( + to right, + color-mix(in srgb, var(--nt) 40%, transparent), + transparent + ); +} + +.team-row { + position: relative; + display: flex; + align-items: center; + gap: 6px; + height: 21px; + padding-left: 6px; + border-left: 2px solid transparent; + font-size: 10.5px; +} +/* 被派出去的成员:左边一条竖线把它们串成一组 */ +.team-row-active { + border-left-color: color-mix(in srgb, var(--nt) 70%, transparent); +} +.team-row-running .team-name { + color: var(--text); +} +.team-row-idle { + opacity: 0.42; +} +/* 前几轮干过、这一轮没它:压暗但留着成绩,不是"待命" */ +.team-row-past { + opacity: 0.6; +} + +.team-dot { + width: 6px; + height: 6px; + flex: none; + border-radius: 999px; + border: 1.5px solid var(--border-strong); +} +.team-row-running .team-dot { + border-color: var(--nt); + background: var(--nt); + animation: team-dot 1.1s ease-in-out infinite; +} +@keyframes team-dot { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--nt) 55%, transparent); } + 70% { box-shadow: 0 0 0 5px transparent; } +} +.team-row-done .team-dot { + border-color: var(--ok); + background: var(--ok); +} + +.team-name { + flex: none; + max-width: 84px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-dim); +} +.team-bar { + position: relative; + flex: 1; + height: 3px; + min-width: 18px; + border-radius: 999px; + background: var(--bg-hover); + overflow: hidden; +} +.team-bar-fill { + position: absolute; + inset: 0 auto 0 0; + border-radius: 999px; + background: var(--ok); + transition: width 0.4s cubic-bezier(0.32, 0.72, 0, 1); +} +/* 还在跑:不知道要多久,所以是不定长的流光,不是百分比 */ +.team-bar-running { + position: absolute; + top: 0; + bottom: 0; + width: 40%; + border-radius: 999px; + background: linear-gradient( + 90deg, + transparent, + var(--nt), + transparent + ); + animation: team-shimmer 1.15s ease-in-out infinite; +} +@keyframes team-shimmer { + from { transform: translateX(-110%); } + to { transform: translateX(260%); } +} +.team-ms { + flex: none; + min-width: 34px; + text-align: right; + color: var(--text-faint); + font-variant-numeric: tabular-nums; + font-size: 9.5px; +} + +/* 正在跑的那个人在干什么。只给在跑的看,跑完的留在右栏里慢慢读 */ +.team-note { + margin: -1px 0 3px 14px; + padding-left: 6px; + border-left: 1px solid color-mix(in srgb, var(--nt) 30%, transparent); + font-size: 9.5px; + line-height: 1.35; + color: var(--text-faint); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.team-foot { + display: flex; + align-items: center; + gap: 5px; + margin-top: 6px; + padding-top: 5px; + border-top: 1px dashed color-mix(in srgb, var(--nt) 25%, transparent); + font-size: 9.5px; + color: var(--text-faint); + font-variant-numeric: tabular-nums; +} +.team-saved { + color: var(--ok); + font-weight: 500; +} + +/* 出口被选中/落空:分支节点跑完要能一眼看出走了哪条 */ +.react-flow__handle.handle-taken { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); +} +.react-flow__handle.handle-idle { + opacity: 0.35; +} + +/* 实际走过的边:运行结束后留在画布上,就是这次的执行路径 */ +.react-flow__edge.edge-taken .react-flow__edge-path { + stroke: color-mix(in srgb, var(--accent) 75%, transparent); + stroke-width: 2; +} + +/* 活跃边上跑的光点:方向感靠它,虚线只会让人觉得"在动",看不出往哪走 */ +.edge-packet { + fill: var(--accent); + filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 80%, transparent)); + offset-rotate: 0deg; + animation-name: packet-run; + animation-timing-function: linear; + animation-iteration-count: infinite; +} +@keyframes packet-run { + from { offset-distance: 0%; } + to { offset-distance: 100%; } +} + /* Copilot 刚放上来的节点:紫色描边 + 出现动画,final 后几秒淡出 */ @keyframes copilot-pop { from { opacity: 0; transform: scale(0.85); } @@ -305,6 +636,17 @@ pre, background-clip: border-box; color: var(--text-dim); } + /* 边框上的光弧、扫描线、流光条、边上的光点——这些"停了就等于没有"的 + 直接不画。只把时长压到 0.01ms 的话它们会停在动画的最后一帧:光弧卡在 + 角落、光点全堆在起点,看着像渲染坏了。 + 正在跑的成员不会因此看不出状态:圆点是实心的,右边写着"进行中" */ + .fx-halo, + .fx-scan, + .fx-done, + .edge-packet, + .team-bar-running { + display: none; + } } /* 表单控件统一外观 */ diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index cdcedc8e..5397b6d2 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1,4 +1,8 @@ -import type { RunEvent } from '../types' +import type { RunEvent, TeamMember, TeamRound, TeamRun } from '../types' + +// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在 +// types.ts 里;这里再导出一遍,老引用不用改 +export type { TeamMember, TeamRound, TeamRun } /** * 事件 → 人能看懂的步骤。 @@ -57,42 +61,6 @@ export interface Step { team?: TeamRun } -/** - * 协作团队一轮里的一个人。 - * - * ms 是**各自量出来**的耗时,不是拿整轮墙钟摊的——界面要显示"并行省了多少", - * 那个数按 N×墙钟 推算会把快的那个也记成最慢那条,省下的时间就被夸大了。 - */ -export interface TeamMember { - agent: string - instruction: string - ms: number - status: StepStatus - result?: string -} - -export interface TeamRound { - round: number - /** 这一轮同时派了几个人。1 就是串行的一步 */ - parallel: number - /** 这一轮实际花的时间:并发时是最慢那个 */ - wallMs: number - /** 各人耗时之和:并发时它大于 wallMs,差值就是省下的 */ - sumMs: number - reason?: string - members: TeamMember[] -} - -export interface TeamRun { - /** 花名册,按第一次出现的顺序——泳道的行顺序 */ - members: string[] - rounds: TeamRound[] - /** 并行一共省下多少毫秒。全程串行则为 0 */ - savedMs: number - /** 协作是不是已经收尾了 */ - finished: boolean -} - // 这两个是流式增量,后端根本不落库(_EPHEMERAL)。单次运行的 delta 量级会 // 把列表淹掉,而且刷新页面后它们不会回来——UI 不能建立在它们之上。 const EPHEMERAL = new Set(['llm.token', 'llm.thinking.delta']) @@ -397,6 +365,91 @@ function groupConcurrent( } } +/** + * 把一个事件并进协作团队的泳道数据,返回**新的** TeamRun。 + * + * 时间线和画布都要这份数据:时间线用它画泳道,画布用它把 supervisor 节点 + * 展开成协作矩阵。两边各写一遍的话,同一个运行在右栏和画布上会给出不同的 + * 并行度——而"几个人同时在跑、省了多少"正是这个节点唯一值得看的东西。 + * 所以只留这一份翻译,两处都调它。 + * + * 不可变:每次返回新对象。画布那份挂在 zustand 的节点状态上,原地改的话 + * 引用不变,React 收不到更新。 + * + * 返回 null 表示这个事件和协作团队无关(绝大多数事件都是)。 + */ +export function reduceTeam(prev: TeamRun | undefined, event: RunEvent): TeamRun | null { + const d = (event.data ?? {}) as Record + const nodeId = event.node_id + if (!nodeId) return null + + const roundOf = (team: TeamRun, round: number): TeamRound => + team.rounds.find((x) => x.round === round) ?? { + round, parallel: 1, wallMs: 0, sumMs: 0, members: [], + } + + const withRound = (team: TeamRun, round: number, patch: Partial): TeamRun => { + const next = { ...roundOf(team, round), ...patch } + const rounds = [...team.rounds.filter((x) => x.round !== round), next] + .sort((a, b) => a.round - b.round) + const savedMs = rounds.reduce((acc, x) => acc + Math.max(0, x.sumMs - x.wallMs), 0) + return { ...team, rounds, savedMs } + } + + switch (event.type) { + case 'agent.step.start': { + const team = prev ?? { members: [], rounds: [], savedMs: 0, finished: false } + const name = String(d.agent ?? '') + const round = num(d.round) ?? 0 + const cur = roundOf(team, round) + const members = [...cur.members, { + agent: name, instruction: String(d.instruction ?? ''), ms: 0, + status: 'running' as StepStatus, + }] + return withRound( + { ...team, members: team.members.includes(name) ? team.members : [...team.members, name] }, + round, + { members, parallel: Math.max(cur.parallel, num(d.parallel) ?? 1) }, + ) + } + + case 'agent.step.end': { + if (!prev) return null + const name = String(d.agent ?? '') + const round = num(d.round) ?? 0 + const cur = roundOf(prev, round) + const ms = num(d.duration_ms) ?? 0 + const preview = String(d.preview ?? '').slice(0, 2000) + const idx = cur.members.findIndex((x) => x.agent === name && x.status === 'running') + const members = idx >= 0 + ? cur.members.map((x, i) => (i === idx ? { ...x, ms, status: 'done' as StepStatus, result: preview || undefined } : x)) + : [...cur.members, { agent: name, instruction: '', ms, status: 'done' as StepStatus, result: preview || undefined }] + // 这一轮实际花的是最慢那个,各人之和减去它就是省下的 + return withRound(prev, round, { + members, + wallMs: Math.max(...members.map((x) => x.ms)), + sumMs: members.reduce((acc, x) => acc + x.ms, 0), + }) + } + + case 'log': { + // 带 round 的 info 日志是调度决策,不是排查日志 + if (String(d.level ?? 'info') !== 'info' || d.round == null) return null + const team = prev ?? { members: [], rounds: [], savedMs: 0, finished: false } + const structured = Array.isArray(d.agents) + const text = String(d.message ?? '') + const legacy = text.match(/^调度\s*→\s*([^((]+)[((](.*)[))]\s*$/) + const done = structured ? !!d.done : legacy?.[1]?.trim() === 'FINISH' + const reason = structured ? String(d.reason ?? '') : (legacy?.[2]?.trim() ?? '') + if (done) return { ...team, finished: true } + return reason ? withRound(team, num(d.round) ?? 0, { reason }) : team + } + + default: + return null + } +} + /** * 把一次运行的事件解码成步骤树。 * @@ -418,24 +471,10 @@ export function decodeRun(events: RunEvent[]): Step[] { /** node_id → 起止时刻。用来事后认出"哪几个节点是同时跑的" */ const spans = new Map() - const teamOf = (nodeId: string | undefined): TeamRun | null => { - if (!nodeId) return null - let t = teams.get(nodeId) - if (!t) { - t = { members: [], rounds: [], savedMs: 0, finished: false } - teams.set(nodeId, t) - } - return t - } - - const roundOf = (team: TeamRun, round: number): TeamRound => { - let r = team.rounds.find((x) => x.round === round) - if (!r) { - r = { round, parallel: 1, wallMs: 0, sumMs: 0, members: [] } - team.rounds.push(r) - team.rounds.sort((a, b) => a.round - b.round) - } - return r + /** 协作团队的状态只有一个来源:reduceTeam。画布那边调的是同一个函数 */ + const trackTeam = (event: RunEvent): void => { + const next = reduceTeam(teams.get(event.node_id ?? ''), event) + if (next && event.node_id) teams.set(event.node_id, next) } /** * node_id → 这个节点当前那条尚未闭合的审批步骤。 @@ -774,18 +813,7 @@ export function decodeRun(events: RunEvent[]): Step[] { } pendingAgents.set(agentKey(nodeId, d), step) push(step, nodeId) - - const team = teamOf(nodeId) - if (team) { - const name = String(d.agent ?? '') - if (name && !team.members.includes(name)) team.members.push(name) - const r = roundOf(team, num(d.round) ?? 0) - r.parallel = Math.max(r.parallel, num(d.parallel) ?? 1) - r.members.push({ - agent: name, instruction: String(d.instruction ?? ''), - ms: 0, status: 'running', - }) - } + trackTeam(event) break } @@ -805,23 +833,7 @@ export function decodeRun(events: RunEvent[]): Step[] { detail: preview, }, nodeId) } - - const team = teamOf(nodeId) - if (team) { - const r = roundOf(team, num(d.round) ?? 0) - const name = String(d.agent ?? '') - const ms = num(d.duration_ms) ?? 0 - const m = r.members.find((x) => x.agent === name && x.status === 'running') - ?? (r.members.push({ agent: name, instruction: '', ms: 0, status: 'running' }), - r.members[r.members.length - 1]) - m.ms = ms - m.status = 'done' - m.result = preview || undefined - // 这一轮实际花的是最慢那个,各人之和减去它就是省下的 - r.wallMs = Math.max(...r.members.map((x) => x.ms)) - r.sumMs = r.members.reduce((acc, x) => acc + x.ms, 0) - team.savedMs = team.rounds.reduce((acc, x) => acc + Math.max(0, x.sumMs - x.wallMs), 0) - } + trackTeam(event) break } @@ -845,11 +857,7 @@ export function decodeRun(events: RunEvent[]): Step[] { : (legacyTarget && legacyTarget !== 'FINISH' ? [legacyTarget] : []) const done = structured ? !!d.done : legacyTarget === 'FINISH' const reason = structured ? String(d.reason ?? '') : (m?.[2].trim() ?? '') - const team = teamOf(nodeId) - if (team) { - if (done) team.finished = true - else if (reason) roundOf(team, num(d.round) ?? 0).reason = reason - } + trackTeam(event) // FINISH 是协议里的收尾标记,不是某个 agent。"交给 FINISH" // 会让人以为还有个叫 FINISH 的成员 diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index 97a07025..92eb72aa 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -6,6 +6,7 @@ import { import { api, streamCopilot, streamRun } from '../api/client' import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs' import type { CopilotOp } from '../run/decode' +import { reduceTeam } from '../run/decode' import type { GraphEdge, GraphSpec, NodeRuntime, NodeType, Run, RunEvent, ValidationIssue, VarIssue, Variable, Workflow, @@ -796,13 +797,19 @@ export const useStudio = create((set, get) => ({ break case 'tool.start': patch({ - toolCalls: [...(runtime[nodeId!]?.toolCalls ?? []), { tool: event.data.tool, args: event.data.args }], + toolCalls: [...(runtime[nodeId!]?.toolCalls ?? []), + { tool: event.data.tool, args: event.data.args, agent: event.data.agent }], }) break case 'tool.end': case 'tool.error': { const calls = [...(runtime[nodeId!]?.toolCalls ?? [])] - const idx = calls.map((c) => c.tool).lastIndexOf(event.data.tool) + // 多 agent 时同一个工具名可能被两个成员同时调,只按名字找会配错人 + const idx = calls + .map((c, i) => (c.tool === event.data.tool + && (event.data.agent == null || c.agent === event.data.agent) ? i : -1)) + .filter((i) => i >= 0) + .pop() ?? -1 if (idx >= 0) { calls[idx] = { ...calls[idx], result: event.data.preview, ok: event.type === 'tool.end' } } @@ -822,6 +829,21 @@ export const useStudio = create((set, get) => ({ break } + // 协作矩阵、循环轮次、命中的出口:都从事件里读,只走 decode.ts 那一份翻译, + // 免得画布和右栏对同一次运行给出不同的说法 + const team = reduceTeam(runtime[nodeId ?? '']?.team, event) + if (team && nodeId) patch({ team }) + if (event.type === 'edge.taken') { + const handle = String(event.data.branch ?? '') + const iteration = Number(event.data.iteration) + patch({ + takenHandle: handle, + reason: event.data.reason ? String(event.data.reason) : undefined, + // loop 的 edge.taken 带 iteration,从 0 数;卡片上给人看的是第几轮 + ...(Number.isFinite(iteration) ? { iteration: iteration + 1 } : {}), + }) + } + // 高亮当前正在流动的边:从已完成节点指向正在运行的节点 const running = Object.entries(runtime) .filter(([, r]) => r.status === 'running' || r.status === 'waiting') diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 781fb61f..3019231f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -158,7 +158,52 @@ export interface NodeRuntime { error?: string tokens?: string thinking?: string - toolCalls?: { tool: string; args?: any; result?: string; ok?: boolean }[] + toolCalls?: { tool: string; args?: any; result?: string; ok?: boolean; agent?: string }[] + /** supervisor 的协作矩阵:谁被派了、在第几轮、几个人同时在跑。运行中长出来 */ + team?: TeamRun + /** loop 走到第几轮(从 1 数)。卡片上那个"第 2/5 轮" */ + iteration?: number + /** 分支/循环实际走的出口。命中的那条亮起来、其余压暗——不然一个六出口的 + * 分支节点跑完,谁也说不清它到底选了哪条 */ + takenHandle?: string + /** 走这条出口的理由:表达式分支命中了哪条条件,或调度者为什么派给这个人 */ + reason?: string +} + +/** + * 协作团队一轮里的一个人。 + * + * ms 是**各自量出来**的耗时,不是拿整轮墙钟摊的——界面要显示"并行省了多少", + * 那个数按 N×墙钟 推算会把快的那个也记成最慢那条,省下的时间就被夸大了。 + */ +export interface TeamMember { + agent: string + instruction: string + ms: number + status: 'running' | 'done' | 'failed' | 'waiting' + result?: string +} + +export interface TeamRound { + round: number + /** 这一轮同时派了几个人。1 就是串行的一步 */ + parallel: number + /** 这一轮实际花的时间:并发时是最慢那个 */ + wallMs: number + /** 各人耗时之和:并发时它大于 wallMs,差值就是省下的 */ + sumMs: number + reason?: string + members: TeamMember[] +} + +export interface TeamRun { + /** 花名册,按第一次出现的顺序——泳道的行顺序 */ + members: string[] + rounds: TeamRound[] + /** 并行一共省下多少毫秒。全程串行则为 0 */ + savedMs: number + /** 协作是不是已经收尾了 */ + finished: boolean } /** 一处 {{ }} 引用 */ diff --git a/scripts/check-canvas-fx.mjs b/scripts/check-canvas-fx.mjs new file mode 100644 index 00000000..ef92ee66 --- /dev/null +++ b/scripts/check-canvas-fx.mjs @@ -0,0 +1,241 @@ +// 画布运行态动效的检查:把脚本化的事件灌进 store,看画布上真的长出了东西。 +// +// 为什么不是"真跑一张图":多 agent 那张图要连模型、要等十几秒,而且撞不上 +// "三个人同时还在跑"那一瞬间——检查会变成偶发失败。dev 构建把 store 挂在 +// window.__studio 上,这里直接往里灌事件:走的是页面上那一份 applyEvent / +// NodeCard / FlowEdge,只有事件来源是脚本。 +// +// 守的是几件"事件对了但界面没跟上"的事:协作矩阵没长出来、并行度没显示、 +// 分支跑完看不出走了哪条、动效在系统关了动效时还在播。 +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api' +const CHROME = process.env.CHROME_PATH + ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} + +const agent = (name, description) => ({ name, description, system: name, tools: ['web_search'] }) + +const GRAPH = { + nodes: [ + { id: 'start', type: 'input', data: { label: '问题', config: { fields: [{ name: 'q' }] } } }, + { id: 'team', type: 'supervisor', data: { label: '研究协作团队', config: { + goal: '多角度研究', max_rounds: 4, max_parallel: 3, + agents: [agent('检索员', '找资料'), agent('分析员', '交叉验证'), + agent('撰写员', '写成结论'), agent('审校', '核对数字')], + } } }, + { id: 'gate', type: 'branch', data: { label: '质量门', config: { mode: 'expression', cases: [ + { key: 'ok', condition: "vars.verdict == 'ok'", label: '通过' }, + { key: 'fail', condition: "vars.verdict == 'fail'", label: '打回' }, + ] } } }, + { id: 'retry', type: 'loop', data: { label: '返工重试', config: { mode: 'while', condition: 'x', max_iterations: 5 } } }, + { id: 'done', type: 'output', data: { label: '出具', config: { fields: [{ name: 'r' }] } } }, + ], + edges: [ + { source: 'start', target: 'team' }, + { source: 'team', target: 'gate' }, + { source: 'gate', target: 'done', sourceHandle: 'ok' }, + { source: 'gate', target: 'retry', sourceHandle: 'fail' }, + { source: 'retry', target: 'done', sourceHandle: 'done' }, + ], +} + +const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, data, ts: Date.now() / 1000 }) + +/** 三个人同时开工,然后各自交回 */ +const PARALLEL = [ + ev(1, 'run.started', null, { nodes: 5 }), + ev(2, 'node.started', 'start', {}), + ev(3, 'node.finished', 'start', { duration_ms: 0, preview: { q: 'x' } }), + ev(4, 'node.started', 'team', { node_type: 'supervisor' }), + ev(5, 'log', 'team', { level: 'info', round: 0, agents: ['检索员', '分析员', '撰写员'], done: false, + parallel: 3, reason: '三个方向互不依赖', message: '调度 → 检索员、分析员、撰写员 · 3 人同时进行' }), + ev(6, 'agent.step.start', 'team', { agent: '检索员', instruction: '把资料找全', round: 0, parallel: 3 }), + ev(7, 'agent.step.start', 'team', { agent: '分析员', instruction: '交叉验证', round: 0, parallel: 3 }), + ev(8, 'agent.step.start', 'team', { agent: '撰写员', instruction: '写成结论', round: 0, parallel: 3 }), +] + +const SETTLED = [ + ev(9, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 6600, round: 0, parallel: 3, preview: '8 篇' }), + ev(10, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 9800, round: 0, parallel: 3, preview: '2 处矛盾' }), + ev(11, 'agent.step.end', 'team', { agent: '撰写员', duration_ms: 4200, round: 0, parallel: 3, preview: '草案' }), + ev(12, 'log', 'team', { level: 'info', round: 1, agents: ['审校'], done: false, parallel: 1, + reason: '数字要有人核对', message: '调度 → 审校' }), + ev(13, 'agent.step.start', 'team', { agent: '审校', instruction: '核对数字', round: 1, parallel: 1 }), +] + +const FINISHED = [ + ev(21, 'node.finished', 'retry', { duration_ms: 0, preview: { __decision__: 'body', iteration: 0 } }), + ev(22, 'run.finished', null, { output: {}, usage: {}, duration_ms: 30000 }), +] + +const BRANCH = [ + ev(14, 'node.finished', 'team', { duration_ms: 26400, preview: { text: '结论' } }), + ev(15, 'log', 'team', { level: 'info', round: 2, agents: [], done: true, reason: '可出具', message: '调度 → 结束协作' }), + ev(16, 'node.started', 'gate', {}), + ev(17, 'edge.taken', 'gate', { branch: 'fail', reason: "命中条件:vars.verdict == 'fail'", mode: 'expression' }), + ev(18, 'node.finished', 'gate', { duration_ms: 0, preview: { __decision__: 'fail' } }), + ev(19, 'node.started', 'retry', {}), + ev(20, 'edge.taken', 'retry', { branch: 'body', iteration: 0, mode: 'while' }), +] + +async function openStudio(extra = {}) { + const browser = await chromium.launch({ executablePath: CHROME }) + const ctx = await browser.newContext({ viewport: { width: 1400, height: 950 }, ...extra }) + const page = await ctx.newPage() + const errors = [] + page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)) + page.on('console', (m) => { if (m.type() === 'error' && !m.text().includes('404')) errors.push(m.text()) }) + + // 先让后端把坐标排好再落库。图里没有坐标的话所有节点都堆在原点, + // 这张"最近更新的工作流"会被别的检查脚本(check-ui 打开 /studio 落到第一张) + // 撞上,那边点节点就会点到叠在一起的东西上——检查之间不该互相下绊子 + const laid = await (await fetch(`${API}/copilot/layout`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ graph: GRAPH }), + })).json() + + const list = await (await fetch(`${API}/workflows`)).json() + const found = list.find((w) => w.name === '__fx_check__') + const id = found + ? found.id + : (await (await fetch(`${API}/workflows`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: '__fx_check__', graph: laid }), + })).json()).id + if (found) { + await fetch(`${API}/workflows/${id}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ graph: laid }), + }) + } + await page.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(900) + await page.locator('.react-flow__controls-fitview').click() + await page.waitForTimeout(500) + return { browser, page, errors, id } +} + +const feed = (page, events) => page.evaluate((list) => { + for (const e of list) window.__studio.getState().applyEvent(e) +}, events) + +// ---------------------------------------------------------------- 展开 + +console.log('=== 多 agent 节点展开协作矩阵 ===') +{ + const { browser, page, errors } = await openStudio() + await feed(page, PARALLEL) + await page.waitForTimeout(500) + + const matrix = page.locator('.team-matrix').first() + check('矩阵长出来了', await matrix.count() > 0) + + const rows = page.locator('.team-row') + check('花名册全员在列(含没被派到的)', await rows.count() === 4, `${await rows.count()} 行`) + check('三个人同时在跑', await page.locator('.team-row-running').count() === 3, + `${await page.locator('.team-row-running').count()} 行`) + check('没被派到的那个是待命', await page.locator('.team-row-idle').count() === 1) + + const body = await matrix.innerText() + check('说出了并行度', body.includes('3 人同时进行'), body.split('\n')[0]) + check('说了是第几轮', /第 1 轮/.test(body)) + check('在跑的人带着当前任务', body.includes('把资料找全')) + + // 正在跑的节点:光环和扫描线都得在 + check('运行中的卡片有光弧', await page.locator('.node-running .fx-halo').count() > 0) + check('运行中的卡片有扫描线', await page.locator('.node-running .fx-scan').count() > 0) + check('活跃边上有流动的光点', await page.locator('.edge-packet').count() > 0, + `${await page.locator('.edge-packet').count()} 个`) + + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + await browser.close() +} + +// ---------------------------------------------------------------- 收束 + +console.log('=== 一轮交回之后 ===') +{ + const { browser, page } = await openStudio() + await feed(page, [...PARALLEL, ...SETTLED]) + await page.waitForTimeout(500) + + const matrix = page.locator('.team-matrix').first() + const body = await matrix.innerText() + check('交回的人不再是"进行中"', await page.locator('.team-row-running').count() === 1, + `${await page.locator('.team-row-running').count()} 个还在跑`) + check('说了跑了多少轮', /^\d+ 轮$/m.test(body), body.match(/\d+ 轮/)?.[0]) + check('算出了并行省下的时间', /并行省下/.test(body), body.match(/并行省下[^\n]*/)?.[0]) + + // 条形长度按最慢那个归一化:9.8s 的那条必须比 4.2s 的长 + const widths = await page.locator('.team-bar-fill').evaluateAll( + (els) => els.map((el) => parseFloat(el.style.width))) + check('条形长度和耗时成正比', widths.length >= 2 && Math.max(...widths) === 100 + && widths.every((w) => w > 0 && w <= 100), widths.join(', ')) + + // 前几轮干过的人不该被写成"待命" + check('上一轮干过的人保留成绩', body.includes('6.6s') && body.includes('9.8s')) + await browser.close() +} + +// ---------------------------------------------------------------- 分支与循环 + +console.log('=== 分支命中的出口 / 循环第几轮 ===') +{ + const { browser, page } = await openStudio() + await feed(page, [...PARALLEL, ...SETTLED, ...BRANCH]) + await page.waitForTimeout(500) + + const gate = page.locator('.react-flow__node[data-id="gate"]') + check('命中的出口被点亮', await gate.locator('.handle-taken').count() === 1) + check('落空的出口被压暗', await gate.locator('.handle-idle').count() === 2, + `${await gate.locator('.handle-idle').count()} 个`) + // 还在跑的时候那条边是"活跃"(虚线 + 光点);跑完才落成"走过的边" + check('跑动中那条边是活跃的', await page.locator('.react-flow__edge.edge-active').count() > 0, + `${await page.locator('.react-flow__edge.edge-active').count()} 条`) + await feed(page, FINISHED) + await page.waitForTimeout(400) + check('跑完之后执行路径留在画布上', + await page.locator('.react-flow__edge.edge-taken').count() > 0, + `${await page.locator('.react-flow__edge.edge-taken').count()} 条`) + + const retry = page.locator('.react-flow__node[data-id="retry"]') + check('循环显示第几轮 / 上限', (await retry.innerText()).includes('第 1/5 轮'), + await retry.innerText().then((t) => t.split('\n')[0])) + await browser.close() +} + +// ---------------------------------------------------------------- 关掉动效 + +console.log('=== 系统关了动效 ===') +{ + const { browser, page } = await openStudio({ reducedMotion: 'reduce' }) + await feed(page, PARALLEL) + await page.waitForTimeout(400) + + const styles = await page.evaluate(() => { + const el = document.querySelector('.fx-halo') + return { + matches: matchMedia('(prefers-reduced-motion: reduce)').matches, + halo: el ? getComputedStyle(el).display : '不在 DOM 里', + packet: document.querySelector('.edge-packet') + ? getComputedStyle(document.querySelector('.edge-packet')).display : '不在 DOM 里', + } + }) + check('浏览器确实处在减少动效模式', styles.matches) + check('光弧不再绘制', styles.halo === 'none', styles.halo) + check('光点不再绘制', styles.packet === 'none', styles.packet) + // 状态本身还得看得出来,不能因为关了动效就变成"不知道在不在跑" + const body = await page.locator('.team-matrix').first().innerText() + check('静态下仍看得出谁在跑', body.includes('进行中')) + await browser.close() +} + +console.log(failed ? `\n${failed} 项未通过` : '\n全部通过') +process.exit(failed ? 1 : 0)