mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
多 agent 节点在画布上展开协作矩阵,运行时的动效统一成一套语汇
多 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 卡片必须在排版里留出位置)。
This commit is contained in:
@@ -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')` 用页面上正在跑的那份
|
||||
走线代码算路径,然后按几何断言——没有两条线压在同一段上、没有线从别人的卡片中间穿过去、
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string>()
|
||||
const takenOf = new Map<string, string>()
|
||||
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<Edge[]>(
|
||||
() =>
|
||||
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 生成完之后把视角拉回图上。位置全变了却不重新取景,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const RoutingContext = createContext<Map<string, Route> | 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 (
|
||||
<>
|
||||
<BaseEdge
|
||||
@@ -36,6 +41,24 @@ export function FlowEdge({
|
||||
markerStart={markerStart}
|
||||
interactionWidth={interactionWidth}
|
||||
/>
|
||||
{/* 数据在流动:光点顺着线走。虚线只会让人觉得"在动",光点能看出往哪走。
|
||||
只在真正活跃的边上出现——每条边都跑光点的话,画布会变成一片流星雨 */}
|
||||
{animated && (
|
||||
<>
|
||||
<circle
|
||||
className="edge-packet" r={2.6}
|
||||
style={{ offsetPath: `path("${path}")`, animationDuration: `${seconds}s` }}
|
||||
/>
|
||||
<circle
|
||||
className="edge-packet" r={1.8} opacity={0.55}
|
||||
style={{
|
||||
offsetPath: `path("${path}")`,
|
||||
animationDuration: `${seconds}s`,
|
||||
animationDelay: `${-seconds / 2}s`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{label != null && label !== '' && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle, Check, Hand, Loader2, Sparkles, Wrench } from 'lucide-re
|
||||
import clsx from 'clsx'
|
||||
import { NODE_DEFS, sourceHandles } from './nodeDefs'
|
||||
import { NODE_WIDTH } from './routing'
|
||||
import { TeamMatrix } from './TeamMatrix'
|
||||
import { formatDuration } from '../run/decode'
|
||||
import { useStudio, type FlowNode } from '../store/studio'
|
||||
import type { NodeType } from '../types'
|
||||
@@ -68,6 +69,26 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
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 (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -81,6 +102,17 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
)}
|
||||
style={{ width: NODE_WIDTH, borderColor: selected ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{/* 光效层。独立一层,见 index.css 里 .node-fx 的说明 */}
|
||||
<div className="node-fx" aria-hidden="true">
|
||||
{status === 'running' && (
|
||||
<>
|
||||
<div className="fx-halo" />
|
||||
<div className="fx-clip"><div className="fx-scan" /></div>
|
||||
</>
|
||||
)}
|
||||
{status === 'done' && <div className="fx-clip"><div className="fx-done" /></div>}
|
||||
</div>
|
||||
|
||||
{def?.hasTarget && (
|
||||
<Handle type="target" position={Position.Left} style={{ left: -5 }} />
|
||||
)}
|
||||
@@ -99,6 +131,11 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
{data.label || def?.label}
|
||||
</div>
|
||||
</div>
|
||||
{roundChip && (
|
||||
<span className={clsx('round-chip', roundChip.muted && 'round-chip-muted')}>
|
||||
{roundChip.text}
|
||||
</span>
|
||||
)}
|
||||
{status === 'running' && <Loader2 size={12} className="animate-spin" style={{ color: 'var(--nt)' }} />}
|
||||
{status === 'done' && <Check size={12} className="text-[var(--ok)]" />}
|
||||
{status === 'waiting' && <Hand size={12} className="text-[var(--warn)]" />}
|
||||
@@ -108,8 +145,9 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 摘要 */}
|
||||
{summary && (
|
||||
{/* 摘要。多 agent 节点展开协作矩阵之后就不再显示——那一行就是成员名单,
|
||||
矩阵里已经逐个列出来了,同一份东西说两遍只是让卡片更高 */}
|
||||
{summary && !showTeam && (
|
||||
<div className="px-2.5 pb-2 pt-1.5 text-[11px] leading-snug text-dim">
|
||||
<div className="line-clamp-2 break-words">{summary}</div>
|
||||
</div>
|
||||
@@ -147,6 +185,16 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 协作矩阵:多 agent 节点的内部编排摊开在卡片里 */}
|
||||
{showTeam && team && (
|
||||
<TeamMatrix
|
||||
roster={roster.filter((a) => a.name).map((a) => ({ name: a.name!, description: a.description }))}
|
||||
team={team}
|
||||
live={status === 'running'}
|
||||
maxRounds={maxRounds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部状态条 */}
|
||||
{(runtime?.durationMs != null || runtime?.error) && (
|
||||
<div className="flex items-center gap-2 border-t px-2.5 py-1">
|
||||
@@ -161,20 +209,27 @@ function NodeCardImpl({ id, data, selected }: NodeProps<FlowNode>) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 出口:有名字的分支把标签显示出来,不用点开就知道哪条是哪条 */}
|
||||
{/* 出口:有名字的分支把标签显示出来,不用点开就知道哪条是哪条。
|
||||
跑过之后,命中的那条亮起来、落空的压暗——六出口的分支节点跑完,
|
||||
不这样的话谁也说不清它到底选了哪条 */}
|
||||
{handles.map((handle, i) => {
|
||||
const top = handles.length === 1 ? '50%' : `${((i + 1) / (handles.length + 1)) * 100}%`
|
||||
const hit = taken ? taken === handle.id : null
|
||||
return (
|
||||
<div key={handle.id}>
|
||||
<Handle
|
||||
id={handle.id}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className={clsx(hit === true && 'handle-taken', hit === false && 'handle-idle')}
|
||||
style={{ top, right: -5, borderColor: handle.color }}
|
||||
/>
|
||||
{handle.label && (
|
||||
<span
|
||||
className="pointer-events-none absolute left-full ml-2 -translate-y-1/2 whitespace-nowrap text-[9.5px]"
|
||||
className={clsx(
|
||||
'pointer-events-none absolute left-full ml-2 -translate-y-1/2 whitespace-nowrap text-[9.5px]',
|
||||
hit === false && 'opacity-40',
|
||||
)}
|
||||
style={{ top, color: handle.color ?? 'var(--text-faint)' }}
|
||||
>
|
||||
{handle.label}
|
||||
|
||||
@@ -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 (
|
||||
<div className="team-matrix">
|
||||
<div className="team-head">
|
||||
<span>
|
||||
第 {(round?.round ?? 0) + 1} 轮
|
||||
{maxRounds ? ` / 上限 ${maxRounds}` : ''}
|
||||
</span>
|
||||
<span className="team-head-rule" />
|
||||
<span>{parallel > 1 ? `${parallel} 人同时进行` : live ? '串行推进' : '已收尾'}</span>
|
||||
</div>
|
||||
|
||||
{rows.map(({ name, active, member, roundNo, hint }) => {
|
||||
const running = member?.status === 'running'
|
||||
const done = member?.status === 'done'
|
||||
return (
|
||||
<div key={name}>
|
||||
<div
|
||||
className={clsx(
|
||||
'team-row',
|
||||
running && 'team-row-running',
|
||||
done && 'team-row-done',
|
||||
active && 'team-row-active',
|
||||
!member && 'team-row-idle',
|
||||
member && !active && 'team-row-past',
|
||||
)}
|
||||
title={[hint, member ? `第 ${roundNo + 1} 轮 · ${formatDuration(member.ms) || '0ms'}` : '']
|
||||
.filter(Boolean).join('\n') || name}
|
||||
>
|
||||
<span className="team-dot" />
|
||||
<span className="team-name">{name}</span>
|
||||
<span className="team-bar">
|
||||
{running ? (
|
||||
<span className="team-bar-running" />
|
||||
) : done && member ? (
|
||||
<span
|
||||
className="team-bar-fill"
|
||||
style={{ width: `${Math.max(8, (member.ms / busiest) * 100)}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="team-ms">
|
||||
{running ? '进行中'
|
||||
: done && member ? formatDuration(member.ms)
|
||||
: '待命'}
|
||||
</span>
|
||||
</div>
|
||||
{running && member?.instruction && (
|
||||
<div className="team-note">{member.instruction}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="team-foot">
|
||||
<span>{team.rounds.length} 轮</span>
|
||||
<span className="flex-1" />
|
||||
{/* 名册就在上面几行里,再数一遍人数是废话。这里只留一个真正要算的数:
|
||||
并行到底省了多少;一次都没并发过就直说串行 */}
|
||||
<span className={clsx(team.savedMs > 0 && 'team-saved')}>
|
||||
{team.savedMs > 0 ? `并行省下 ${formatDuration(team.savedMs)}` : '全程串行'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+349
-7
@@ -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 一个都没用)。
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
/* 光环的角度要能插值,得先声明成一个 <angle> 属性 */
|
||||
@property --fx-angle {
|
||||
syntax: '<angle>';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表单控件统一外观 */
|
||||
|
||||
+97
-89
@@ -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<string, any>
|
||||
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<TeamRound>): 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<string, { start: number; end: number; ms: number }>()
|
||||
|
||||
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 的成员
|
||||
|
||||
@@ -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<StudioState>((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<StudioState>((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')
|
||||
|
||||
+46
-1
@@ -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
|
||||
}
|
||||
|
||||
/** 一处 {{ }} 引用 */
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user