Commit Graph

28 Commits

Author SHA1 Message Date
ThearchyHelios 0997603ccf 画完先自查:条件写错在校验时就拦下,Copilot 交付前按同一套规则查一遍、查出问题先改
开发库里四次运行死在同一句"循环条件写错了:表达式里不允许出现 Set"。条件原文是
`{{ vars.gate }} != "ok"`——把模板写法带进了表达式:`{{ x }}` 在 Python 里是"装着
一个集合的集合"。Copilot 的提示词写着"模板语法在任意字符串里用",模型照做而已;
校验又不看表达式,写错了只有跑到那一步才知道,前面的步骤白跑。

1. 表达式:解析和静态检查收成一个 parse_expression,运行时、校验、Copilot 自查共用,
   不会出现"校验说能跑、跑起来报错"。多套的 {{ }} 意思无歧义,在语法树上剥掉(字符串
   里的花括号不动),按 vars.x 理解并提示。真写错的给能照改的提示:| 过滤器 → len(x)、
   集合 → 列表、不认识的函数列出能用的、= 当 ==、引号里的 {{ }} 永远不成立、推导式、
   切片、私有属性。and / or 短路掉的那半边也查。

2. 校验:按节点类型和模式挑出真正求值的字段(分支条件、while 条件、整形表达式、口径卡
   指标、skip_if)。写错是 error、挡住运行;漏写 vars. 的名字提示会取到空值;while 没写
   条件、整形没填表达式也说出来。foreach 的 condition、模板模式的 expression 不求值,不查。

3. 代码节点:assign_to 拿的是 stdout。「测试 1」修好条件后跑通了、结果却是空的——代码
   最后一行是裸表达式,那是 notebook 的行为。设了 assign_to 却找不到任何输出的 Python
   代码,校验直接拦下,提示 print(json.dumps(结果))。宁可漏判不可误判:子进程、exec、
   附带文件里的 print 都算有输出;整行只有一个 {{ }} 的代码是运行时才拿到的(⑤号示例
   整段就是 {{ vars.code }}),看不见,不猜。库里 20 张工作流无一误拦。

4. Copilot 自查:搭完用同一套校验过一遍,error 级的问题连同图交回模型——只修这些、只
   输出改动,最多两轮。改好了才自动运行;改不好照实交付、列出剩下的问题,不自动开跑。
   没问题的图不多花一次调用;只是多套了 {{ }} 的不返工。提示词改成"模板只在普通文本
   字段里用",并列出表达式字段的对 / 错写法和代码节点必须 print。

5. 前端:自查这一段在 Copilot 的过程里看得见——发现几处问题、交回去改、改好了没有;
   改不好标红并说明没有自动运行。"搭好了,共几步"那一行不会被挤掉。

测试:新增 test_expressions(开发库里真实死过的四条条件、静态提示、按模式挑字段、代码
输出、带 {{ }} 的循环真跑一遍)与 test_copilot_selfcheck(脚本化模型:改好 → 自动运行;
改不好 → 不运行、最多两轮;干净的图一次调用;多套 {{ }} 不返工)。test_continue_run 里
用来制造运行时失败的表达式换成 `2 ** 100000 > 1`——原来那条现在在校验阶段就被拦下了。
check-decode 补了自查的解码断言。
2026-09-25 17:04:39 +08:00
ThearchyHelios 429398a156 前端四处直接伤人的地方:输入法回车、覆盖别的图、停止不停、整站白屏
1. 中文输入法选词的回车被当成发送。问数据页输入框不判组字状态(Composer 判
   了,这里没有):用户每选一个词,半句问题就被送去建图、跑图。同样的漏洞还在
   会话重命名、知识库搜索、{{ }} 补全、⌘Enter 运行上。收成 ui.isComposing,六处
   统一——Safari 结束组字那一下 isComposing 已经是 false、keyCode 还是 229,两个
   都看。

2. 「在画布里打开」后 ⌘S 覆盖上一次打开的工作流。它只调了 setGraph:换掉画布
   上的节点,store 里的 workflow 还是上一张图,保存就把那张图整张覆盖了(旧版本
   还在版本表里,界面上没有入口找回);没打开过图时 ⌘S 则什么都不做。现在建成
   一张新工作流(以这一轮的问题命名)、走它自己的地址,原来的图不受影响。

3. 问数据页的「停止」不停。它只关了自己这头的事件流,从不调 runs.cancel,而后端
   断开订阅不会取消运行——模型和 SQL 其实还在跑、还在计费,界面上却已经显示
   "已取消"。等人工审批的那一轮照旧不动。

4. Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢。三层都补:
   - 后端 _apply_op 拒收未知类型,不进图、不转发,最终校验里说明跳过了哪一步;
   - 前端流式加节点时再拦一道;sourceHandles、属性面板遇到未知类型不再崩;
   - 全站加页面级 ErrorBoundary:导航栏留着,换一页自动恢复;编辑中的图在 store
     里,点「重试」时它还在。

新增 scripts/check-guards.mjs(真浏览器,拦下所有非 GET 请求,不写库不花钱):
组字回车与 keyCode 229 不发送、普通回车照常发送、未知类型不白屏、渲染真出错时
只坏这一页且换页恢复,连跑三次全绿。等待一律等事件,只有"不该发生"的断言用
窗口。后端补了未知类型不进图的用例,413 → 414 passed;tsc 通过,decode / stream
/ canvas-layout 三个检查不回归。
2026-09-25 15:41:30 +08:00
ThearchyHelios 8905fe5e60 多 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 卡片必须在排版里留出位置)。
2026-09-25 12:47:07 +08:00
ThearchyHelios 75be5f485e 画布上的线不再叠在一起:排版按车道留走廊,走线不再交给 smoothstep
原来所有边都交给 React Flow 的 smoothstep,它的竖直段一律落在源和目标的
正中间。同一列的边于是全挤在同一个 x 上:一张"入口扇出三路 → 归并 → 分支
→ 出具/驳回"的图上,跨层的 a3→join 和 m2→join 的横段**完全压在同一条线
上**,长边还直接从"归并B"卡片中间穿过去。排版那边同样糙:同层按 id 字典序
竖排,所以 out_no 排在 out_yes 上面("n" < "y"),分支的 yes/no 是反的;
层内没有任何减少交叉的排序;层间距写死 300。

后端 app/engine/layout.py 重写成分层布局:先剔回边再按最长路径分层(loop
的回边不参与分层,否则循环体会被压到循环节点左边);层内中位数启发式上下扫
六轮取交叉最少的一版,分支/循环的出口再按声明顺序占位重排;y 先均匀堆叠再按
邻居重心迭代松弛,同列节点因此和上下游对齐,长边也走得直;最后按"穿过这条
走廊的连线在竖直方向最多重叠几条"给走廊留宽——区间图上的贪心染色,和前端
用的是同一套,走廊留窄了前端再聪明也只能把线叠起来。

前端 canvas/routing.ts 自己算走线:出边沿右边缘错开、入边沿左边缘错开(入边
按上游纵向位置排序),竖直段按区间染色分配车道——真叠在一起的才各占一条,
首尾相接的(同一个扇出点)共用一条,看起来就是一条总线;跨层的长边和循环的
回边在中间各列的纵向占用里找空档绕过去,不再压过卡片。边换成自定义组件后
React Flow 不再自动加动画类,实测确认 .edge-active 仍然生效。

顺带修掉"排完版看不见图":整张图换过坐标后(自动排版 / Copilot 生成完 /
切图)自动重新取景。坐标变了视角没变,人盯着的是一片空白。

新增 scripts/check-canvas-layout.mjs:把夹具图交给真后端 /api/copilot/layout
排版,再直接 import('/src/canvas/routing.ts') 用页面上跑的那份代码算路径,
按几何断言没有两条线压在同一段上、没有线穿过别人的卡片、两端都接在节点边缘,
另有 1325 条随机坐标的 fuzz 防手拖之后算出 NaN 路径(NaN 的 path 什么都不
画,界面上就是"线没了",一声不吭)。这条检查做过变异验证——把 laneX 改成
永远返回走廊中点,它立刻报 a3→join × m2→join,正是原图上那处重叠。

285 passed(新增 12 条排版不变量)。check-ui 69 项通过,唯一失败项"本地哈希
要标出没有语义能力"已 git stash 到基线复现,与本次无关。
2026-09-24 22:58:54 +08:00
ThearchyHelios c8393c3c6b 移除仓库中的商业数据:删掉 21 个相关用例,其余标识符脱敏
这个仓库是公开的,而历史里带着一份客户 BI 仓的逐字转储:ANALYTICS(原
schema 名)下 53 张视图名、实测行数、字段结构表,外加一句写明取数方是谁
的注脚。还有一处把真人姓名和工作单位写进了检查脚本的断言里。这些都不是
化名,是能直接拿去对库的东西。

按"含敏感词的测试直接删"处理,共 21 个用例:

  test_agent_tool_loop.py   整个文件(2 个,agent 工具循环的真图验收)
  test_conversations.py     9 个(会话 CRUD、历史文本、上下文进 prompt)
  test_datasource.py        5 个(探查失败的实情、候选 schema 提示)
  test_copilot_context.py   3 个(数据源摘要的限定名)
  test_review.py            1 个(干净运行无信号)
  test_tool_args.py         1 个(call_tool 端到端)

辅助函数和产品源码不在"测试"之列,改为脱敏——否则删一个 _seeded 会连带
带走 8 个没被点名的用例。标识符一律换成中性名:shop→shop、
ANALYTICS→ANALYTICS、prod_shop→prod_shop、真人姓名→张三。

markdown 样本里那两条不是改标识符能解决的:行数、字段结构、业务域描述
整段都是客户的。换成同构的合成内容——领域换了,markdown 构造逐项对齐
(标题层级、有序/无序列表、三列表、带 inline code 的单元格、引用块),
渲染器的覆盖面没有变窄。check-stream 里那句"用的是真实输出"跟着改成实话。

286 → 265 passed,差值正好是删掉的 21 个。tsc 干净,decode / stream / ui
三个 check 不回归。

历史里的同一批内容另行用 git filter-repo 处理。
2026-09-20 22:52:28 +08:00
ThearchyHelios 00f8dd78a9 时间线上认得出并行分支了:3 路并行 · 合计 6.6s,实际 3.0s
图上一个节点连出多条边就是 fan-out,LangGraph 在同一个 superstep 里并发执行
——这一直是真并发。但在时间线上它们只是穿插出现的几行,"这三路是同时跑的、
因此省下了 3.6 秒"一个字都没说。上一轮给协作团队做了泳道,这条同样的信息
在 fan-out 这边还是缺的。

现在按时间跨度把同时跑的节点收进一个分组,标题写明几路、meta 写明省了多少:

    ⑂ 3 路并行                    合计 6.6s,实际 3.0s
       ├ 查销售库  2.2s
       ├ 查库存库  3.0s
       └ 查客户库  1.5s

**判据是"同一批出发",不是"跨度重叠"。** 第一版按包含关系排除嵌套,结果漏掉了
先跑完的那一路:三路同时开始但先后结束时,长的那条在时间上确实"包含"短的。
实测 leg0 [0.05,2.21] / leg1 [0.05,3.01] / leg2 [0.05,1.51],被漏掉的正是
最快的 leg2——而漏掉最快那路会让"省下多少"算少。改成看开始时刻是否落在同一批
(250ms 窗口),fan-out 的几路实测相差不到 1ms,子工作流那种真嵌套则是父节点
明显更早开始,自然分得开。

组内要求两两都同批,而不是链式传递:否则 A|B 重叠、B|C 重叠但 A|C 不重叠的
三个节点也会被串成一组,而它们并不是同时在跑。瞬时节点(<30ms)不参与——
它们谁跟谁都"重叠",是噪音不是信息。

**误报比漏报更糟**:它会让人以为省了时间。所以断言里专门守了反向——db、human、
loop_approve 三份顺序执行的样本一个都不能被判成并行。

fixtures 里新增的 fanout 是真跑出来的,不是手写的:三路分别 sleep 1.8 / 2.6 /
1.1 秒,事件原样收进来,所以 6.6s / 3.0s 这两个数和后端量到的是同一份。

验证:check-decode 新增 6 条(含三份顺序样本的反向断言);check-stream 新增
10 条,宽窄两栏都验了不溢出;后端 276 passed 不受影响;tsc 干净。
2026-09-20 21:09:30 +08:00
ThearchyHelios c3f7d6ffcd 协作团队真的能并行了,界面上也看得出来
先说一个和名字不符的事实:这个节点叫「多 Agent 协作」,但在此之前它是一条
严格的串行流水线。调度协议写死成 `next: string`——"一次只能派一个"被编进了
协议本身,模型再想并行也表达不出来。三个专家各花 10 秒,总是 30 秒。

(真并行一直在图级 fan-out:实测 3 个各 sleep 2 秒的节点连在同一个上游,
全部 +0.00s 开始、+2.37s 结束,墙钟 2.6 秒。只是协作团队走不到那条路。)

**协议改成 assignments 数组**,一轮可以派多个互不依赖的专家,asyncio.gather
并发执行,总耗时按最慢的那个算。max_parallel 可配,设成 1 即退回严格串行。

**并发只在任务真的互不依赖时才成立**,这件事的成本必须说清楚:同一轮发出去的
专家拿到的是**同一份进展快照**,谁也看不见谁这一轮干了什么。派错了不报错,
只会让两个人基于一样的旧信息重复劳动,而这在结果里很难看出来。所以判断责任
压在调度者身上,提示词里把反例写出来:

    可以同时派:「查 A 的资料」和「查 B 的资料」
    不能同时派:「查资料」和「根据资料写报告」
    不能同时派:「写初稿」和「校对初稿」

并明确告诉它拿不准就分两轮——代价只是慢一点。

**每个人单独计时**,而不是事后拿整轮墙钟去摊。界面要显示"并行省下多少",
那个数必须是量出来的:按 N×墙钟 推算等于假设每个人都跑满了最慢那条,
而实际上快的那个可能只花了三分之一。测试里专门有个快慢不对称的用例守这条。

**调度日志改成结构化字段**(agents / done / reason / parallel)。以前只有一句
中文消息串,解码层得拿正则去拆「调度 → X(理由)」——后端文案一改就散架,
而这次正是改了文案。老运行的事件没有这些字段,正则那条路留着兜底。

**界面加泳道。** 每个成员一行、每轮一格,条形宽度按耗时归一化,所以一眼看得出
谁和谁是同时干的、谁是这一轮的瓶颈。顶上一行汇总"3 名成员 · 3 轮 · 2 轮并行,
省下 11.8s";点任意一格展开那个人这一轮的任务和回复。全程串行时不装作有并行,
而是说"本次任务互相依赖,逐轮进行"。

泳道排在子步骤**前面**:先给一眼看清的形状,再往下是每一步的内容。反过来的话,
得把十几行步骤读完才知道这个团队是怎么分工的。窄栏(画布右栏 360px)下隐去
"N 人同时"的文字——并行与否看条形本来就一目了然,而缩写成"并2"没人看得懂。

验证:后端 276 passed(新增 test_supervisor.py 7 条,核心那条用**墙钟**判定
并发而不是看事件顺序——顺序只能说明"发出去了",说明不了"同时跑");
check-stream 新增 10 条泳道断言,含"同一轮里慢的那个条更长"和两种栏宽不溢出;
tsc 干净,三个 check 不回归。
2026-09-20 20:57:03 +08:00
ThearchyHelios 7b04162540 Excel/CSV 变成能查的表、PPT 读得进来、跑挂了能接着跑、文档和工具有了地址
四件事,其中 Excel 那条不是"多支持一种格式",是补一个和地基冲突的缺口。

**Excel / CSV → 可以用 SQL 查的表。**

这个项目有条不可回退的决策:叙述层无算术权限,所有算术下沉到 SQL 或口径卡,
叙述只能引用(engine/issuance.py)。表格传进知识库只能被切块检索,数字就成了
模型从片段里"读"出来的——复核层拦得住凭空多出的数字,拦不住"从一堆片段里
读错了一格"。所以表格必须变成表。

不新增数据源类型:kind='sqlite' 早就支持,build_url 拿 database 当文件路径。
落成 .db 文件再建一条 DataSource 指过去,下游一行都不用改——SQL 守卫、结构
探查、db_query__<name> 工具、查询快照进工件库、Copilot 的数据源清单,全都白拿。
建表走 sqlite3 直连而不是 run_query:guard 的 _ALWAYS_DENIED 里有 create,
那是对的(模型不该能建表),而导入是我们自己发起的可信操作。

四个语义是这块的全部分量:

- **类型要推,不能全存 TEXT。** SQLite 是动态类型,但列亲和性决定排序和比较:
  整数列落成 TEXT 的话 '10' < '9',SUM() 在混入空串时静默出错,而没有任何
  地方会报。逐列扫描推断 INTEGER/REAL/TEXT,空单元格存 NULL 不存 "",
  日期存 ISO(字典序恰好等于时间序)。测试里那条 ORDER BY 要是 9<10 而不是
  '10'<'9',守的就是整块的意义。
- **表头取第 1 行,但不猜。** 不做"自动找表头"的启发式:猜错的表现是列名变成
  一行数据,没有任何征兆,直到某天有人发现汇总一直少一行。改成 header_row
  可填,并把推断出的列名和类型当场回显——错了一眼看见。
- **同名重传原地替换,id 和 name 都不变。** 名字会成为工具名(db_query__sales)
  而工具名写进了保存过的图,另起一个 sales_2 等于悄悄让那些图失效。
- **中文原样保留。** 一开始把 sheet 名清洗成了 ASCII,结果「明细」「汇总」都变成
  sheet、sheet_2,模型和用户都认不出哪张是哪张——而 SQLite 的标识符本来就允许
  非 ASCII,那道清洗纯属自找麻烦。现在只替换会断句的空白和标点。

实测:两 sheet 的中文 xlsx → 两张表,金额列 INTEGER,跨表 JOIN + SUM 跑通,
Copilot 的工具清单里带着表名。CSV 按 utf-8-sig / utf-8 / gb18030 依次试——
GBK 的 CSV 是 Excel 另存为的默认行为,按 UTF-8 读会得到乱码列名,而乱码不报错。

知识库那边顺手改了指路:传 .xlsx/.csv 进去不再说"不是 UTF-8 文本",
而是告诉你该传到数据源那边、以及为什么。

**PPT。** 文本框和表格分开取(表格的 has_text_frame 是 False),幻灯片之间
留空行当段落边界。.ppt/.doc/.xls 这些老二进制格式认出来再拒,说清楚"另存为
新格式"——以前它们会掉进"不是 UTF-8 文本"那个兜底,对着一个 .ppt 说这话,
用户根本不知道该怎么办。

**问数据页的「接着跑」。** 挂了那一轮不该只剩一行红字:断点一直在 checkpoint
里躺着。按钮挂在 approvalsFor 那个每轮插槽里(本来就托着「看执行过程」和
「跑一下」)。不带图——问数据页改不了图,这里就是"用原来的配置重试失败的
那一步",配置本身错了要到画布上改。实测:续的是同一次运行(8→13 条事件),
只跑了 lp 往后,轨迹里有 run.resumed。

**文档 / 工具的地址。** /knowledge/kb/:docId 是新页面——文档原来连详情视图都
没有,而切块这层做了不少事(按行边界留重叠、跨块续表头),结果一处都看不见。
现在逐段列出来带字数和 token 数,还标出"这份文档里混着两个模型建的向量"
(换模型只重建了一部分时真会发生,而那会让这部分内容悄悄退回关键词检索)。
新增 GET /kb/documents/{id}。工具那边 tab key 从 tools 改成 library,
否则地址是 /tools/tools/<id>。

验证:后端 265 passed(新增 test_tabular.py 16 条);tsc 干净;三个 check 全过,
check-ui 补了文档/工具两节;四块都在真浏览器/真后端上端到端跑过。
开发库里造的演示数据源、会话和运行已清干净。
2026-09-20 16:19:07 +08:00
ThearchyHelios 542bcb65bb 一次对话有自己的地址了:/chat/<chatID>,运行、画布、各页标签同理
在此之前 react-router 只管到页面一级,「页面内部在看哪个」全在 store 和
localStorage 里,全站一处 useParams / useSearchParams / useNavigate 都没有。
后果:

- 一次对话没有地址。想把「这个结论是怎么查出来的」发给同事,只能发截图。
- 刷新回到上次那个对话靠 localStorage,换台机器、换个浏览器就丢。
- 浏览器前进/后退在页面内部彻底失效——切了五个对话,后退一下直接离开问数据页。
- /settings 永远落在「模型接入」,指不到数据源那一屏。

    /chat/:conversationId     /studio/:workflowId     /runs/:runId
    /knowledge/:tab           /tools/:tab             /settings/:tab

每页写两条显式路由(带 id / 不带 id)而不是可选参数 `:id?`:「没带 id」是真要
单独处理的一种情况(落到上次那个、或列表第一个),藏在一个问号里下次就没人
记得它存在了。

**URL 驱动 store,不是反过来。** 两边都能写就一定会打架——点一下列表,store
改了、地址栏没动,刷新又跳回旧的。所以 useConversations.currentId 降级成 URL
的派生缓存,唯一写入口是 ChatPage 的那个同步 effect;列表里的切换、新建、删除
一律改成导航。conversations.load() 不再自作主张决定「当前看哪个」,
remove() 改成返回「接下来该去哪」交给调用方。

localStorage 留着,但它现在只回答另一个问题:**打开不带 id 的 /chat 该去哪**。

**指向不存在的东西**要优雅:等列表加载完再判(在那之前"找不到"是假的,照着它
跳会让每次深链接进入都先闪一下错误提示),落到一个真实的,并说明为什么换了
地方。RunsPage 特别一点——列表只取前 100 条,而一条老运行的链接必须也能打开,
所以详情按 id 直接取,不在列表里找。

**画布多两道闸。** 选择器里本来就有"未保存改动"的确认,但那道闸在它的 onClick
里,而前进/后退会绕过选择器直接换图——改了一半的画布就这么没了。URL→画布那个
effect 里补一道,取消就把地址退回去。

过程中实测抓到三个问题,都修了:
- 同一个坏地址弹三次"那张工作流不在了":effect 在请求回来之前又跑了两遍,
  加了在途去重(chat 那边同样的毛病弹两次)。
- 「在画布里打开」现在直接把人送去 /studio,而那是一张还没存、没有 id 的草稿图。
  不带 id 就落到第一张图的逻辑会拿第一张把它盖掉,还会先弹一句莫名其妙的
  "有未保存的改动"——加了 nodeCount 判断让开。这个坑其实原来就在,只是以前
  要手动点导航才踩得到。
- 删掉当前打开的那张图之后地址还指着它,刷新会撞上"不在了"。

顺带:三个 tab 页共用一个 useTabRoute(放在 Tabs 旁边,tab 和它的地址是同一件
事,拆两处迟早各改各的),认不出的 tab 名回落并把地址一起纠正过来。
「在画布里打开」不再是 toast 让用户自己去点导航,直接送过去。

验证:tsc 干净;后端 249 passed(没碰后端,跑一遍确认没带坏);
check-decode / check-stream 不回归;check-ui 新增「URL 指得到」一节 19 条,
真浏览器里验了深链接、切换改地址、刷新、后退、坏地址、tab 直达、
草稿图不被盖掉、越过列表上限的老运行。
2026-09-20 14:09:40 +08:00
ThearchyHelios cc608f56ec 跑完先过一道复核再交给你:异常说破、数字不许现编、出错自动改一次
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里,
14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。
最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」——
一句内部管道文案冒充结论。

出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,
还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output,
走不到那条路。所以补一层轻的。

**干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan),
没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用
「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。

**分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。
这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响,
为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。
原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了,
算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。

**复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据,
写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的
数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。

**规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit
时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。
反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。

**重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到
「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了
什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被
排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。
上限一次。

**答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去——
这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻
(防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方,
broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。

复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个
已知不可信的结论当事实继续往下推。

验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净;
check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。
两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方;
重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。

复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
2026-09-20 11:18:53 +08:00
ThearchyHelios 11780ed0b4 设置页能接本机的向量模型了,不用改代码
上一版只有三个写死的选项:本地哈希 / OpenAI 3-small / 3-large。想用本机起的
LM Studio、Ollama、vLLM,只能去代码里调 configure——等于没有。

现在可以填任意 base_url。配套加了一个探测接口(GET /kb/embedding/probe),
按地址列出那台服务上有哪些模型,从列表里选。模型名手填太容易错——LM Studio
里叫 text-embedding-qwen3-embedding-4b,不是 Qwen3-Embedding-4B,而填错的
表现是切换时 404,人还以为是服务没起。

base_url 跟着落库。不存的话重启后会悄悄退回本地哈希,而库里的向量是上一个
模型建的,检索整体退回关键词——用户只看到"重启之后搜得不准了"。

几处说给用户听的:
- 当前用的是什么、几维,直接显示在最上面
- 本地哈希旁边标"没有语义能力"——它是默认值,而这个缺陷不说没人会发现
- 切换成功的提示里带上新维度和"多少条存量向量需要重建"。换模型后维度多半
  变了,存量全部作废,这件事要当场说,不能等人发现搜不准了再去猜

check-ui 加了一节,真浏览器里验过:展开、填地址、探测到本机的
text-embedding-qwen3-embedding-4b、出现「用它」。
2026-09-20 09:34:55 +08:00
ThearchyHelios a027261701 Copilot 会自己记该记的了;顺带让写记忆这件事看得见
以前只有用户明说「记住」才会写:17 轮有图的对话里只有 4 轮带记忆节点,全在
那个用户主动谈记忆的会话里。于是他说过一次「我叫张三」,换个话题再问就
谁也不知道了。

现在 answer 路径多一步判断:这句话里有没有值得长期记住的东西,有就在主链路
前面加一个 memory(write) 节点。边界比指令本身要紧,所以写死了四类不该记的:
这一轮的问题本身、查出来的数(会过期且能重查)、一次性指令、以及**用户没说过
而你推测出来的**。另外两条是从召回的实际情况倒推的——content 要脱离上下文也
成立(召回时没有这轮对话,「他说他叫张三」读起来不知道「他」是谁),以及
拿不准就不记:记错了污染以后每一次对话,漏记一条用户再说一遍就是了。

**动手前先修了一个前提**:写记忆在界面上是隐形的。memory 节点发的是 info 级
日志,而解码层明确丢弃所有 info(decode.ts),也就是说自动记忆 = 系统背着
用户往一个跨会话、长期有效的地方存关于他的信息,而他看不见。

新增 memory.end 事件,解码层按动作分开渲染:写入把**记了什么**原样显示
(只报「写入 1 条」等于没说,用户没法判断它记的是不是他想让它记的)、召回说
「想起 N 条」、清空标成警告。

真模型验过(deepseek-v4-pro):

「我平时叫小蒋,以后报表金额用万元」
  → content='用户平时叫小蒋,他要求以后报表里的金额统一用万元作单位'
    scope 未写、跑完落库、事件里带着内容
  → 换个说法召回得到:「该怎么称呼我」「金额用什么单位」都命中

三条对照确认不滥记:
  shop 里有几家店        → 没记
  重来                    → 走 reply,连图都没建
  把上一轮的结果按月份排一下  → 没记
2026-09-19 22:41:27 +08:00
ThearchyHelios df935599f2 知识库:换 embedding 模型不再 500,检索能回指了
三件事,都是"这三个模块一条测试都没有"的后果。

**换 embedder 会让检索整体 500。** 库里是 512 维的本地向量、查询是 1536 维的
远端向量时,cosine 直接 `shapes (1536,) and (512,) not aligned`。from_blob 有
dim 参数,但 kb.py 和 memory/store.py 调用时都没传;而且存量向量属于哪个模型
代码里没有任何地方记着,所以也无从降级。

Chunk / MemoryItem 各加 embed_model + embed_dim(走现成的 _COLUMN_MIGRATIONS,
存量回填空值,正好表示"不知道是谁建的")。检索遇到对不上的向量整体退回纯
关键词并说破,不是把它们当 0 分静默沉底——那样用户看到的是结果莫名其妙变差,
而不是"这里有一批索引该重建了"。配套 kb.reindex + POST /api/kb/reindex。

长期记忆那份一起修了。判重那条更危险:维度碰巧相同但模型不同时 cosine 算出来
是随机数,会把不相关的旧记忆认成"重复",然后把新内容丢掉。

**默认 embedder 没有语义能力,而开关藏在一个环境变量里。** LocalEmbedder 是
特征哈希 + TF,和 BM25 吃同一批词;alpha 默认 0.5 等于给了它一半权重,同义
改写一条都召不回。开启真模型的唯一开关是 AGENTLAB_USE_OPENAI_EMBEDDINGS——
没界面、没人会发现。改成设置项(照 COPILOT_SETTING_KEY 的路子),知识库页顶部
能选能看能重建。

配置失败必须响:第一版写成静默退回本地,但选了 OpenAI 却拿到哈希向量的人,
在搜不出同义表达时完全无从怀疑到这里。现在抛 EmbedderUnavailable,API 400
原样交回"检查 OPENAI_API_KEY 是否配了",且不会把配置改掉。

**检索结果不进工件库。** db_query、工具节点、agent 工具调用都落 put_json 快照,
run_retrieve 什么都不落——于是结论只要来自知识库,"这个数是哪来的"就断在这儿,
而数字回指正是这个项目的签名能力。现在落 kind="retrieval_snapshot",并发一条
retrieve.end 带 artifact id。解码层补了正式映射(原来会掉进"未知事件"兜底,
画成一坨原始 JSON)。

新增 test_kb.py 11 条,补上这三个模块此前的零覆盖,包括 embeddings.py 里
注释记着、但一直没有测试守的那个归一化边界(单条候选被 min_score 误杀)。

端到端在真实服务上验过:撞上存量向量返回 200 + 降级说明(以前 500)、
重建后 stale 归零、配不起来的远端模型 400 且配置不变。
2026-09-19 09:27:07 +08:00
ThearchyHelios cd7ced7673 不是每句话都值得跑一遍库:模型自己选要不要执行
以前必执行——final 一到就无条件 launch。库里那条会话 7 轮全建了图、全跑了
一遍 153 张表的库,其中:

- 「重试」「继续找找」这种对过程说的话,各自重建了一整张图去查
- 一句明确要「设计一个工作流,使得每次执行的时候都给出管理员账户信息」的,
  建完也直接跑了——用户要的是那张图,不是这一次的结果

流协议给模型两个新出口,三条路径二选一:

- {"op":"reply","text":"…"}  不建图不跑。问的是前几轮已经查出来的东西
  (口径、怎么算的),或者是对过程说的话,或者需要先反问清楚
- done 里 run:false          建图但停住,界面上给「跑一下」
- done 里 run:true(默认)   不变

reply 一到服务端就收流:让它走到后面那套排版校验上,空图会撞出"图校验未
通过:图是空的,先拖一个节点进来"——上一个提交刚治过同类的坑。

放开「直接回答」等于绕开了这条路径上最硬的一条约定(涉及数据必须真查),
所以两头都上:prompt 里写死 reply 只能复述或解释前面几轮已有的结果、不得
新断言任何数据事实,凡是要动到库才知道的哪怕觉得上一轮见过也重新查;界面
上那一轮的结论**前面**挂一条声明,说清楚这一条没有查库。check-stream 里有
一条断言专门盯它排在结论前面——排在后面等于先让人读完再说来源不可靠。

需要留意:这依赖模型照约定表态,不是硬规则。判断跑偏的话收紧那几句约定。
2026-09-18 22:30:45 +08:00
ThearchyHelios 152264b29c 长报告不再被咬掉一口:折叠只在行边界切
用户看到的是一份读起来完整、末尾却挂着半行原始 markdown 的报告:

    userID	用户名	邮箱	昵称	providerID	商家名称	联系人邮箱
    | 1 | ThearchyHelios | user@example.com | … | Test Marchant By Shop

表头是制表符(已渲染成表格),数据行是竖线(还是原始文本)——折叠是
text.slice(0, 3000) 按字符硬切的,切点落进表格中间,前面几行渲染成表格、
最后半行没能成行。报告本身是完整的,只是被折叠了,但界面上看不出来。

改成只在行边界切;切点落进表格时整块表格都不要——宁可少显示一段,也不给
一张半截的表。另外原来只有一个"展开全部"按钮,看上去像报告就这么长,
现在先说"…后面还有,这里先折叠了"。

顺带治了落库那一处:answerText 在 4000 字处无声截断,刷新页面后那份就永久
少一截,断点同样常常落在表格中间。上限提到 20000,真切到了会写明已截断、
完整内容见运行记录。喂给下一轮的那份另有更狠的截断,两回事。

check-stream 多一节,盯的就是"折叠后不许留下没渲染的原始表格行"。
把切法改回硬切,它会立刻抓到残留的 `| 24 | user_23 | …`。
2026-09-18 22:29:24 +08:00
ThearchyHelios dcadc0c48b 对话有上下文了,左边也能选会话
三条 run(d983c4e6 / 14103db5 / 08c3de1c)在用户看来是一次对话,系统里却
毫无关联。查下来不是哪里传漏了,是整层缺失:

- 每轮 base_graph=None 从零建图,Copilot 不知道上一轮问过什么
- 每轮 thread_id = run.id,三条独立的 checkpoint 线程
- 前端 turns 只活在 zustand 里,刷新页面就全没了——那三轮当时在界面上
  已经找不回来,只剩 runs 表里三条互不相干的记录
- 后端 13 张表里没有会话这个概念

加两张表(conversations / conversation_turns)。失败的轮次也记:只记成功的,
历史就成了一份美化过的记录,而人回来最想搞清楚的恰恰是上次卡在哪。

Copilot 的上下文带三样:前几轮问答、上一轮那张图(走 _slim 去掉坐标噪音)。
只给问答文字不够——"再按月份拆一下"会让模型重新设计一张图,实测经常接到另
一张表上,答案对不上前一轮。运行时另外注入 {{ input.history }},管的是指代:
用户问"它们平均多少",图里的 agent 得知道"它们"是上一轮那 12 家店。

左侧会话栏只在问数据页出现,可折叠、可改名删除,当前会话存 localStorage。
轮次按会话分桶缓存——切走再切回来要看得到原来的步骤,回源只拿得到问答。
历史轮次的执行过程不预加载,点「看执行过程」才取事件、走现成的 decodeRun。
画布右栏的 Copilot 依附工作流各有一条会话,连说两次"再加一个节点"能接上了
(base_graph 带的是图的结果,带不出"你刚才要我干什么"),但不进左侧列表。

过程中修掉的两个自己引入的坑:

- 第一版在列表为空时自动建一条会话。create 是异步的、StrictMode 把 effect
  跑两遍,于是每次进页面都留下两三条"新对话"。改成用户开口那一刻按需建
- 加了"在上一轮的图上改"这条指令之后,模型照改图协议只发 update_node,
  而问数据页这条路径的累加器从空图起步,操作全部落空,用户收到的是
  "图校验未通过:图是空的,先拖一个节点进来"(会话 f5db9809 的第二轮,
  而它的 explanation 证明上下文其实完全读懂了)。指令改成沿用 id 和 config
  但完整重发,另加一道兜底:空图且上一轮有图时把操作重放到那张图上

check-ui 多一节「会话」:切换、刷新还在、逛一圈不多出空会话。
2026-09-18 17:58:04 +08:00
ThearchyHelios 105120f22f 成果区渲染 Markdown:「1+1 等于 **2**」不再原样带着星号
模型几乎总是用 Markdown 组织回答,而成果区是 whitespace-pre-wrap 的纯文本,
于是它的结构全丢了:标题、列表、表格、粗体一律变成满屏符号。

**手写而不是装库。** 依赖只有七个、全是必需的,react-markdown 那一串
(unified / mdast / micromark)比这个文件大一个数量级。而要渲染的东西是
有限的——统计了库里 252 段真实输出,出现过的构造只有:

    有序列表 40% · 无序列表 15% · 粗体 13% · 标题 3% · 行内代码 3%
    表格 3% · 分隔线 2% · 代码块 1% · 引用 1% · 斜体 1% · 链接 0.8%

所以只做这些,**不声称支持完整 CommonMark**。认不出的语法原样当文本显示
——那正是现在的行为,下限不会比今天差。包大小 +37KB(gzip +12KB)。

安全:全程构造 React 元素,一处 dangerouslySetInnerHTML 都没有。模型输出
是不可信内容,走 innerHTML 等于给自己开个 XSS 口子。链接只放行
http/https/mailto,`javascript:` 退回纯文本。

一个只有拿真实输出才会撞上的 bug:模型很爱写 **`table_name`**(粗体套
代码),而粗体在扫描顺序上先命中,把那对反引号当普通文本吃掉了——渲染出来
是加粗的 `v_demo_table` 带着反引号。强调内部要再解析一层(深度卡在
三层,每层文本严格更短)。另外把模块级正则的 g 标志去掉了:lastIndex 有
状态,递归调用时会从上一层留下的位置接着扫。

审批卡里的待审草稿也走同一个渲染——要人家判断"这条能不能发",却让他对着
一堆星号看,是在给审批添难度。

check-stream 补 12 条,测例是从库里导出的真实输出 + 一段构造的恶意输入
(真实输出不会自带攻击,但渲染层要么天生免疫,要么就是个口子):残留标记、
粗体/代码/列表/表格、粗体套代码、javascript: 链接不可点、正常链接可点、
原始 HTML 不变成真标签且当文本显示、注入脚本没执行。
2026-09-18 12:51:55 +08:00
ThearchyHelios 1444c774cf 模板字段加 {{ }} 补全:从源头消灭那类静默失败
模板取不到值时渲染成空字符串、不报错,所以拼错一个变量名在运行时完全静默。
上一个 commit 是事后校验(标红+建议),这个是事前:打 {{ 就弹出这张图里
实际有的变量,每条带说明和产出它的节点。

**只挂在真正过模板渲染的字段上。** 这一条是整件事最容易做错的地方:
这套里有五个字段长得像但走的是表达式求值——skip_if、loop.condition、
cases[].condition、transform.expression、metrics[].expression——那里要写
`vars.count == 0` 这样的裸路径,不带大括号。在那些字段上挂 {{ 触发器等于
手把手教用户写错。同理 supervisor 的 agents[].system 长得和 llm.system
一模一样,但后端是 cfg.get("system") 直接取值、根本不渲染,那里弹补全会
是个谎;它走的是 AgentList 里的裸 textarea,天然不受影响。

几个不写就会坏的细节:

- 这是完全受控组件。插入后 onChange 会一路走到 store 再把 value 灌回来,
  DOM 光标会跳到末尾——必须等那一帧过去再自己 setSelectionRange 放回去。
  测试里专门验了"插入后继续打字,字落在后面而不是中间"。
- 候选框用文档流里的块,不是绝对定位浮层:属性面板是 overflow-y-auto、
  外层 InspectorSheet 是 overflow-hidden,浮层到边缘直接被切掉。
- 用 onMouseDown 不是 onClick——onClick 在 blur 之后才触发,那时候弹层
  已经关了。
- Escape 要 stopPropagation:外面 InspectorSheet 也在听 Escape,不拦的话
  按一下会连整个属性面板一起关掉。
- 光标前已经打了 | 或 [ 就不再弹——那时候是在写过滤器或下标,不是在选变量。

check-ui 补一组:变量表列得出来、抽屉挤压后画布没被压没(React Flow 容器
高度归零会直接报 error 004)、打 {{ 弹候选、插入成完整写法、光标位置正确。
2026-09-18 10:55:56 +08:00
ThearchyHelios f43f56abb7 把 Copilot 的思考显示出来:模型一直在想,只是被丢掉了
用户问能不能看到"正在理解需求、规划步骤"里面发生了什么。查下来是这样:
模型确实在想,reasoning 花了实打实的 token,但一路上被丢了三次。

1. langchain 的 ChatOpenAI **故意**不保留第三方的 reasoning_content——它的
   文档原话是 "Non-standard response fields added by third-party providers
   are not extracted or preserved",建议换 ChatDeepSeek 之类的专用包。可
   "用哪个供应商"在这个产品里是用户在设置页配的(DeepSeek / Qianfan /
   vLLM / OpenRouter 都可能),为每种装一个专用包不现实。在 factory 里
   子类化捞回 additional_kwargs,只加字段,不动 langchain 对正文的处理。

2. thinking_text 只认 Anthropic 的 thinking 块。补上兼容端点那一种。

3. 前端把一段思考的标题固定成开头 60 个字。思考是流式的,开头往往是
   "用户想要一个……"这类复述,之后几十秒里它一直在推进——固定显示开头
   等于把一段活的叙述冻在起点上,看着就像卡住了。改成:还在想的时候显示
   最新一句,想完了换回开头那句(那时它是一条历史记录)。

实测一次真实生成,60 秒里侧栏逐句变化:

    3s  可用数据源 bi
    9s  人工节点 approve 模式,审核
   18s  我们可以用 db_query__sales 执行 SELECT COUNT(*) FROM orders 等
   36s  更合理:rejected -> query_agent(重新
   45s  对于 bi 有 53 个对象,靠
   57s  (plan 到达,思考收尾,换回开头那句)

顺带运行期的 LLM 节点也跟着有了思考——走的是同一个 thinking_text,之前
同样只有 Anthropic 系才有。已验证流式合并后思考连成完整一段,不是只剩
最后一片。

tests/test_thinking.py 守两种形态、合并、以及"不产思考的模型不硬凑"。
check-decode 补 5 条守"还在想显示最新一句、想完了换开头"。
2026-09-18 09:55:42 +08:00
ThearchyHelios aa18904af0 运行和 Copilot 分成两层;顺带修了一个会丢事件的时序缺陷
用户:「点击右上角运行后,会跳到流程;然后下方就又有 Copilot 对话框,把
结果和 Copilot 又放在一块儿了」。说得对,而且我上一版的理由是错的。

我当时把建图和跑图排进同一条时间轴,说它们是"一件事的两半"。它们在时间上
先后发生,但不是同一种东西——最直接的证据在我自己的代码里:运行那一轮的
question 只能填 undefined。一个没有提问的"对话轮次",本来就不是对话。

更实际的问题是那个输入框:它贴在运行结果下面,暗示你可以回复这次运行。
但你不能——在那儿打字改的是画布上的图。这是个假的可供性。两者的生命周期
也对不上:Copilot 的轮次会累积,运行永远只有最新一次。

现在右栏是一个常驻的家(Copilot 对话)加两层临时盖上来的东西(节点属性、
运行过程)。运行那一层没有输入框,有自己的头部和停止按钮;对话那一层底部
留一条运行进度,点一下过去。

---

顺着这条线查下去,发现一个更严重的问题:**后端在丢事件**。

浏览器侧连跑 5 次,2 次丢了中间一条。抓 WebSocket 原始帧才看清:所有帧的
replay 都是 false——历史读回来是空的,全部走实时;丢的是 run.started,
它在客户端订阅之前就广播了,而那时还没落库,所以历史里也没有。

根因是 _emit 的顺序:先广播、后落库。订阅者的完整性靠"先订阅 → 读历史 →
接实时"拼起来,它需要这条保证——提交早于我这次 SELECT 的,在历史里;提交
晚于我这次 SELECT 的,广播也晚于我订阅,在实时流里。后半句只有在"提交先于
广播"时才成立。交换顺序后,6 次 WebSocket 探测 + 5 次浏览器运行全部无缺口。

(上一轮我修过这个文件的另一个窗口——先读历史后订阅。那个修复是对的,但
只堵住了两步之间的缝,堵不住订阅建立之前的那一段。)

tests/test_emit_order.py 守这条不变式:广播的那一刻,这条事件必须已经能从
另一个 session 查到。去掉修复后它确实失败。

---

还修了三处:

- 停在人工介入时,"在不在等人"原先取自审批列表,而那是 4 秒轮询一次的;
  中断后会有几秒钟界面说"这次运行已结束",而它正等着你点通过。改成从事件
  推导(isAwaitingHuman),即时且和显示的内容一致。
- 新增 tests/conftest.py 把整个测试会话关进临时目录。test_emit_order 第一版
  直接用 SessionLocal 往开发库里写,几行假运行排到了运行列表最前面。已清掉
  那 12 行。这该是全局保障,不是只修一个文件。
- 开发期把 store 挂到 window.__studio,好让 playwright 直接读状态——"事件
  收全了没有"在界面上看不出来,只能问 store。已确认不进生产包。
2026-09-18 09:39:24 +08:00
ThearchyHelios 3de7567377 右栏重构:助手常驻,属性改为盖上去的一层
两个问题都源于同一件事——一栏 360px 被要求同时干两件活。

**没地方引导写问题。** Copilot 输入框是个 38px 高的框,挤在一条长长的流
底下,比它上面任何一块都不起眼;例句被截断成 16 个字的 chip,示范"该说到
什么颗粒度"这个作用就没了,只剩装饰。改成一个组件两种体量:没说过话时它
就是这一栏的主体(竖直居中、三行高、自动聚焦、完整句子的例句),并且告诉
用户它能够到什么(N 个数据源 / M 个工具)——不然"查销售库"这种话有没有意义
只能靠猜。有了对话之后它退到底部两行。不做成两个组件:那就是两个实例,
切换时会卸载其中一个,草稿、光标、输入法候选词全没。

**Copilot 和属性挤在一起。** 它们原是 [助手|属性] 两个 tab,而 tab 是互斥的
——跑图跑到一半点开节点看配置,整条执行过程就从眼前消失,切回来滚动位置和
输入草稿也没了(切 tab 会卸载组件)。改成属性滑出来盖在助手上:助手一直在
下面活着,属性是临时造访者。头部给一个明确的"‹ 助手",Esc 关得掉;盖住
期间底部留一条执行进度,点一下回去——不能因为换了实现就把原来最难受的
那个场景原样留着。

运行控件搬去工具栏。它原先和 Copilot 输入框一起常驻助手栏,两个都是"主要
动作"互相压着;而它们是两种意图(跑这张图 / 改这张图)。运行是对整张图的
动作,和保存、发布同类。有输入字段就弹表单,没有就一键直接跑。

顺带修了两处状态说反的地方:
- 停在人工介入时,承载的节点还在转蓝圈。转圈说的是"它在忙,你等着",而
  实际正相反——它在等你。改成待办色 + 手势图标。
- "继续执行"的去重原先靠"界面上还有没有 waiting 步骤",上一条改动多了一处
  产生 waiting 的地方就失效了。改用事件相邻性判断——那是这两条事件的固有
  性质,不会因为别处改了展示而变。

动效:面板滑入、例句依次入场、正在出字的那一步有一道光扫过(比转圈多说
一件事:它在产出,不只是在等)、空态标记轻微呼吸。全部 240ms 以内,缓动
统一 cubic-bezier(.32,.72,0,1)。另外补上了此前完全没有的
prefers-reduced-motion——这套界面里动的东西不少(边在流动、节点在脉冲、
面板在滑),系统里关了还照播,对前庭敏感的人是实打实的难受。

验证:check-ui 重写了助手栏那一节,核心断言是"选中节点再回来,输入的字
还在"——那正是 tab 方案做不到的。新增一条用 emulateMedia 验证关掉动效后
动画确实停了。真跑了一次带人工审批的图,期间打开属性面板确认进度条还在。
四套检查 + 82 个后端测试全过,生产构建通过。
2026-09-18 00:05:22 +08:00
ThearchyHelios 3ff6840b99 多 agent 节点不再永远转圈,调度决策也说出来了
运行 #6038f5:supervisor 节点跑完 31.7s 之后,researcher 那一行还在转圈。

agent.step.start / agent.step.end 是这套事件里**唯一**一对没做配对的
(node、llm、tool、sandbox、human 都配了)。start 那条建出来就是 running,
end 另起一行,于是没人去关它。这正是 decode.ts 自己文档里的第 2 条原则
——成对事件合并成一行——我写了却没用在 agent 上。

配对键用 (node_id, agent, round):光用 agent 名不够,supervisor 会在多轮里
反复派给同一个 agent,第二轮的 end 会错配到第一轮的 start 上。

顺带把 supervisor 的调度决策捞出来。后端把它标成 info 日志,而 info 是整类
丢弃的,结果"为什么派给 researcher"、"为什么只跑一轮就结束"在界面上完全
没有交代——那个节点就是一个跑了 31 秒的黑盒。带 round 字段的 info 才是调度
决策,和普通排查日志区分得开。FINISH 译成"结束协作"而不是"交给 FINISH",
后者会让人以为还有个叫 FINISH 的成员。

真实运行已收进 fixtures,check-decode 加 9 条(含"普通 info 日志仍然不进
主流程"和"warn 日志照常显示"这两条反向约束)。
2026-09-17 19:14:06 +08:00
ThearchyHelios f0fd63f0b9 UI 重构:Copilot 进侧栏,三个页面共用一条助手流
用户的原话是「copilot(包括问答)可以是侧边栏的效果」「显示他的简要思考节点,
使得用户知道他大致在想什么,以及用了哪些工具」。

画布右栏从「运行 / 属性 → 时间线 / 成果 / 事件」(两层嵌套 5 个视图)变成
「助手 / 属性」一层。助手栏里是一条流:你让它做什么、它做了什么、结果是什么。
Copilot 建图和跑图在协议上完全无关(SSE 操作流 vs 事件 WebSocket,不共享
seq 和 node_id),但对用户是一件事的两半,decode.ts 把两种流译成同一种 Step
之后才能排进同一条时间轴。

Copilot 不再是弹窗。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后
需求文本就没了,想微调只能重打;而画布左下角那条浮条(z-30)和 Toast
(z-100)、Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。
两个都删了,输入框就在栏里,说完一句接着说下一句。右栏没删——它是发起运行
和处理审批的唯一入口,RunLauncher 原样保留,只是跑起来会自动收起给执行过程
让位。tab 用 hidden 切换而不是卸载,不然点一下「属性」看个节点再切回来,
输入框草稿和展开状态全丢。

问数据页和运行页换成同一个 AssistantStream + 同一个 decode.ts。RunPanel 里
那套平行的 EVENT_META + describe() 删了:留着两套翻译,同一次运行在不同页面
会读出不同的故事,而用户没法判断哪个是真的。同理合并了两个耗时格式化函数
(画布节点显示"0ms",右边同一步什么都不显示,两块还并排放着)。运行页列表
加了内容摘要——原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样,
要分清哪条是哪条只能挨个点开。

跑真实运行时发现两个缺陷:

1. 后端丢事件(app/core/bus.py)。WebSocket 先读历史、再订阅,两步之间图
   正好跑出来的事件既不在历史里也不在实时流里,永远丢了。而 subscribe() 是
   异步生成器,真正注册还要等到第一次迭代,窗口比看上去更大。实测一次跑图
   开头的 node.finished/node.started 消失,界面上表现为某个节点永远在转圈、
   它的子步骤跑到了顶层。改成先 attach 占位、再读历史,重叠部分按 seq 去重。
   tests/test_bus_attach.py 里有一条刻画旧行为的测试,说明这个窗口确实存在。

2. 循环里的多轮审批被算错。我在 S1 按内容去重,但三轮审批的 title 一模一样,
   和 LangGraph 的重放无法区分——结果第 2、3 次真实审批被当成重放吞掉,而
   三次"你驳回了/你放行了"各自成行。改成开/闭配对:开着就是同一次,闭了才是
   新一次;决定折进那条审批行而不是另起一行。真实运行(驳回 → 改写 → 再驳回
   → 改写 → 放行)已收进 fixtures。

验证:三层各守一件事,共 76 条断言全过。check-decode 守翻译(含新的多轮审批
和截断结果集),check-stream 守渲染,新增 check-ui 守"接起来还对不对"——真
浏览器真后端,包括「切走再切回来草稿还在」「唯一的运行入口还在」「原始事件
切得到也切得回」。另外真跑了一次带三轮审批的图和两次 Copilot 生成(60s,
deepseek-v4-pro),截图逐帧看过。后端 78 个测试全过。
2026-09-17 18:29:16 +08:00
ThearchyHelios c1c13fad41 助手流组件:一份 Step,宽屏和 360px 窄栏共用
AssistantStream 是纯展示的,不连 store。这样画布右栏和问数据页能用同一个
组件,而不是"一个组件三种停靠模式"——后者要求同一个实例出现在三个 DOM
父节点里,React 里除非 portal 否则不成立,硬做就变成跨路由 remount,
输入草稿和展开状态全丢。差别只在 dense 一个开关。

渲染真实事件时发现的几个问题,顺手一起修了:

- 取数运行的最终成果是一个**被截断的双重转义 JSON**(后端按字符数硬切,
  切点落在 JSON 中间是常态),严格 JSON.parse 必然失败,于是这个产品最
  典型的一次运行,成果区呈现为满屏 \"attribute01\", \"attribute02\"。
  parseQueryResult 改为解析失败时截到最后一条完整记录再解析——78 列 1 行
  的表格,比一屏转义文本有用得多。并区分"查询撞了行数上限"和"预览被切断":
  前者该去调查询,后者该去看完整工件,混成一句"已截断"等于没说。

- 审批恢复过的运行有"开始执行"+"继续执行"两条生命周期,run.finished 只
  收了第一条(find 而非 forEach),"继续执行"永远转圈——明明已经跑完。

- 处理完的审批保留着 warn 级别,一直是橙色,看着像还有事没办。

- 一屏 0ms:那些纯赋值节点确实不花时间,把没信息量的数字摆出来反而把真正
  慢的那一步淹了。低于 10ms 不显示,同时空耗时给 undefined 而不是空串。

- output 里 {"result": ""} 很常见(图跑通但出口没接上),会渲染出一条什么
  都没有的分隔线。

验证:新增 scripts/check-stream.mjs,真浏览器跑真组件——5 类真实运行 ×
(无报错/无转义 JSON/无 0ms/不横向溢出)+ 展开看 SQL 和结果表 + 空态。
check-decode.mjs 补 11 条,两个脚本共 68 条断言全过。frontend/preview.html
是离线预览页,只有 dev server 加载(已确认不进 vite build 产物)。
2026-09-17 17:00:50 +08:00
ThearchyHelios ca6e672dfa 事件解码器:全站唯一的"事件 → 人话"翻译层
UI 重构的地基。现有的 describe()(RunPanel.tsx:392)是工程师视角——
db_query__warehouse({"sql":...}) 是函数调用语法、1234+567 tok · $0.0021 是账单、
exit=0 · microvm 是退出码,而且 25 个事件类型里一半直接 return '' 没翻译。

做成独立模块而不是散在各页面,是因为画布时间线、助手栏、运行页要读同一个
故事。两套翻译的话同一次运行在不同地方讲得不一样,用户没法判断哪个是真的。

## 三条原则

- **不回写事件**:run 终态时对全部已落库事件算 manifest_hash,事后增删改会
  对不上。翻译只在前端内存里发生。
- **成对事件合并成一行**:tool.start + tool.end 是同一件事的两个时刻,拆两行
  等于把"查询数据"和"查完了"当成两件事。按 call_id 配对(并发调同名工具时
  按名字会错位),独立 Tool 节点没有 call_id 则回退到名字。
- **节点是天然的分组单位**:一个图节点内部可能有好几轮模型和工具调用,
  它们属于同一个步骤——这正是"简要思考节点"该有的结构。

## 用真实事件调出来的五个坑

从库里导出 5 类真实运行(带查询/带审批/带出具/失败/带思考)当 fixture,
构造的样本永远不会长成那样:

- **审批重复三次**。human.requested 没有 interrupt_id、run.interrupted 才有,
  两个 key 算不到一起;而审批恢复后 LangGraph 重放节点又发一遍 human.requested。
  改用两者共有的 node_id|mode|title 做键,三条收敛成一条。
- **节点重放让同名节点出现两次**。node.started 第二次来时复用原来那条。
- **"继续执行"说两遍**。run.resumed 和 run.started(resumed=true) 是同一件事。
- **标题退化成裸 id**。没起过名字的节点 label 等于 id(后端兜底),"in"/"h"/"out"
  对用户毫无意义——退到节点类型的中文名。
- **空 preview 当详情显示**。{} 不是详情,是噪音。

## 刻意不做的

- 不显示 token 数和美元:那是账单视角,不是"它干了什么"
- 不依赖 canvas/nodeDefs:解码器是纯翻译层,画布只是消费者之一。
  16 行的类型名映射重复得起,反向依赖会让运行页被迫加载整套节点定义
- 未知事件不静默吞掉:宁可显示一条原始的,也比让人觉得"什么都没发生"好

## 验证

scripts/check-decode.mjs 用真实 fixture 跑 21 条断言(转译借 vite dev server,
测的就是应用实际运行的那份)。守的是"审批只出现一次""没有裸 id""没有事件名
泄漏到标题"这类会悄悄退化的性质。
2026-09-17 16:25:33 +08:00
ThearchyHelios 76837d4ef0 dev.sh:端口被占时挪开,并确认答应健康检查的是自己
踩到的坑:8000 被另一个项目长期占着(绑 *:8000),uvicorn 起不来直接退出,
而健康检查去探 8000 又被那个项目答应了,脚本以为后端就绪继续起前端——
于是前端代理连着别人的服务,界面上全是陈年旧数据,还很难看出哪里不对。

三处收紧:
- 端口被占就自动往后挪(显式指定 AGENTLAB_PORT 时则明确报错并列出占用方)
- 健康检查要看到 service 是 agentlab 才算数,顺便看住后端进程别悄悄死了
- vite proxy 的目标端口跟着 AGENTLAB_PORT 走,不再硬编码 8000
2026-09-14 14:20:20 +08:00
ThearchyHelios 4e901be0ec 改用 conda 管理后端环境,移除 uv
environment.yml 只负责把解释器和依赖装进具名环境 agentlab,依赖清单仍以
backend/pyproject.toml 为准,不在两处各写一遍。dev.sh 通过 conda run 解析出
真实解释器路径再启动 uvicorn,环境不存在时自动按 environment.yml 创建。

uv.lock 一并删掉:留着会和 conda 装出来的环境慢慢漂移,两套锁反而更难查问题。
2026-09-14 14:09:51 +08:00
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00