Commit Graph

211 Commits

Author SHA1 Message Date
8ca28eb071 feat(toolcall): 工具结果只统计不裁剪(方案 B),并治掉 seq 侧的静默截断
问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接
`Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。
时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管
a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破
预算而内核不会提前发现。

为什么**不裁剪**(与方案 A 的取舍):
· 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定;
· 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复
  吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%);
· 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询),
  而不是内核单方面替模型决定。

改动:
· core/toolresult_budget.go: checkToolResultSize 只**计数+报告**;
  阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉);
  报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发
  消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。
· 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的
  内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。
· TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询
  "是否有工具在稳定产出超大结果"。

★ 顺带治掉 seq 侧一处**我自己留下的静默截断**:
handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字
且**无任何标注** —— 正是我批评过的静默降级。
改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前
160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。
端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷
("变量槽被截到 160/5000 字却没有任何标注")。

判据(toolresult_budget_test.go,4 条):
· 400KB 结果触发超限报告(含工具名与 token 数)
· ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式)
· 小结果不误报(噪音会淹没有效信号)
· 报告文案可执行:带工具名、token 数、改法建议

变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。

另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**——
循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map),
`-race` 下 20 轮并发判据全绿。
2026-09-27 14:05:38 +08:00
116dc413f0 feat(seq): 新增 seq_help —— 格式说明 + 可照抄示例
动机来自真机实跑:模型写序列时踩了三个坑,各试 1~3 次才改对
  ① tools 漏末尾的 ';'       → 「末尾缺少 ';'」
  ② group 的 in 传成字符串     → 重试 3 次
  ③ as 指向未声明的 out 槽      → 静态校验拦下
这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰是模型最易错处。
散落在七个描述里等于没有集中入口。

实现(help.go + tools.go + plugin.go):
· seq_help 无参数、纯文本返回(与仓内 output_send__*_help 同范式)
· 「格式要点」逐条写明:in/out 必须是**对象**、tools 必须是**字符串**、
  每个 tool 后(含最后一个)都要 ';'、as 必须在 out 声明、
  groups 与 file 二选一、组内并行组间串行
· 「条件 when」列出支持的表达式形态
· 「可照抄的完整示例」给一行**单行紧凑**的合法序列

★ 判据(plugin_test.go,3 条):
· seq_help 已注册、有 description、不声明并发安全
· 内容覆盖真机踩过的**每一个**坑(判据从"坑"出发而非从"打算写什么")
· ★ 示例**自己能被本包解析器接受**:validateHelpExample 从帮助文本里
  抽出示例喂给 Parse —— 模型是照抄的,示例自己解析不过就是给模型挖坑。
  而"从文本里有没有某个词"是看不出这类 bug 的。

过程中判据自己错了两次(都被这条示例判据照出来):
1. 抽取用 strings.Index(help, `{"name"`) ⇒ 先命中「格式要点」里**有意写的**
   示意片段,截到非示例的内容,报出莫名其妙的 invalid character '…'。
2. 修完又混用两套偏移基准(base 的下标拿去切 help)⇒ invalid character '\xaf'。
   ⇒ 重写为全程在同一 base 上定位。
★ 两次都说明:**判据的抽取逻辑本身就是需要验证的代码**,
它出错时报出的信息极具误导性(看起来像实现有 bug)。

示例形态也改过一次:原为多行缩进 JSON,改为**单行紧凑** —— 模型照抄时
免去缩进/换行带来的额外风险。

变异验证:去掉示例里的末尾 ';' ⇒ 示例判据 FAIL。

另一处:加 seq_help 后「恰好注册 6 个工具」判据 FAIL(实际 7)——
这正是那条判据的用意(防止悄悄多加工具稀释工具面),已更新并注明原因。

回归:go build ./... 通过;internal/plugins/... core sdk 全绿。
2026-09-27 13:48:15 +08:00
0b96d6d78c fix(seq): 类型不匹配的错误改成模型可执行的话(真机实跑发现)
真机实跑(独立实例)发现:模型把 group 的 `in` 传成字符串 "{}",
拿到的是 encoding/json 的原始报错:

  json: cannot unmarshal string into Go struct field rawSeq.groups.0.in
        of type map[string]string

这句说的是**事实**(string 解不成 map)而不是**该怎么做**
(in 应该写成对象 {"键":"类型"});残留的 `rawSeq` / `Go struct field`
更是 Go 内部实现细节,对模型无意义且会误导它去猜一个叫 rawSeq 的东西。
模型为此**重试了 3 次**才改对。

这与本仓反复吃亏的那类问题同源:`20s` 少引号 → 静默降级 →
cmd_run 失败率 34%。**报事实不报改法,模型只能猜。**

改动(parse.go):新增 friendlyJSONError,把原始报错翻译成可执行文案
· in / out 类型不符 ⇒ 说明"应写成对象 {键:类型};无入参请写 {}"
· tools 类型不符 ⇒ 说明"应写成字符串(内容是 ; 分隔的 JSON 对象)"
· groups / name / when / missing / timeout / on_error ⇒ 逐个说明期望
· 未知字段 ⇒ 列出 group 允许的全部字段名(拼写错误最常见)
· shortFieldName 剥掉 `rawSeq` 这类包内类型名前缀
· jsonKind / goTypeName 把 Go 类型翻译成模型看得懂的说法

判据(parse_test.go,2 条):
· in 传字符串 ⇒ 错误须指名字段、须说明该传对象、**且不得残留 Go 内部类型名**
· tools 传数组 ⇒ 错误须指明 tools 且说明它是字符串

★ 变异验证时踩了一次坑:第一次变异让 friendlyJSONError 不被调用,
结果**编译失败**(函数变成未使用),判据压根没跑,我却看到 "ok"。
改用可编译的变异(函数保留、开头直接 return err)后判据正确 FAIL。
★ 教训:**"变异后判据通过"要先确认变异真的生效**——编译失败 ≠ 判据通过。

真机复验:模型读一次即懂,并明确说"提示里的意思很明确";
修复前它为此重试 3 次。

回归:internal/plugins/... internal/agent/core internal/sdk 全绿。
2026-09-27 13:43:39 +08:00
3d31037f63 test(seq): 端到端接线判据,抓出「传参方式完全不可用」的真 bug
P1–P4 的判据都在**包内**(假 runner / 直接调函数),覆盖的是**语义**;
本轮补的是**接线**层——参数名对不对、返回值模型读不读得懂、跨层调用断不断。
接线层的 bug 语义判据抓不到:例如工具注册了但参数名拼错,单元判据全绿
而模型永远传不进来。

★ 抓到一个真 bug:**seq_create 走 groups 传参时完全不可用**。
根因:marshalGroups 只把 groups 包进 JSON 文档、不带 name,而 Parse 要求
name 非空 ⇒ 报「序列缺少 name」。而 seqCreate 里那句
`if seq.Name == "" { seq.Name = name }` 回落分支是**死代码**(Parse 早就失败了)。
后果:**只有 file 方式能用,传参方式一律失败**。
已修(name 一并包装)。包内判据抓不到这个——它们直接构造 *Sequence,
不经过这条路径;只有真正 dispatch 一遍才暴露。

判据(e2e_test.go,7 条):真实 dispatch 串通
seq_create → seq_list(须展示签名,模型据此按名调用)→ seq_run
(执行序按 tools 声明序、槽回填、结果里**不得**出现 Go 的 `map[` 语法)
· seq_call 按名调用 group 并返回其出参
· seq_when_call 条件为假 ⇒ 跳过且**零工具被执行**
· seq_when_call 条件畸形 ⇒ **报错**且零执行(不得静默跳过)
· seq_create 走**文件**(长序列的主力用法)
· groups 与 file 同时传 ⇒ 报错「二选一」
· seq_delete 不存在 ⇒ 报错(模型会以为删掉了)

过程中又一次臆造 helper(`writeFile`),改用 os.WriteFile;
并把三处 `_, _ = p.dispatch(...)` 补上 err 检查(正是
go-ignored-call-result 报的那类)。

回归:seq -race 全绿;go build ./... 通过;go test ./internal/... 全绿。
2026-09-27 13:18:19 +08:00
71c894c182 feat(seq): 六个 seq_* 工具、插件装配,并补内核两处缺口(插件线 P4)
内核缺口(都是 P3 落地时暴露的真实缺陷):
· **GetAllTools 丢 ParallelSafe**:它只带出 Name/Description/Parameters,
  插件看到的设备工具一律"不可并发" ⇒ 设备工具的并发声明**对插件不可见**。
· **ToolAPI 缺按名查**:新增 ToolDefByName。插件需要在**运行前**判断目标
  是否存在/是否并发安全(工具动态注册,"不存在"是常态),
  而 GetAllTools 只能拿到全量列表。查不到返回 nil,不 panic。

seq 插件:
· plugin.go:插件骨架 + kernelRunner(把 sdk.ToolAPI 收窄成三个方法,
  判据因此能用假实现驱动,不必构造整个内核)
· tools.go:六个工具定义(独立真相源,注册/判据/文档都从它取)
· handlers.go:seq_create/list/delete/run/call/when_call 的实现
· register.go + all.go:按 skillmgr 同一范式 init 注册

★ 过程中解决一个**我自己的设计矛盾**:
判据原先要求 `seq_call` / `seq_when_call` 进黑名单,但"按名调用
group/序列"恰恰是本包的核心能力——禁掉它,序列就退化成单层脚本。
分层澄清后:黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条
序列**;seq_call 系列留给序列内部组合,其递归由 maxCallDepth + 环检测
负责(设计文档 §8.3 本来就是这么定的,我把两层混了)。
`seq_run` 留在黑名单:序列内再跑整条序列语义上是递归。

**六个工具一律不声明 ParallelSafe**:seq_run/seq_call 会执行一串工具,
其中可能含写操作;标成并发安全会让内核把两条 seq_run 并发跑,
两个序列的执行顺序交错、变量表互相污染。

安全性:序列名与文件路径都做穿越防护(`..` 段、分隔符、空名)。

过程中四次自伤:
1. 臆造 `jsonMarshalIndent`(不存在)→ 改 encoding/json.MarshalIndent;
   并把 execGroup 的 runner 传错成 p(应 p.runner)。
2. seq 判据里写了 `black(name)`,而 blacklisted 是**谓词**不是函数。
3. 一次 python 替换删漏,把「跨序列目标存在性检查」那段从 CheckNew
   里整段摘掉又贴回原处——靠编译错误发现。
4. ★ 注册失败我写了 panic:内置插件在 main() 装配期加载,panic 会
   **直接拖垮内核启动**,而"某个工具没注册上"只该让该工具不可用。
   已改为 log.Printf + 继续(与 clawhubadapter / mcp 一致)。

变异验证:把 seq_run 移出黑名单 ⇒ 黑名单判据 FAIL。

判据(plugin_test.go,6 条):六个工具全部注册且 description/参数 schema
非空;seq_create 声明 required 并说明 groups/file 二选一;
seq_run 说明"按数组顺序";六个工具均未声明 ParallelSafe;
黑名单含递归风险项且**不误伤** seq_call 与普通工具。

回归:seq -race 全绿;internal/sdk/... internal/plugins/... 12 包全绿。
2026-09-27 13:05:28 +08:00
3d753126a3 feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)
store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
2026-09-27 12:47:18 +08:00
7532af7e9b feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2)
三个不变量(各有判据钉住):

1. **组内并行、组间串行**。parallel=true 时各工具并发执行。

2. ★ **合并按声明顺序**,不按完成顺序。
   并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果,
   整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引),
   组屏障处按 tools 数组顺序一次性合并。
   顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。

3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。
   把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了
   —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。

条件求值(设计文档 §5 的 L1+L2,不引表达式引擎):
· true/false、$args.key 裸引用(真值)
· == / != / > / < / >= / <= 、contains
· 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本
· 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定

变量插值两种形态(缺一不可):
· **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型**
  (数字仍是数字;否则模型收到字符串 "3")
· **文本内插值** "ssh $args.host" ⇒ 在字符串内替换
· 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")
  (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法)

on_error:abort(默认)/ continue。失败时也留槽(记错误文本)——
否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。

判据(exec_test.go,8 条):
· 具名槽写入正确
· ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱)
· array 槽同名 as 按声明顺序确定性追加
· 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行
· ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具**
· 条件为真 ⇒ 正常执行
· on_error 的 abort / continue 两种语义
· 插值:文本内替换 + 整值引用保留类型

过程中三次自伤:
1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了
   摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。
2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒
   build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。
3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" ——
   是我没配就断言,不是实现错。

变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL,
报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。
(第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测,
 已改成真正模拟完成序的实现。)
`-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。

顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`,
**不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
2026-09-27 12:29:56 +08:00
76030ae30e feat(seq): 序列文本 → AST 解析与静态校验(插件线 P1)
插件,不是内核:并行执行是内核提供的**唯一**基础设施(core 的
batchRunnable);分组 / 具名槽 / 条件 / 调用图全部在本包内自建,
**不要求内核开任何新接口**。

实现(parse.go):
· Sequence / Group / ToolCall 三个 AST 类型
· Parse:JSON → AST + **全部**静态校验一次做完
  (而非留到执行期——group 有独立签名,具名槽的价值就在于构建期就能
  查出错写的槽)
· splitToolList:`;` 仅在 brace 深度 0 且**不在字符串内**时才是分隔符
· 枚举/未知字段一律硬报错:DisallowUnknownFields + 显式校验
  (missing / on_error 报错时列出合法取值,不当默认值蒙过去)

静态校验规则:
· `as:X` 未在 out 声明 ⇒ 报错(具名槽的核心价值)
· `$args.X` 未在 in 声明 ⇒ 报错
· group 名重复 ⇒ 报错(签名名必须唯一才能按名调用)
· 非 array 槽被同名 as 写多次 ⇒ 报错(组内并行下同名写入是数据竞争);
  array 槽则允许(组屏障按序追加)
· tools 分隔符畸形(漏中间 / 漏末尾 / 连续 / 未闭合)⇒ **硬报错**,
  绝不静默吞掉一个工具

判据(parse_test.go,9 条),★ 两条最关键:
· 含分号的真实 command(取自 core 里那份线上日志 fixture)保持为 1 个工具
· ★ TestBracesInsideStringDoNotAffectDepth:字符串里的**不成对**花括号
  不得影响 depth

★ 本阶段的三次自伤(都靠变异测试暴露,不是靠判据变红):
1. **格式本身是错的**:我在设计文档里写的 `{tool:cmd_run,...}` 根本不是
   合法 JSON(键没引号),encoding/json 直接解析失败。判据一跑就暴露
   ——"写了格式却从没验证它能解析"。已改为要求合法 JSON(键带引号),
   这也是 DisallowUnknownFields 能生效的前提。
2. **判据验证的不是它声称验证的规则**:原本那条"分号在字符串内"的用例,
   分号其实落在 args 对象的**花括号内部**,depth>0 就足以保护 ⇒ 删掉
   分词器的字符串跟踪后**仍然全绿**。反复两次才找到真正的判别点:
   必须用**不成对**花括号在 depth 恰为 0 处,才只有字符串态能救它。
3. 手写多层转义把引号写成 \",使分词器永远进不了字符串态。改用
   json.Marshal **分层构造** fixture——手写转义没有不出错的机会。

变异验证(两轮,均能检出):
· 删掉"回到顶层必须紧跟 ;"检查 ⇒ 漏中间分隔符用例 FAIL
· 删掉字符串跟踪 ⇒ TestBracesInsideStringDoNotAffectDepth FAIL
  ("结构未闭合(括号深度 1)")

回归:internal/agent/... internal/plugins/... 全绿。
2026-09-27 12:05:27 +08:00
9c93f232e3 fix(webui): 星图配色改中性灰蓝 + 图例按实际类型动态生成
### 为什么会出现「一片绿」

上一提交修好了「分类配色从未生效」那个 bug(服务端发 "Concept"、JS 键是
"concept" ⇒ 永不匹配 ⇒ 1150 个节点全回退兜底灰 0xcccccc)。修好之后
颜色值**一个字没改**(concept 键仍是 0x44ff88),于是 1148 个 Concept
节点第一次真的拿到了那个亮青绿 —— 1149 个节点里 1148 个是 Concept,
所以整张图变成绿的。

⇒ 结论:绿色不是渲染 bug,是「配色终于生效」后暴露出的真实数据形状。
   之前的灰色恰好是「全都没匹配上」的症状。

### 换中性色(用户裁定)
默认色改为 SM_COLOR_DIM = 0x7d8a9e(中性灰蓝)。亮青绿配 1149 个
自发光球确实扎眼。

### 顺带把「图例说谎」也修了
原图例写死「人物/概念/对象/地点/来源」五项,而实测图谱里只有
Concept(1148)与 Source(1)—— 列出四个永不出现的类型,等于告诉
用户存在实际不存在的分类。

现在:
- 图例按**实际出现**的类型动态生成,标注占比
- 占比 <1% 的不单列(实测 1/1149 = 0.087%,单列会显示成「来源 0%」,
  既难看又误导读者以为图里没有来源节点),归入「其他 N 个」
- 只有**一种有存在感**的类型时,附一句实话说明「节点同色不是分类图」

### 颜色规则也跟图例对齐
smColorFor:只有存在 >=1% 的第二类型时才按类型上色,否则全图中性色。
理由:99.9% 概念 + 0.1% 其他时按类型上色,得到的仍是一整片同色,
而那一两个异色点在视觉上就是噪点(实测 colors 只剩 ["7d8a9e"])。

不动服务端类型识别(用户裁定):nlp/extractor.go 至今不判类型、
graph.go:451 写死 Concept,根治要改记忆链路,本轮不碰。

### 途中修掉一个自己引入的 bug
图例空。首屏星图在**总览页**初始化,那时 #sm-legend 还不存在
(骨架由 renderStarmapTab 建),smLegend 内部 getElementById 返回 null
直接返回;而 renderStarmapTab 建完骨架后只调了 smUpdateStat()。
⇒ 浏览器实测图例 html 长度 0。在 renderStarmapTab 里补一次 smLegend()。

### 验证(真实 1149 节点数据集 + WebGL)
- 颜色:["7d8a9e"](单一中性色)—— 改前 ["44ff88"] 一片绿
- 图例文本:「概念 100%  其他 1 个(图谱实体几乎都是同一类型,节点同色;
  出现新类型后会自动分类)」
- 统计:1149 节点 / 865 关系;控制台零异常
- go vet 干净;全量测试通过

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 07:14:34 +08:00
4a231a8d4c style(webui): dashboard.js 两处表达式合并为单行
纯格式化,无语义变化:
- state.startedAt 的三元表达式(563 行附近)
- renderAll 里 Promise.all 的实参(624 行附近)

两处的表达式与参数列表逐字符相同,只是原先的换行被收拢。
已随二进制部署并验证上线:现网 `/` 返回的 HTML 里单行写法命中 1 处、
旧多行写法 0 残留。
2026-09-27 06:44:49 +08:00
d3315c68ca perf(webui): 前端按页签懒加载 —— 首屏不再拉隐藏页签的数据,空闲请求砍到 1/3
生产实测的问题:renderAll 无论当前在哪个页签,都无条件拉 9 个接口并
渲染全部 7 个页签。首屏 792,933 B 里**约 230KB 花在用户看不见的隐藏
DOM 上** —— /api/v1/kernel(152KB,只有内核页要)与 /api/v1/settings
(72KB,只有设置页要),而 renderKernel() / renderOneSettings() 是在
**隐藏的 tab 容器**里构建 DOM 的。更糟的是每 15s 重来一遍。

### 依赖关系是实测出来的,不是猜的
逐个 render 函数 grep 它读的 state.*:
  renderOverview     → 无(只读 status/runtime/dom)★ 总览最便宜
  renderKernel       → state.kernel
  renderOneSettings  → state.settings / state.meta
  renderPlugins      → state.kernel / installedPlugins / disabledPlugins
  renderAdapters     → 无(自拉 /api/v1/adapters)
  renderChat         → 自建布局;星图/终端/命令是其子面板
于是「切到哪页才拉哪页的数据」写成一张 TABS 表(唯一真相表),
正确性由结构保证,而不是靠一串 if 串联。

### 顺带治掉三个轮询器(浏览器 40s 空载实测)
改前停在总览页 40s 内 37 个请求:
  /runtime 17 次、/terminals 8 次、/cmd/history 8 次、/status 3 次

1. **terminals/cmd/history 的 5s 轮询是无条件的** —— 但这两个面板只
   存在于**聊天页**(buildChatLayout 里的 chat-panel-terminal/-cmd),
   在总览/设置/内核页渲染它们既没人看也只改看不见的 DOM。
   改为「仅聊天页可见时才轮询」。
2. **/runtime 有两个消费者各拉一遍**:startRuntimeTicker(3s)与
   starmapPullActivity(3s)。星图现在只读 state.runtime,不再自己发请求。
3. 轮询改走共享数据块 starmapFetchBlock(带 3s 节流 + 单一数据源)。

改后 40s 内 17 个请求(-54%),且不再有任何接口用于渲染不可见的面板。

### 首屏
overview 首屏只拉 status/runtime/chat/history/persona/memory-graph,
**不再拉 kernel 与 settings**。

### 刷新语义
区分「活数据」与「近乎不变的数据」:status/runtime 每 3s 允许重拉;
kernel/settings/plugins 首次拉过后**不再每 15s 重拉**(这正是 53MB/h
的主因)。切回页签也不重拉(数据没理由变)。四个变更操作
(源/MCP 的增删)改调 renderAll(true) 强制刷新;启停插件/保存设置那几处
本来就自己 refetch 再局部重渲染,不依赖 renderAll。

### ★ 途中修掉一个被上一提交引入的真 bug
loadChatStarmapData 的空图分支硬写 getElementById("sm-container-chat")。
星图搬到总览后,总览页与独立页签都没有这个 id ⇒ 首页首帧必抛
「Cannot set properties of null」,被 catch 吞掉但 starmapInit 没置上,
于是**永不重试、星图永远空白**。改为取 starmapActiveContainer() 并加
空值保护。浏览器实测:修前 ERRORS 非空,修后 STARTUP + 7 个页签全 clean。
(教训:把 UI 元素挪到新位置后,必须把所有按 id 直取该元素的地方找全 ——
grep 该 id 一次。)

同时删掉被 starmapActiveContainer 取代的死函数 starmapContainer()。

### 验证(浏览器实测,非估算)
- go vet 干净;全量测试通过
- 首屏(overview):只拉 status/runtime/chat-history/persona/memory-graph,
  kernel 与 settings 确认**未出现**
- 逐页切换记录请求:每页只拉自己那几项
- 空载 40s:37 → 17 个请求
- 运行态面板仍正常渲染(rt-panel 存在、rt-sec-pipe / rt-sec-topo 均在)
- 启动 + 7 个页签全部无控制台异常

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 00:23:04 +08:00
ae87f4b8b2 perf(webui): 服务端 gzip —— 首屏 wire 字节 -70%
生产实测:首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding
(直连 127.0.0.1:8080 与经 nginx 的公网入口两条路径都验过:响应头里没有
该字段,wire 尺寸 == 原始尺寸)。同一份数据 gzip 后:

  /api/v1/kernel     152,667 →  37,697  (-75%)
  /api/v1/chat/history?limit=40   554,764 → 174,594 (-69%)

这些是高度重复的 JSON(同批 key 名反复出现、中文实体名、时间戳),
压缩比自然地高。真实实例上实测首屏 wire 字节 77,943 → 23,339(-70%)。

位置:链改为 proxyDispatch → gzip → logged → mux。夹在 proxyDispatch
与 logged 之间,是因为 proxyDispatch 命中时直接 return、响应来自上游
(其 Content-Encoding 由 httputil 处理),我们不插手;门户自身的全部
响应(requireAPI 的 401/503、requireWeb 的 302、HTML/CSS/JS、全部
JSON API)都压。

### 三个必须显式处理的坑

1. **SSE 不能压。** text/event-stream 进 gzip 缓冲后 flush 语义就废了
   (前端收不到流式,要等缓冲攒够)。对 SSE 请求直接透传。
2. **必须透传 http.Flusher。** handleChatEvents / streamOpenAI 里是
   `w.(http.Flusher)` 类型断言;包装 ResponseWriter 会让断言失败 ⇒
   flusher 为 nil ⇒ 走降级分支 ⇒ SSE **静默**坏掉(不报错,只是收不到
   流式)。这不是「顺手加一下」能过的改动,有专门的判据守着。
3. **204/304/HEAD 没有 body**,压它们只浪费 CPU 并加坏头。
   另外 webp/png/zip/gzip 等已压缩类型也跳过(mascot.webp 133KB 就在内)。

### 小于 1KB 的响应不压
gzip 头 23 字节,几百字节的 JSON 压完反而更大。与 nginx 的
gzip_min_length 1000 对齐。实测 /api/v1/status(197B)不带
Content-Encoding。

### 状态机写成枚举而非多个 bool
第一版用 passthrough/decided/buffering/allowBuf 四个 bool 交叉表示,
结果出两个 bug:小响应内容被写成空、已压缩类型仍被压。根因是
「该不该压」在 Write / WriteHeader / 收尾三处各判一次且判据不一致。
改成单一 mode 枚举(undecided/passThrough/buffering/streaming)、
判据只在 WriteHeader 与 Write 各求值一次后,两个 bug 同时消失。

### ★ 一条判据我自己写错了,值得记下来
TestGzipDropsContentLength 初版断言「压缩响应不应带 Content-Length」,
实测失败。追查后证明**判据错了、代码是对的**:
Go 在 Del("Content-Length") 之后,若响应体小到能被一次性缓冲(<2048B),
net/http 会**自动重算**并补上压缩后的真实长度(实测 14000B → 119B →
响应头 Content-Length: 119,正确)。真正要防的是**陈旧长度**:留着
14000 而实发 119 时,客户端按 Content-Length 读满会先拿到 119 字节再吃
unexpected EOF(已用对照探针实测复现)。判据改成两条:①声明长度 ==
实际读到字节数 ②该值 == 压缩后长度而非压缩前长度。另加一条对照判据
TestGzipStaleContentLengthWouldBreak,把危害钉成可执行断言。

### 验证(不是「应该能跑」)
- go vet 干净;全量测试通过;新增 12 条 gzip 判据;覆盖率 66.5% → 67.0%
- 真实实例(独立数据目录 + 18081 端口)实测:
  · SSE:无 Content-Encoding,2 次独立 TCP 读(逐帧下发,未被缓冲)
  · /api/v1/status(197B):不带 Content-Encoding
  · /api/v1/kernel -69%、/api/v1/settings -73%、/api/v1/plugins -63%
  · 首屏 wire 字节 77,943 → 23,339(-70%)
  · 内容完整性:gzip 解压后与明文逐字段相等(plugins/tools/build/
    channels 名称集合与顺序均一致)
- ★ 途中被一个「MISMATCH」误导过一轮:/api/v1/kernel 两次请求字节不同。
  追查发现是 IOManager.ListChannels 遍历 **map**(Go 每次迭代随机化),
  **在本次改动之前就已不确定**,与 gzip 无关。差点被我误报成压缩 bug。

附:dashboard.js 被自动格式化器整体重排(6783 增 / 6293 删,纯空白与
引号风格)。已用 prettier 归一化后逐字节比对确认**零语义差异**。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 00:02:28 +08:00
9346fed0d9 feat(webui): 星图跟随 agent 活动 + 搬到主页 + 修分类配色从未生效
星图此前只是静态展示:starmapAnimate() 只转星空,节点完全静止,
与 agent 的动作零关联。本轮三件事。

★ 修一个从未被发现的 bug:分类配色一直是坏的
  服务端 type 是首字母大写("Concept",见 internal/memory/graph.go),
  而 smTypeColors 的键全是小写 ⇒ 永远匹配不上 ⇒ 1151 个节点全渲染成
  同一个灰色 0xcccccc。浏览器实测确认:改前 colors=["cccccc"],
  改后 ["44ff88"](概念绿)。

一、跟随 agent 动(三路信号,全部在渲染循环里推进,不另起定时器)
  1. tool_call / stage / agent_output 的 SSE 事件 → 命中节点发光冲高
     + 尺寸微扩。工具名按**词**匹配实体(knowledge_list → knowledge_*)。
  2. /runtime 调度器(3s)→ 排队/中断/挂起时全图绷紧;中断或抢占计数
     上升时来一记强脉冲。
  3. /memory/graph/pulse(10s,新端点)→ 新记忆「生长」:从 0 弹到
     正常大小并留余晖。
  另:距上次活动越近,全图越亮(抽样呼吸)—— agent 一忙图就活。

二、搬到主页 + 独立页签
  总览页内嵌 360px 星图;顶部导航加「星图」独立页签(全高 + 图例)。
  同一套 renderer 用 appendChild 在容器间搬运 canvas(three.js 的
  canvas 只能有一个父节点,同时渲染会一边黑屏)。

三、性能:保留全部 1151 节点,但全部降规格
  改前每节点 = 独立 SphereGeometry(16,12) + 独立光晕球 + 一张 256x64
  CanvasTexture ⇒ 2302 个独立 geometry、约 88 万三角形、1151 个
  <canvas>,仅文字贴图就吃约 72MB 显存。全景远看根本读不清那些标签。
  改后:共享 SphereGeometry(8,6)(约 84 三角形/节点);标签改为 hover
  时在容器角上显示 HTML 文本(零显存,且比 3D 贴图更清晰);
  866 条边按关系类型合并成 4 个 LineSegments(draw call 866 → 4)。
  hover 复位随之改为 baseScale —— 旧的 set(1,1,1) 会把按 mention_count
  缩放过的大节点缩成最小尺寸。

四、/memory/graph 瘦身:不再下发稠密向量
  星图是本接口唯一消费者,却从不读 vector。生产实测该字段占
  79,314 / 402,811 字节 = 19%,而 8 块记忆就这么多,200 块就是 ~2MB
  白查白发白堆。真实数据集实测响应 402,811 → 326,997 字节(-18%)。

新增 /memory/graph/pulse:只回 since 窗口内变动过的实体(id/name/type/
mention_count/updated_at)。星图每 10s 拉它来判断「哪个节点新长出来」,
而不必重拉 400KB 全量。

验证(不是「应该能跑」):
  - go vet 干净;go test 全绿;新增 5 个测试(向量裁剪 / pulse 窗口 /
    since 放大 / pulse 不带向量 / 类型断言失败时透传不丢数据)
  - 覆盖率 65.5% → 66.5%
  - 真实 1151 节点数据集上跑 headless chromium + SwiftShader 实测:
    nodeMeshes=1151、edgeSegs=4、geoShared=true、控制台零报错、
    图例与统计(1151 节点 / 866 关系)正常、canvas 在两个容器间正确搬运
  - 脉冲匹配在浏览器里逐个 hint 验证:
      knowledge_list → 2 个(只命中 knowledge_base / knowledge_list)
      qq_get_message 等无匹配 → 8 个(走「整体活动」兜底)
    ★ 途中修掉自己的两个错:① 最早的子串匹配让 hint="knowledge" 命中
    全部单字实体(一次 pulse 选中 250 个、队列顶到 260 上限);
    ② 改成词匹配后,旧的「补齐到 20 个」逻辑又把 1 个真实命中补成 20 个
    无关节点 —— 现象与①一样,只是成因不同。补齐现在只在**完全无匹配**
    时启用。

未做(本轮范围外):服务端 gzip(首屏 793KB 无压缩,实测可压到 ~240KB)、
renderAll 按页签懒加载(首屏仍在拉隐藏页签的 kernel+settings 共 230KB)、
setInterval 15s 全量重拉(空闲 53MB/h)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-26 23:31:58 +08:00
dfc05780fd feat(kbtree): 知识库暴露范围配置 —— 按树状只暴露指定分类
## 问题

kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent 自己的 agent
走进程内直调 knowledge_* 内核工具,不经此),但它只有 listen_addr 与
token 两个配置,**没有任何范围限制**:拿到 token 的任何 agent 都能
/tree 列出全部条目、/search 取回任意条目全文。

本机库里混着个人内容(航空发动机教材摘录、课表、身份合并规则),
不该 broadly 可读。

## 改动

1. `internal/plugins/kbtree/scope.go`(新):暴露范围语义
   - 留空 = 全部可见(范围是"限制"不是"必填",留空保持既有行为)
   - 前缀按**路径分段**匹配:public 命中 public 与 public/tech,
     但**不**命中 publication(否则 publication 意外暴露)
   - 根下无分类的条目在范围非空时不可见 —— 它没有分类可匹配,
     放行等于范围形同虚设
   - 分隔符容忍逗号/分号/空白/换行/竖线:这是给人手填的字段

2. `plugin.go`:注册 `expose_categories` 配置项,接入**全部四个端点**
   - /tree      服务端裁剪子树(就地改,不重建:TreeView 字段多)
   - /categories 过滤路径列表
   - /counts    过滤计数并**重算 total**(数量本身也是信息泄露)
   - /search    ★ 过滤结果条目;这处最关键:
     只过滤 /tree 而放过 /search 等于范围形同虚设(换个 ?q= 就能拿到全文)。
     同时修正 limit 语义 —— 范围外条目不占名额,范围内条目不会被挤掉。

3. SDK 契约补 `Knowledge.Category`(纯增量)
   - 此前 `sdk.Knowledge` 只有 Name/Content,内核明明返回了 Category
     却在 knowledge_impl 的拷贝里丢掉 ⇒ 外部服务无法按分类判定,
     范围过滤在 SDK 层根本做不了。
   - Name/Content 均保留,无删除。

## 判据(8 条 + 4 组变异)

范围过滤最容易"只做一半",所以每个端点都单独钉。

★ 判据补强一处:初版只查条目名(priv1),结果「/categories 不过滤」
  这个变异**完全逃过** —— 分类端点返回的是路径不是条目名。
  补上分类路径断言(private)后判红。

变异验证:
- /search 不过滤      → 泄露 priv1 全文        ✓ 判红
- /categories 不过滤  → 泄露 private 分类路径   ✓ 判红(补强后)
- /counts 不过滤      → TestCategoriesAndCountsEndpoint 判红
- 前缀退化为字符串前缀 → publication 被误暴露      ✓ 判红

★ 过程中我的 fake 有两处与真实内核不符,先修 fake 再修实现:
  1. 漏了内核 treeLocked 的"子分类提升一层" ⇒ 得到 children=0 的假空树
  2. filterTree 无差别清空 t.Items ⇒ 整棵树只剩空壳节点
  (第一版的实现是"看着测试红就改",实际是 fake 在骗我)

全量 41 包绿。SDK 接口纯增量,未发布故无需冻结检查。
2026-09-26 15:31:15 +08:00
7212ab4aad fix(webui): 星图依赖本地化 —— 不再从公网 CDN 拉 three.js
用户点「星图」看到「3D 星图不可用(CDN 加载失败)」。

服务端 curl 那两个 URL 都是 **200** ⇒ 不是服务端的问题,是**浏览器**
访问不到公网 CDN(内网 / 出口受限 / 断网)。dashboard.html 从 cdnjs 与
jsdelivr 拉 4 个库:three.js、OrbitControls、marked、DOMPurify。

换 CDN 只是把同一个赌注重下遍。HomeAgent 明确支持离线与内网部署,
前端却有 4 个硬依赖在公网上 ⇒ 断网即坏,且用户无从修复。

顺带两个收益:
- **安全**:DOMPurify 是净化 Markdown 的关键一环,它挂掉前端会退化到
  「不净化」分支(dashboard.js 里有 typeof 检查)—— 那是安全降级,
  比星图坏更值得修。
- **体积**:四个库共 ~700KB,embed 后由本服务同源提供,省掉 4 个跨域握手,
  也不再受第三方可用性影响。二进制 84MB → 84.7MB(+0.8%)。

- `internal/plugins/webui/static/` 放四个库(固定版本,随二进制走)
- `//go:embed static` + 新增 `/static/` 路由
- dashboard.html 的四个 src 改指 `/static/...`
- 加载失败兜底 8s → 3s(本地是毫秒级;仍超时就说明真有问题)

`/static/` **不走 requireWeb**:未登录时页面也要加载这些库才能渲染登录框,
加认证会让用户看到白屏(比 401 更难自查)。这些资源不含用户数据。

- TestDashboardHasNoExternalCDN:页面不得引用任何公网 CDN
- TestStarmapDependenciesAreLocal:星图两个库必须来自本地
- TestVendorFilesExistInSourceTree:vendor 文件必须在(embed 的前提)
- TestStaticVendorRoutesServeRealLibraries:路由**必须真的返回库内容**

★ 最后一条的判据强度是补出来的:初版只判状态码,变异「返回 200 + 空体」
时**仍然绿** —— 而空体在浏览器里的症状与 404 完全一样(都报加载失败)。
改为同时判体积与特征串(REVISION / OrbitControls / marked / DOMPurify)
后,变异「只写前 10 字节」判红。

变异验证 3 条:HTML 改回 CDN → 2 条判红;路由挂回 requireWeb → 503 判红;
截断响应体 → 体积判红。

全量 38 包全绿。
2026-09-26 14:41:32 +08:00
41d754334e feat(kbtree): 知识库分类树的独立只读服务 + agent 技能 + WebUI 树浏览
让**外部 agent** 也能按分类树用这套知识库。HomeAgent 自己的 agent 仍
直接调内部方法(knowledge_search/create 等),走进程内直调,不经此服务。

一、内核树视图(internal/knowledge/tree.go)
  为什么不复用 TreeIndex:那个是**内部导出物**,面向 .index.json 落盘,
  每个条目带 top-20 的 TF-IDF 特征向量。直接序列化给外部有三个问题:
  体积(200 条时 .index.json 已 246KB 且冗余存了 preview,而正本在
  content.md)、泄漏(稀疏特征表 = 分词/IDF 内部表示)、语义错位
  (外部要的是"有哪些分类、每类下有什么")。
  新增 TreeView/Subtree/Categories/CategoryCounts:不含向量,带条目数
  与可读摘要,支持 MaxDepth 懒加载、IncludeItems 只看结构。
  节点 Name 是**本级段名**("go")、Path 是完整路径("tech/go")——
  最初把全路径写进 Name,前端拼层级会得到 "tech/tech/go",已修。

二、kbtree 插件:独立 HTTP 服务(默认 127.0.0.1:9892)
  为何不挂在 WebUI 的 /api/v1/knowledge* 下:
  1. 不共享鉴权与端口。WebUI 的 api_key 是给人操作界面用的,把它分发给
     外部 agent 等于把管理面凭据扩散出去。本服务用**独立 token** +
     独立端口,可单独关闭(token 未配置则启动时随机生成)。
  2. 只读。写入要决定分类归属与媒体处理,外部自行拼装容易造出越界/重名
     条目 —— 写入留给内核工具。
  3. 形状按树组织,而不是平铺搜索接口。
  端点:/tree(可指定 category/depth/items)、/categories、/counts、
  /search、/ (自述)。全部需 token(X-API-Key / Bearer / ?token=),
  非 GET 一律 405。无知识库时 Start 直接失败,不占端口。
  鉴权与 Slowloris/超时设置照 remotedevice 范式。

三、agent 技能(assets/skills/knowledge-base/SKILL.md)
  指令文档型 skill:教模型"先看树 → 定位分类 → 分类内检索",并列出
  易错点(name 已含分类别再拼、只看第一条、404 附现有分类)。
  加载与校验由 internal/plugin/skill_bundled_test.go 守住 —— 这条断言
  的由来:非白名单的二级标题会被 extractToolDefs 当成工具定义,报错
  "invalid tool name",而提示与真正原因(标题层级)毫无关联。
  kbtree 的测试还会校验文档提到的端点与代码一致,防漂移。

四、WebUI 树浏览(前端真正用起来,而非留一个没人调的端点)
  面板加可折叠的分类树:逐级点选即把搜索范围切到该子树(原先是让人
  手打分类名)。当前范围有可见标签与「全库」复位。
2026-09-26 14:20:19 +08:00
8842d76aff fix(webui): 知识库不再把二进制当文本存 + 接上分类/媒体/删除 + 实时计数
后端(handler_memory.go 重写 handleKnowledge):
- ★ multipart 分支原来无条件 file.Read → string(buf[:n]) → 当 Markdown 存进
  content.md:传一张 PNG 得到的是一份乱码文本知识,还在 .index.json 占一份
  preview,且**没有任何迹象**表明出了问题。
  现在按 http.DetectContentType 探测的**真实类型**分流(不信客户端声明的
  Content-Type——谎报 text/plain 的 PNG 在测试里是真实场景):
  媒体入 CAS 按 digest 挂条目 / 文本校验 UTF-8 后存正文 / 都不是则 400 明确
  拒绝并回传 rejected 清单。
- 状态码语义修正:此前 POST/GET 一律 500、DELETE 一律 404,把「名称非法」
  这类调用方能自己纠正的错报成服务器故障。现按 errors.Is 分流
  400/404/503。
- 搜索支持 category 与 limit;返回 knowledgeView(不泄露服务端绝对路径,
  不回传几百 KB 的 Dense 浮点数组)。
- 列表端点补 dense 状态(前端据此提示多模态是否就绪)。
- 媒体存储未接线时上传图片返回 503,而非退化成把二进制当文本存。

前端(dashboard.js):
- 搜索结果从 <pre>{JSON}</pre> 改为结构化渲染(名称/体积/预览/媒体标记
  + 每条删除按钮)。此前前端根本没有删除入口。
- 新增分类输入框(走 category 参数)、多文件上传。
- 计数改实时:原先读 state.kernel 快照,知识条目经工具/上传增删后不会变
  (实测创建完仍显示 "-")。切到知识面板时拉 /knowledge 的真实 names.length。
- 顶部输入框变多文件;显示多模态就绪状态(ready/total)。
2026-09-26 14:20:19 +08:00
a7fbdb3110 fix(webui): 旧反代两条路径合并修复 —— 302变502/泄露内网URL/流式被缓冲
新反代(proxy.go)早已修掉这些,但**两条旧路径**没跟上,各自复制了一份
http.DefaultClient 实现:

  proxyToPluginmgr        (handler_settings.go)
  handleDeviceGatewayProxy(handler_device.go)

同一个 bug 修了两遍还漏了两处。抽成共用的 reverseToUpstream,不再分叉。

## Bug 1:跟随上游 3xx → 302 变 502 + 泄露内网 URL

http.DefaultClient 默认跟最多 10 跳。上游回 302 时反代跟过去,目标可能是
内网另一个服务或不可达,于是把「上游的 302」变成「本层的 502」,
错误里还带着内网地址:

  {"error":"device gateway unreachable: Get \"http://127.0.0.1:1/api/...\":
   dial tcp 127.0.0.1:1: connect: connection refused"}

外部用户看到 Bad Gateway + 他访问不了的内网地址:既无用又泄露拓扑。
→ 反代**不应有重定向策略**(那是客户端的事),原样透传 3xx。
→ 上游错误细节只写日志,对外统一「上游服务不可达」。

## Bug 2:不逐帧 flush,流式响应被缓冲到结束

原实现 io.Copy(w, resp.Body),ResponseWriter 自带缓冲 → 上游按帧下发的
SSE/长轮询内容全堆到上游结束才吐。裸 TCP 实测:上游每 80ms 一帧共 3 帧,
缓冲版本只产生 **1 次**读(集中在 161ms),客户端表现为「卡住不动然后
一次性全出来」。改用 flushCopy(4KB 块 + 块间 Flush)。

## Bug 3:不过滤逐跳头

Content-Length / Transfer-Encoding 描述的是**上游那条连接**的分帧方式,
照抄到本层连接会导致客户端按错误长度读;Keep-Alive/Connection 同理。
按 RFC 7230 §6.1 剔除。

## 附带修正

- 补 X-Forwarded-For / X-Real-IP / X-Forwarded-Host / X-Forwarded-Proto,
  且**只在尚未设置时补** —— 外层 nginx 已注入时覆盖会丢掉真实客户端 IP。
- 与 proxy.go 新建了带超时的 upstreamClient(DefaultClient 无超时,
  上游卡住会拖住 goroutine)。

## 判据(5 条)+ ★ 判据设计的两个坑

★ 这条判据我试错了三轮,过程留在测试注释里:

1. 「首帧早于末帧」→ **假绿**。Go 在响应结束后把缓冲一次吐出,首末帧仍有
   微秒级差,任何 `> 0` 都绿。
2. 用 http.Client 量「首帧延迟 < 上游总时长 70%」→ 仍是**假绿**。实测直连
   上游首帧 761ns、总时长 160ms:Go 的 HTTP **客户端**合并读,量到的
   「首帧」是客户端首次取到数据的时间,与服务端何时 flush 无关。
3. 当前(正确):**裸 TCP 直连被测服务**,看读到几次、分别在什么时刻。
   对照组实测 3 次读 / 0.24ms / 80ms / 161ms —— 正是上游节奏。
   第二个坑:不能按「累计 12 字节正文」判结束(首读含响应头 + 首帧正文,
   会在首读就以为读完,后两帧时序全丢)。改为读到 EOF。

变异验证 4 条(均按预期打红后还原):退回 DefaultClient → 跟随重定向判红;
错误信息带 err.Error() → 泄露判红;退回 io.Copy → 只读到 1 次判红;
不过滤逐跳头 → Keep-Alive 判红。

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
3273c507b3 fix(webui): Server 读侧超时(防 Slowloris)+ 反向判据守住流式不被腰斩
http.Server 原本**一个超时都没设**(只有 Handler)。后果是 Slowloris:
攻击者只占连接不发完整请求头,每个连接挂几 KB。MaxHeaderBytes 限的是
头部**大小**,「慢慢发」不占大小、不受它约束,几百个连接就能耗尽 fd。

## 为什么不是「把超时都设上」

webui 有一条长连接 SSE(/api/v1/chat/events)与可跑 300s 的流式
/v1/chat/completions。WriteTimeout 是「从请求开始到响应写完」的**总预算**,
会把它们腰斩 —— 表现为 SSE 每隔一段时间断一次、前端疯狂重连。而这类
回归在功能测试里很难立刻发现。

所以只设读侧三项,各管一段:

  ReadHeaderTimeout 20s —— 请求头必须按时发完,Slowloris 的正解
  ReadTimeout       60s —— 读完整请求(含 body)的预算,防慢速上传
  IdleTimeout      120s —— keep-alive 空闲连接(另两项都管不到)
  WriteTimeout        0 —— **刻意不设**(见上)

## 判据(3 条,含一条反向判据)

- TestServerHasReadSideTimeouts:三个读侧超时都必须 > 0
- TestServerHasNoWriteTimeout:**反向**钉住 WriteTimeout 必须保持 0,
  防止将来有人「顺手补全超时」把 SSE 弄坏
- TestSSEConnectionSurvivesBeyondReadTimeout:SSE 连接确实活过读侧窗口

反向判据看着琐碎,但它守的正是「这次没做的那件事」——
不加 WriteTimeout 是个**决定**,不是疏漏,所以要用判据把决定固定下来。

变异验证:补上 WriteTimeout:30s → 反向判据判红;
去掉 ReadHeaderTimeout → 前向判据判红。

测试脚手架注意:newServerForTest 绑 127.0.0.1:0(内核分配空闲端口),
绝不用 :8080 —— 那是生产端口(见 a752ae1)。

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
35df6f4366 fix(webui): 限流来源识别改为「只信任受信反代的 XFF」—— 修复把自己锁在门外
★ 这是在生产上亲手踩出来的:上一提交加了按 IP 限流后,我用 8 次错误登录
做验证,结果**把管理员自己锁在外面 10 分钟**。

## 现场证据

  [webui] POST /api/v1/login from=127.0.0.1 auth=none status=429

webui 经 frp/nginx 穿透到公网时,**所有外部请求的 RemoteAddr 都是
127.0.0.1**。于是所有人共用一个桶:任何人爆破 5 次,就把**所有人**
(含管理员)一起锁死。限流从防护变成了 DoS。

## 我第一版还犯了个方向的错

当时我刻意**不采信** X-Forwarded-For,理由是「该头可伪造,换个头就能
绕过限流」。这个理由本身对,但结论下反了:完全不采信,在穿透部署下
**必然退化成全局限流** —— 而全局限流正是我试图避免的那个后果。

## 正确做法:中间路线

**只信任受信反代发来的 XFF**。判定「是否来自受信反代」不能靠
内网/回环 IP 猜 —— 穿透部署下反代恰恰就在本机 127.0.0.1,与直连请求
完全同源,猜不出来。所以由部署方**显式声明**(新设置项
`webui.trusted_proxies`,逗号分隔 CIDR 或裸 IP)。

权衡写明:未声明时穿透明场景下限流退化为「全局」。这是**刻意的保守
默认** —— 宁可限流偏保守,也不能因为采信伪造头而形同虚设。

## 判据(+4)

- TestLoginRateLimitUsesForwardedForFromTrustedProxy:受信反代下按真实
  客户端 IP 隔离(否则就是全局锁)
- TestLoginRateLimitIgnoresUntrustedForwardedFor:换 XFF 头不得绕过限流
- TestLoginRateLimitNeedsExplicitTrustedProxyConfig:未配置 = 不采信
- TestParseTrustedProxies:合法项接受、非法项丢弃、空 = nil

全量:35 包全绿。

★ 附带教训(也记在判据注释里):**用失败注入做验证时要意识到副作用
范围**。我那次「跑 8 次错误密码看看会不会限流」本身是合理的验证动作,
但它作用在**生产实例**上,且限流的作用域(全局化)正好覆盖了自己。
在带状态的安全机制上做破坏性验证,判据应该先证明作用域是对的。
2026-09-26 13:49:59 +08:00
667b9fdc8a fix(webui): OpenAI 兼容面 —— 补 /v1/models + 真流式(原先是假流式)
/v1/* 是**给外部程序用的**(IDE、脚本、agent 框架),不是给人看的聊天页。
它的行为必须真符合 OpenAI 协议,否则调用方直接坏掉。下面两条都在
**生产实测**中确认过,不是推理。

## ① GET /v1/models → 404

几乎每个 OpenAI 客户端(curl 脚本、LangChain、OpenAI SDK、IDE 插件)
启动时都会先列模型来探测服务可用性。404 让它们直接判定「服务不可用」,
连试都不试 —— 这是集成方最容易踩空、也最难自查的缺口(表现为
「连不上」,而实际端点是通的)。

新增 handleOpenAIModels。返回什么模型**不重要**,结构合法才重要:
本端点不做模型选择(model 只是回显),所以只暴露 HomeAgent 自身。
不谎报 GPT 之类名字 —— 那会让用户以为能选模型,实际不能。

## ② stream=true 是假流式

实测:首字节 7.79s,随后**整段**内容在一个 chunk 里到达。

根因:两条路径都走 InjectTextSyncNoMemory —— **同步等完整回复**才返回,
之后才把已拼好的全文切成 3 个 chunk 吐出去。客户端的「生成中」/取消/
超时/进度条全部失效;300s 超时表现为「卡 5 分钟然后一次性出现」。

重写为真流式:先订阅 EventContentDelta / EventReasoningDelta **再**启动
注入(顺序反了会漏开头几个分片),边收边转成 chunk,最后用同步调用拿到的
完整回复补 usage、发 finish、[DONE]。沿用 handleSSE 的成熟结构
(批量 16ms 合并、独立 writer goroutine、done channel 而非 close)。

顺带处理内核的 reset 事件:流式失败回退非流式时内核会发
content="" + reset=true(见 internal/agent/core/process.go)。忽略它会让
客户端看到半截内容后又接上完整内容(重复且自相矛盾),故识别并丢弃累积。

## 判据(4 条,变异验证)

- TestOpenAIModelsEndpoint / RequiresAuth
- TestOpenAIStreamIsActuallyStreaming
- TestOpenAINonStreamUnchanged(别把非流式改坏)

★ **判据本身踩了两个坑,都已修正并记在测试注释里**:

1. `httptest.ResponseRecorder` 把整个响应**缓冲在内存里**,请求结束才交付
   —— 它**根本观察不到流式**。用它写的流式判据必然是假的。故改用
   `httptest.NewServer` + `bufio.Reader` 逐帧读。

2. 「要求首帧早于末帧」**抓不住**假流式:假流式确实是分多次 write 的,
   帧间间隔是微秒级 > 0,任何 `> 0` 判据都绿(已实测)。
   真正能区分的是:**首帧是否早于「内核产出最终答案」那一刻**。
   于是假内核被构造成:发 3 个增量后**扣住**最终响应,直到消费者
   表现出「已在读帧」才放行 —— 真流式首帧 0.35s,假流式首帧 3.30s。

3. 鉴权判据一度写错:未登录时门户 302 到 /login,若跟随重定向就会拿到
   登录页的 200,把「被重定向」误判成「鉴权通过」。改用不跟随重定向的
   客户端。

变异验证:去掉 /v1/models 路由 → 判红(还原 404);
不转发增量 → 判红(首帧 3.30s)。

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
5ebc4481b0 fix(webui): 登录入口加固 —— 限流 + 常量时间比对 + 请求体限量 + 防用户名枚举
门户可经 frp 穿透到公网(https://homeagent.jianfgit.xyz/ 实测直达),
而 handleLogin 原先是**零防护**:无限流、无失败计数、口令用 == 明文比对、
失败不审计。等于把唯一��口令入口直接开到外网任人爆破。

## 改动

1. **按来源 IP 的失败计数限流**(login_limiter.go)
   - 5 次失败后拦,10 分钟窗口。
   - 退避而非永久封禁:窗口过期自动恢复。永久封禁意味着一旦误撞
     (或被撞库)就再也登不进,只能上机器改配置。
   - 成功即清零:手滑输错几次不该被永久记账。
   - **刻意不采信 X-Forwarded-For** —— 该头可伪造,直接采信等于让
     攻击者换一个头就能绕过限流,甚至把限流当成打别人来源的武器。
     代价(已在注释写明):若 webui 挂在反代后,限流会退化成「全局」,
     那种部署应在反代层限流或用 PROXY protocol 传真实来源。
   - **刻意不做账号级锁定**:本系统只有一个管理员账号,账号级锁定
     相比 IP 级无额外收益,却多一个误伤面。
   - 过期记录会被 prune —— 否则攻击者轮换 IP 就能喂成内存泄漏。

2. **常量时间比对**(crypto/subtle):`==` 会在第一个不同字节处短路,
   泄漏「猜对了几位」的时序信息。

3. **请求体限量**:ContentLength 前置拒绝 + MaxBytesReader 兜底。
   ★ 后者**不能只靠解码器报错** —— json.Decoder 按需读流,遇到
   「超大 + 非法 JSON」会在第 0 字节就报语法错误、永远读不到上限,
   于是 8MB 数据已进缓冲而 MaxBytesError 从未出现。只挂 MaxBytesReader
   的写法对最省力的攻击载荷反而无效(实测确认)。

4. **防用户名枚举**:用户不存在与口令错误给完全相同的状态码与报文。

## 判据(8 条)

限流触发 / 按来源隔离(否则一个 IP 就能把所有人锁死,限流即 DoS)/
成功清零 / Retry-After / 请求体限量 / 防枚举 / 过期清理 / 重试时长非零。

变异验证(3 条打红后还原):
- 去掉限流调用 → 3 条判红
- 去掉 ContentLength 前置检查 → 判红(回到 400)
- 去掉 Reset → **起初没打红**:原判据「跑 30 次看是否限流」在阈值只有 5
  时无论有没有 Reset 都会限流,是条**假判据**。已改为**测出实际阈值**
  (清零后应重新拿到完整额度),再去变异即打红。

诚实说明:常量时间比对那条**无法用单测可靠断言**(时序属性,噪声远大于
信号)。它由代码评审保证,不由测试保证 —— 写明以免后人以为有测试兜着。
2026-09-26 13:49:59 +08:00
6d0c1188f6 fix(webui): 服务入口「打开」改用路径形态 + 别名模式不补尾斜杠
验收时发现的**用户可见缺口**:API 早就同时返回 url(子域)与 url_portal
(路径),但「服务入口」卡片只用了 url —— 而子域形态在穿透部署下
**恰恰是打不开的那个**(外层只放行一个 Host、三级子域通配证书不匹配)。
用户点「打开」得到坏链接,还会以为是插件的问题。

## 改动

1. 卡片「打开」优先 url_portal(路径形态):
   无 DNS 依赖,单端口穿透 / 子域无证书时都能用。
   子域链接保留为次选按钮(局域网内直连时更直观)。
   文案补一句说明两者差别(子域需 DNS 能解析 `*.<基域名>`)。

2. **别名模式不再补尾斜杠**(顺带发现的 bug):
   原实现给所有 url_portal 无条件加 `/`。前缀模式下对(那是规范形态,
   前端靠它算相对路径基准);别名模式下错 —— 那里的 path 是上游真实
   路径语义(/api/v1/device 是 /api/v1/device/xxx 的前缀),补成
   /api/v1/device/ 会让人误以为存在一个可访问的根。

## 判据

+2 条:TestProxyServicesOffersBothForms(两种形态都必须给出,
且前缀模式的 url_portal 必须带尾斜杠)、
TestProxyServicesAliasKeepsExactPath(别名模式不得带尾斜杠)。

变异验证(2 条,均按预期打红后还原回绿):
- 别名模式也加尾斜杠 → 判红
- 前缀模式不加尾斜杠 → 判红

另修正一条旧判据的期望值:它当年断言的是「所有 url_portal 都带尾斜杠」
(即把 bug 当成契约钉住了)。那条路由正是设备网关(别名模式),
现在改为断言不补尾斜杠,并注明理由。

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
125bf57cfa fix(test): 测试不再抢生产端口 :8080(internal/plugins 加载内置 webui 所致)
部署过程中反复出现「8080 被 plugins.test 占用」导致生产 WebUI 起不来。
追到底:internal/plugins 的集成测试会 pluginReg.Load(全部内置插件),
其中 webui 默认监听 :8080 —— **正是生产实例的端口**。

## 为什么这个 bug 特别难查

它不是测试失败,而是**测试与生产静默抢端口**:先到者胜,另一个 bind 失败。

  - 跑测试的人看到「测试随机失败」(其实是生产先占了)
  - 用生产的人看到「WebUI 随机死掉」(其实是测试先占了)
  - 两边现象互不相干,且各自单独重跑往往都过

叠加 webui 已有的「bind 失败必须显式报错」修复后,症状从「静默死亡」
变成「随机报错」,这反而让归属更容易看错 —— 我一开始也是先怀疑自己的
部署脚本,直到采样 /proc/<pid>/cwd 才确认是测试进程。

## 修法

集成测试在 Load 之前用既有的 SetListenOverride 把地址指到
127.0.0.1:0(内核分配空闲端口),并在 cleanup 还原。
测试因此拿到真实可用的 HTTP 服务,且与任何固定端口实例完全隔离。

- webui 新增 ListenOverride() 读取当前值,供调用方保存/还原
  (只有 setter 时无法在不破坏调用方状态的前提下做临时覆盖)。

## 验证

- 修复前:跑 ./internal/plugins/ 期间 8080 持续归 plugins.test(109/200 采样)
- 修复后:35 次采样全程 8080 归 homed,测试同时全绿
- 变异验证:移除 override 后立刻复现抢端口,判据有效

另记两个测试卫生问题(同一根源,已顺手清理泄漏进程):
测试会启动**真实插件进程**(/home/newqqagent/plugins/*/plugin.bin)。
kill 测试进程后这些子进程会残留。已全部清理,生产 23 插件恢复正常。
2026-09-26 13:49:59 +08:00
2c810bbbce feat(webui): 路径挂载的 strip_path 两态 + 尾斜杠重定向(修 /p/huawei 打不开数据)
用户要求用方案 A(路径挂载)让 huawei 插件 UI 在外部可用,
并把「通过反代的插件必须使用单一入口」写入 SDK 声明。

## 实测暴露的两个真问题

1. **Path 的语义不能一刀切**。原设计「原样保留」只对**机器接口**成立
   (设备客户端硬编码 /api/v1/device/ws,不可能知道反代的存在);
   而自带 UI 的服务需要**剥掉前缀**(/p/huawei/api/status → 上游 /api/status)。
   猜错的结果是全部请求 404,且看起来像上游故障 —— 所以由声明者选:
   strip_path=false 别名模式 / true 前缀模式。非法组合被 validate 挡住。

2. **前缀模式的尾斜杠是必需的**(自测发现的 bug)。
   访问 /p/huawei(无尾斜杠)时页面能开,但页面里所有 fetch 都 404 ——
   相对路径以「当前文档目录」为基准,没尾斜杠时浏览器把最后一段当文件名,
   目录退回上一级,fetch('api/status') 打到 /p/api/status。
   修:前缀模式且路径恰等于前缀时 301 到 /p/huawei/(保留查询串)。
   **别名模式不做此事** —— 那类路径是上游真实语义,加斜杠会改坏它。

## 插件侧(huawei_smarthome)

- 前端 4 处根绝对路径(fetch('/api/status') 等)改为相对路径,
  基准由 location.pathname 推导(BASE)。这是 Path 形态能成立的**前提** ——
  否则请求会打到门户自己身上。
- plg.json 声明:host + path=/p/huawei + strip_path=true + auth=homeagent。
- SDK 升到 1.4.0,并用 hmapdev 1.4.0 重新打包(1.3.0 的 hmapdev 无
  proxies 支持,会把声明**静默丢弃** —— 实测确认过,这是打包链路上
  一个不报警的坑,值得记住)。

## 判据

+6 条:TestProxyPathAliasVsStrip(两态各自正确)、
TestProxyPathLongestPrefixWins(/p/app 不得劫持 /p/apple,
且长前缀胜出)、TestProxyStripPathRedirectsToTrailingSlash(尾斜杠,
含查询串保留 + 别名模式不得重定向)。

变异验证(4 条,均按预期打红后还原回绿):
- 删尾斜杠重定向 → 判红(还原了真实 bug 形态)
- 让别名模式也重定向 → 判红(设备网关语义被毁)
- 从 hmapdev schema 探测体删 StripPath → 判红(漂移检测有效)
- 删 SDK 里的「单一入口原则」字样 → 判红(契约不能只剩口头约定)

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
5da0f8f9fb feat(webui): 外部入口 base_url 配置 —— 穿透场景下链接不再靠猜
用户指出 webui 实际是经 https://homeagent.jianfgit.xyz/ 穿透出去的,
应当支持配置 base URL。实测确认了这个诉求的正当性。

## 实测发现的约束(决定方案)

1. **子域形态在外部不可用**:`*.homeagent.jianfgit.xyz` 泛解析存在,
   但外层只给 `*.jianfgit.xyz` 通配证书 —— 该证书**不匹配三级子域**,
   实测 `huawei-smarthome.homeagent.jianfgit.xyz` 外部握手失败(HTTP 000)。
   外层只放行 `homeagent.jianfgit.xyz` 这一个 Host。
2. **路径挂载形态外部可用**:实测
   `https://homeagent.jianfgit.xyz/api/v1/device/online` → 200。
   所以「一个外部 Host + 路径挂载」是这条链路的正解,且已经工作。
3. 外层 nginx/WAF 会带 `X-Forwarded-Proto: https` 与 `X-Forwarded-Host`,
   因此即使不配置也能推出正确链接;配 base_url 则是显式兜底。

## 新增设置项 base_url

三级优先解析「对外入口」(resolveEntry):

  1. **配置项 base_url** —— 外部入口是部署事实,不该靠请求猜。
     经多层网关时请求可能带内网 Host,按它推导会拼出用户点不开的链接。
  2. **X-Forwarded-Proto / X-Forwarded-Host** —— 反代层给权威信息时可靠。
  3. **请求自身** —— 直连时的正确来源。

base_url 的主机名同时用作**子域反代的基域名**:入口是 homeagent.example.com
时,插件服务自然是 <标签>.homeagent.example.com。

服务清单另增 entry_url 字段,直接给出「外部入口是什么」,便于前端与排错。

## 生效点

- `/api/v1/proxy/services`:url / url_portal / base_domain / entry_url
- `/api/v1/device/gateway`:url / url_portal / http_url / host
- 两处原先各自推导 portalHost,现统一走 resolveEntry,避免再次漂移

## 部署后实测(生产,经真实外部入口)

  entry_url:   https://homeagent.jianfgit.xyz
  base_domain: homeagent.jianfgit.xyz
  remotedevice    | https://devices.homeagent.jianfgit.xyz
                  | https://homeagent.jianfgit.xyz/api/v1/device/   ← 外部可点
  huawei_smarthome| https://huawei-smarthome.homeagent.jianfgit.xyz

发现端点(外部视角):
  url_portal = wss://homeagent.jianfgit.xyz/api/v1/device/ws   ← 外部可连

## 判据

+3 条:TestBaseURLOverridesRequestDerived(内网 Host 场景下必须用 base_url)、
TestEntryPrefersForwardedHeaders(XFF 优先于请求自身)、
TestBaseURLTolerant(尾斜杠/空格容错 —— 手填配置最常见的两种手误)。

另更新一条旧判据的期望值:外部入口是 portal.example.com 时,子域基名应取
**实际入口**而非本机配置的 localhost(后者对远程用户无意义)。
2026-09-26 13:49:59 +08:00
a96ad9ca97 fix(webui): 服务入口 URL 端口必须恰好出现一次(生产部署后暴露)
生产部署后立刻暴露的真 bug:请求 Host 自带端口(实测 Host=127.0.0.1:8080),
而 url_portal 合成时无条件再追加监听端口,拼出
  http://127.0.0.1:8080:8080/api/v1/device/     ← 链接点不开

单测抓不到的原因:此前测试用的 Host 不含端口。真实服务器上 Host 一定带端口
(除非经 nginx 剥掉),所以这个 bug 必然出现在生产。

修法:抽出 portalHostWithPort(host, hostPort) 统一合成 ——
  - host 已含端口 → 原样(尊重调用方看到的真实入口)
  - host 不含端口 → 追加监听端口
两处调用点(服务清单 url_portal、发现端点 url_portal/url/http_url)共用它。

新增 3 条判据,都刻意用**自带端口**的 Host:
  TestPortalHostPortExactlyOnce(7 组输入,含带/不带端口、空值、无冒号端口)
  TestProxyServiceURLsWithPortInHost
  TestDeviceGatewayDiscoveryNoDuplicatePort
2026-09-26 13:49:59 +08:00
94c74b2ee6 fix(devicebridge): 修复设备反复掉线/静默失联 —— ping 路径断连 + bind 结果无人处理
用户要求全面修复「设备桥自动链接」这条链路上的问题。三个真实缺陷,
前两个是**服务端/客户端真 bug**(生产日志实证),第三个是我起初误判的。

## 缺陷 1(最严重):未 bind 时收到 ping → 服务端直接关连接

原实现:
    err := r.wsWriteLocked(curID, writePong)
    if err != nil { return }   // ← 关连接

而 conns 表**只在 bind 成功后才写入**(bind 前刻意不暴露连接给查询/命令
路径)。于是「握手完成、bind 尚未到达」这个窗口里来的 ping 找不到写入口,
函数返回错误,读循环 return —— 把连接关掉了。

生产后果(journalctl 实证):客户端每 30s ping 一次,只要有一次落在未 bind
窗口就断连。日志里同一设备 20 秒内多次 "ws connected",online/offline 与
输出通道注销/注册反复交替:

  17:29:14 ws connected → 17:29:17 ws connected → 17:29:24 ws connected
  → 17:29:29 → 17:29:35 → 17:29:40 online → 17:30:18 offline → ...循环

修法:pong 直接写本连接的 writer。此时该连接尚未进入 conns(没有 Push* 会
碰它的 writer),不存在并发写风险;已 bind 时才取写锁(Push* 可能正在写
同一 buffer)。

判据 TestPingBeforeBindDoesNotDropConnection 直打 bug 点(只握手、不发
hello/bind、发 ping、要求 pong),修复前报 `EOF`,修复后通过。

## 缺陷 2:bind_ack 的 ok 完全没被检查 → 失败静默失联

原实现(客户端):
    case "hello_ack", "bind_ack":
        log.Printf("... device=%v", msg["device"])

两处错:
- **取错字段**:服务端成功时回 {"op":"bind_ack","ok":true},没有 device
  字段,于是日志永远显示 `bind_ack device=<nil>`。这让我起初误判成"绑定
  失败",实际连接是好的(直连与经反代现象完全一致)。
- **不看 ok**:bind 被拒时服务端回 ok:false + error 并关闭连接,客户端既不
  报错也不重连,设备静默失联 —— TCP/WS 通但从未登记进网关。

修法:分别处理两种 ack;bind 判 ok,失败记原因并通知宿主。新增
Bridge.Bound() / BindError() / OnBoundState():**连接成功 ≠ 设备可用**,
只看连接状态的健康检查会给出假阳性。

判据 TestBindFailureIsObservable / TestBindStateCallback。

## 缺陷 3:-chat 一次性模式下桥存活时间过短

不是我最初以为的"bind 失败"。真因:`-chat` 走进 oneshot 后立刻 return,
触发 defer stopDeviceBridge(),桥只活几百毫秒,设备来不及完成 hello→bind。

修法:退出前等 bind 确认(最多 3s);bind 明确被拒则打印原因,不静默丢弃。

## 真实验收(隔离实例,命名 netns + 独立 data + 18080)

真 waiter 经**反代自动发现**连接,保持连接期间查询服务端:

  device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
  bind 成功,设备已登记
  /api/v1/device/online → waiter-mainserver, online=true, caps=[11 项]

长连接稳定性:70 秒(跨 2 个 ping 周期)三次采样设备始终在线,
无 read loop exit / bind rejected 日志。

## 附:反代通路本身的判定性对照

裸客户端(直接构造 hello/bind 帧)**经反代**与**直连 9890** 返回逐字节
一致(bind_ack ok=true、设备注册、online=true)。所以这条链路上反代
不背锅,问题全在 remotedevice 服务端与客户端自身。
2026-09-26 13:49:59 +08:00
7f5bf1670f feat(clients): 设备桥自动链接改用服务端发现 + 路径挂载(无 DNS 依赖)
配套 webui 反代改造:网关现在可由 HomeAgent 反代出去,客户端不能再靠
「门户地址同 host 拼 /api/v1/device/ws」猜地址——基域名与子域标签都是
**服务端配置**,客户端无从得知。

## 服务端:/api/v1/device/gateway 发现端点

客户端问「网关在哪」是唯一不会漂移的做法:子域标签可改(插件声明)、
基域名可改(webui.base_domain)、实例可换形态,客户端都不用跟着改。

⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令;
把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。令牌仍由客户端
自配。已有判据钉住「不得泄漏凭证字段」。

## ★ 实测发现:*.localhost 只有浏览器能解析

这是本轮最重要的发现,直接决定了设计:

| 环境 | devices.localhost 解析 |
|---|---|
| 浏览器 | ✓(RFC 6761 内置) |
| curl | ✓(内置特例) |
| getent / Go / Node | ✗(系统 nsswitch 是 files,dns,无 nss-myhostname) |

设备客户端(waiter / GUI 主进程 / 嵌入式固件)用的正是系统解析器。
实测 waiter 报「lookup devices.localhost on 192.168.2.1:53」。

因此**两处**设计变更:
1. 发现端点同时返回两种形态,并标 preferred:
   - url(子域)—— 浏览器用
   - url_portal(门户同源,同一 host、同一端口,走路径挂载)—— 非浏览器用,
     无任何 DNS 依赖
2. SDK 的 ProxyDecl 新增 **Path**(路径挂载前缀):让同一服务同时挂到
   门户自身 host 的路径下。remotedevice 声明 Path="/api/v1/device",
   设备客户端因此能沿用**它已硬编码的路径**,不需要知道反代存在。

路径挂载语义:请求路径**原样保留**(不剥前缀),上游按真实路径注册即可。
边界卡在路径分隔符上(/api/v1/device 不匹配 /api/v1/devicefoo)。

## 客户端

- **waiter**:新增 discoverGateway(),仅在用户配了门户地址时尝试,失败回退
  自配地址(老版本 HomeAgent 无该端点)。抽出 normalizeGateway() 纯函数,
  显式钉住「已带子域/完整端点的地址不得被改写」。
- **GUI**:renderer 新增 loadDiscoveredGateway(),renderDeviceChannel 优先用
  发现值、回退旧口径。顺带修掉此前插入函数时 anchor 不匹配导致调用点
  找不到定义的问题。
- **鸿蒙**:discoverGateway() + resolveGatewayUrl(),优先 url_portal。
- 三者都**优先 url_portal**(system resolver 的现实约束)。

## 遗留路由鉴权修正

`/api/v1/device/` 的旧路径反代原被 requireAPI 包裹 —— 但其调用方是设备
(带设备令牌而非门户凭证),套上门户鉴权会把它们全挡在 401(**真实实测**:
waiter 经此路径升级握手 401)。去掉这层包装,鉴权交给上游 remotedevice
自己的 requireToken,安全性不降级。

## 判据

webui +6 条、waiter +7 条。

★ 其中一条是**真实回归**:/api/v1/device/gateway 曾被 Path="/api/v1/device"
的路径挂载接走(那服务 auth=none),于是发现请求被转给上游、回 401,
客户端再也发现不到网关。修法是发现端点先于路径挂载判定,并补判据
(走完整生产链,同时确认同前缀的真实设备路径仍归反代)。

## 真实验收(隔离实例,命名 netns + 独立 data + 18080)

真 waiter 客户端 + 真 remotedevice 网关:
  device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
  device bridge active: waiter-mainserver authorized=true
  hello_ack / bind_ack 均经反代往返成功

说明:`bind_ack device=<nil>` 与在线列表为空的现象,**直连 9890 绕开反代
完全一致复现**,属 remotedevice 与 waiter 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
2026-09-26 13:49:59 +08:00
5d278cffdb fix(webui): 反代两处真实故障 —— 凭证头按 auth 区分 + Host 分发先于门户路由
两处都是**隔离实例上跑真实端到端**才暴露的,单测(用不校验凭证的假上游、
直接调 serveProxyHost)全绿却线上出错。记录在此以免重蹈。

## 故障 1:auth=none 路由的凭证被无条件剥掉 ⇒ 设备链路全 401

Rewrite 里原本无条件 Del("Cookie"/"Authorization"/"X-API-Key")。但
auth=none 的语义是"请求原样交给上游",凭证本来就是给**上游**的——
remotedevice 的接入令牌正是走 X-API-Key 传的。

实测症状:带设备令牌经反代访问 /api/v1/device/online → 401;
直连 127.0.0.1:9890 → 200。差异极难定位,因为两侧状态码语义相同。

修法:按路由 auth 分流。
- auth=homeagent:凭证是门户的,剥掉(避免泄漏给插件)
- auth=none:保留(上游要用)

复验:经反代与直连**逐字节一致**(HTTP 200 / 17B,cmp 相同)。

## 故障 2:插件子域被门户路由截走 ⇒ 401 且响应体是门户的 JSON

原实现把 Host 分发放在 mux 的 "/" 兜底里。但 stdlib ServeMux 是**最长前缀
优先**:任何更具体的模式都先命中。插件子域上的 /api/v1/device/online 被门户
为「旧路径反代」注册的 /api/v1/device/ 接走(requireAPI 包裹)→ 401。

判据(响应体格式)是定位关键:
  webui requireAPI        → {"error":"unauthorized"} 25B  ← 实际拿到
  remotedevice requireToken → "unauthorized" text/plain 13B
看响应体格式就能区分是谁拒的,比看状态码有效。

修法:Host 分发提为**最外层中间件**,包在整个 mux 之外,先于任何路径匹配。

## 顺带:消除判据与生产接线错位的可能

新增 Handler.Handler() 返回生产用的完整链(Host 分发 → 日志 → mux),
plugin.go 与测试共用同一条。本次踩过:测试自己组装 mux、中间件却挂在
plugin.go,判据全绿而线上 401;共享同一条链可结构性避免。
(写这条判据时还发现测试里 h.mux 为 nil 导致 panic——也正是这种错位的表现。)

## 新增判据 2 条

- TestProxyCredentialHeadersDependOnAuth:auth=none 必须转发上游令牌、
  auth=homeagent 必须剥掉门户凭证(两个方向都钉)
- TestProxyHostTakesPrecedenceOverPortalRoutes:走**完整生产链**,确认
  插件子域上的 /api/v1/device/online、/api/v1/status、/api/v1/plugins/ 都
  归反代;同时确认门户自身的 /api/v1/status 仍返回门户 JSON(没被反代吞掉)

## 真实验收(隔离实例:命名 netns + 独立 data + 端口 18080)

- 设备网关:令牌经反代 200,与直连逐字节一致;WS 升级 101
- 未声明子域:404 且错误信息含具体标签
- huawei_smarthome(用新 hmapdev 重打包、真装载):
  匿名 401 + 可操作提示;带门户 key 拿到真实 UI(9444B,
  <title>华为智慧生活管家);页面内根绝对路径 /api/status 正确透传
2026-09-26 13:49:59 +08:00
1e58af79d3 feat(webui): 通用反向代理 —— 插件声明服务,HomeAgent 按子域反代出去
用户要求:外部只装 HomeAgent 即可使用自带反代能力;用户只需穿透一个
webui 端口就能访问所有内部插件服务;认证与 WebSocket 支持都作为插件
的可声明项;插件 UI 要有可直接点击的入口。

实测 huawei_smarthome 插件的前端用**根绝对路径**(api('/api/status') →
fetch('/api/status'))。挂在 /p/<name>/ 这类路径前缀下,这些请求会打到
HomeAgent 自己的 /api/status —— 静默错路由;做 HTML/JS 内容重写对拼进
JS 字符串的绝对路径只是"按概率能用",会产生"页面能开、某个按钮就坏"的
静默故障。子域路由下根路径天然正确,**插件前端零改动**。

且它天然匹配"只穿透一个端口":webui 监听 0.0.0.0:8080 按 Host 分发,
外层 frp 单端口 TCP 隧道**一行都不用改**。

默认基座 localhost:RFC 6761 规定 *.localhost 强制解析到 loopback,
现代浏览器原生支持 ⇒ <标签>.localhost:8080 **零配置可用**,不需要 DNS、
证书、/etc/hosts。远程部署改 base_domain 即可。

- 外部插件 → plugin.json 的 proxies(静态可发现:插件没起来也能报
  "声明了 ui 但目标不可达",而不是静默 404)
- 内置插件 → s.DeclareProxy()(remotedevice 是内置的、没有 plugin.json,
  却最需要被反代出去)

反代层在 webui 侧读清单:webui 已能拿到插件目录(PluginManager.PluginDir),
因此**无需给内核接口加方法**。manifest 解析忽略未知字段,加 proxies 对
"旧内核读新插件"与"新内核读旧插件"都无害。

新增 sdk/ProxyDecl 与配套校验(ValidProxyAuth / ValidProxyHostLabel /
NormalizeProxyHost / ValidateProxyDecl);新增运行期 ProxyDeclarer 通道。
hmapdev 的 writePluginJSON 是**白名单 map 重建**——不同步加字段会让声明
被打包静默丢弃(插件作者本地正常、装上去失效),因此 PlgConfig 与
writePluginJSON 同时加,并在打包前校验声明(插件作者本地就能发现写错)。

auth=homeagent(默认,安全的默认):门户会话 / X-API-Key / ?__token=;
auth=none:信任上游自身鉴权,供设备与嵌入式客户端使用——它们不可能持有
浏览器会话,强制走门户鉴权会把设备链路挡死。remotedevice 声明 none,
因为它自身用 ws_token 强制校验。

未声明时升级请求**明确拒绝**(400 + 原因),而不是静默降级成普通请求
(后者表现为前端不断重连、日志看不出原因)。

1. 不跟随上游 3xx:旧实现用 http.DefaultClient(默认跟最多 10 跳),
   上游 302 到内网地址时反代自己跟过去、失败回 502 并把内网 URL 泄给
   客户端。httputil.ReverseProxy 默认不跟随,3xx 原样透传。
2. 逐帧 flush:旧实现 io.Copy 导致上游流式响应被缓冲到上游关闭才下发
   (实测 3 帧 200ms 间隔的流,客户端在 +600ms 一次性收到全部)。
   设 FlushInterval=-1。

另补齐 X-Forwarded-For/Host/Proto(旧实现完全不注入,上游无法判断真实
来源),并剥掉上游 Set-Cookie 的 Domain(防止插件 cookie 打到主门户域)。

插件页新增「服务入口」卡片:列出全部被反代的插件服务(含被拒条目与
不可达原因),点「打开」直接访问。链接带 ?__token=<api_key>,因为子域
与门户不同源、浏览器不会自动带会话 cookie。

webui +35 条、SDK +4 条、工具链 +4 条。关键几条:
- 根绝对路径必须原样到上游(选 Host 路由的核心理由)
- 上游 302 必须原样透传、且反代不得跟随(旧缺陷)
- 已知 Content-Length 的慢速响应必须逐帧到达(**这条经过变异验证**:
  把 FlushInterval 改回 0 后判据挂死 → FAIL,还原后回绿。
  说明:最初写的 SSE/chunked 版本是假判据——ReverseProxy 对
  text/event-stream 与 ContentLength=-1 会自动立即 flush,与
  FlushInterval 无关,变异抓不到,已改正)
- 子域标签冲突不得静默覆盖(后者保留可见并带原因)
- 非法声明不进路由但必须可见(配置页要能看到原因)
- 未声明 websocket 的升级请求必须 400
- auth 逐条生效:none 放行匿名、homeagent 与默认档 401 且给可操作提示
- 自动发现:显式 host 不得被自动编号覆盖(**测试抓到的真 bug**:
  remotedevice 声明的 "devices" 会被改成 "devices-2" 而静默失效)
- 真实端到端:生产实例 huawei_smarthome 的 UI(9444 字节)与其
  /api/status 经反代正确透传

go build ./... 通过;相关包全量测试通过。
internal/plugin/proc 的 TestStreaming_PublishLatencyFlatAcrossSubscribers
是**预存在的不稳定测试**(同一份代码 10 次跑 9 过 1 败,且本改动完全
未触及该包),非本次引入。
2026-09-26 13:49:59 +08:00
17e7094967 fix(plugins): 修三处端口/监听缺陷 + 让测试用临时端口(消除既有 flaky)
排查内核 SIGSEGV 时用 A/B 对照(我的树 20 轮 vs 干净树 20 轮)确认了
两条**既有** flaky,与 C 化改动无关。本提交把它们修掉。

## 缺陷 ①(真 bug,不只是测试卫生):pluginmgr 监听地址是包级可变全局

`var HTTPAddr = "127.0.0.1:9876"` 是包级可变全局,`Start()` 还把 settings 读到的值
**反写**回它,`startHTTPServer` 再读它。后果:
  - 多实例互相污染:后启动的实例把地址写进全局,先启动那个读到的是**别人的**地址
    (实测与生产 homed 抢 9876)
  - 全局读写无同步,属数据竞态

修法:改为实例字段 `p.httpAddr`(默认走 `const defaultHTTPAddr`),
不再有可被任意代码改写的包级状态;并新增 `HTTPURL()` 访问器。

## 缺陷 ②:remotedevice 用 ListenAndServe,监听失败静默且 :0 无法回报端口

`p.server = &http.Server{Addr: p.addr}` + `ListenAndServe()` 在后台 goroutine 里报错,
端口被占时只打一行日志、`Start()` 仍返回 nil —— 插件表面「已加载」而网关根本没跑。
且 `:0` 下拿不到真实端口。

修法:改为 `net.Listen` + `Serve`(与 webui/pluginmgr 同形):
  - 监听失败**同步**返回,交给加载器
  - 用**实际绑定**地址回写 p.addr,日志与诊断面显示真实端口

## 测试侧:全部改用 :0,不再抢固定端口

新增 `ConfigRegistry.SetPluginConfig(name, key, value)`:插件表原本只在
`RegisterDef`(插件 Start 时)创建,导致「想在插件加载前预置配置」无从下手
(直接 Set 会因表不存在而失败,错误常被忽略)。新方法先建表再写,填补该时序缺口。

`setupIntegration` 在 `Load()` 前预置:
  - pluginmgr.http_addr / remotedevice.listen_addr → `127.0.0.1:0`
  - webui 走已有的 `SetListenOverride("127.0.0.1:0")`(它有独立旁路)

实测三个插件现在各自绑到 OS 分配的空闲端口(41895 / 35855 / 34021)。

## 缺陷 ③:deepsearch 测试把「上游限流」当成功能回归

`TestRealPlugin_DeepSearchInvoke` 的断言会在上游限流时失败,但插件此时返回的是
**正常结果**(err==nil,content 含 "未返回结果" 与无响应引擎列表)——那是外部条件。
实测失败信息:`brave(Suspended: too many requests), duckduckgo(CAPTCHA), google cse(...)`。

更糟的是它**不可控地随机红**:干净树连跑 20 轮复现 2 次,与代码改动无关。
这种判据会让真正的回归淹没在噪声里。

修法:区分「上游不可用(限流/CAPTCHA)⇒ t.Skip 并说明理由」与
「其他异常 ⇒ fail」。不用静默 return,避免环境退化时判据无声失效。

## 由此发现并修掉的真缺陷:监听地址被硬编码在三处

`127.0.0.1:9876` 曾硬编码在 pluginmgr / cli / webui 各一份。cli 与 webui 后来改为
运行时读 `pluginmgr.http_addr` 设置(本次核实),pluginmgr 自己却仍是全局 —— 三处
口径现在统一为「读设置 + 实例字段」。

## 验证

- 新增 `TestTwoInstances_ListenIndependently`(pluginmgr):两个实例同时监听、
  各自 HTTPURL 指向自己端口、两个地址都真的可连。
  ★ 经**忠实变异**验证有牙:复原「包级全局 + Start 反写 + 读全局」后该测试判红
  (我第一版测试只断言字段不共享,变异证明它没牙,已重写为端到端判据)。
- `internal/plugins` 连跑 **30 轮:30/30 全过**(修复前干净树 18/20)。
- 全量连跑 3 轮:38 ok / 0 FAIL / 0 bind 冲突。
- go build ./... / go vet ./... 干净。
2026-09-25 17:57:15 +08:00
0c9a3900b8 fix(release): .hmap 纳入发布产物白名单 + 路径解析
## 白名单(真问题)

`upload_assets.py` 的 ARTIFACT_SUFFIXES 只有 .tar.gz/.zip/.deb/.rpm/.pkg/_win64.exe,
**没有 .hmap** —— 即使插件包已经构建好放在 dist/ 下,上传时也会被静默跳过。
这正是「release 里一个插件包都没有」的直接原因之一。

补 `.hmap` 与 `SHA256SUMS.plugins`(插件包的汇总校验和,与内核包的 SHA256SUMS 分开,
避免混用)。实测 is_artifact() 现能正确识别两者、仍跳过 README.md。

## 测试路径解析

`HMAP_BUNDLE_DIR=dist/plugins` 这种相对仓根的写法原先会失败:测试的 cwd 是包目录
(internal/plugins/pluginmgr),相对路径解析到包内,报 "no such file or directory",
看起来像产物不存在。改为相对路径按仓根解析(向上找含 go.mod 的目录)。

实测三种调用都正确:相对路径、绝对路径、不设时 skip。
2026-09-20 09:07:33 +08:00
a35f2126a1 test(pluginmgr): 校验发布用插件包能被内核真实安装
配套 SDK 仓新增的 scripts/build_plugin_bundles.sh:**能构建出来 ≠ 内核装得上**,
这个测试用内核自己的 extractPackage 把产物真解一遍,验证三种包形态都落成规范入口。

覆盖的三种形态(都由真实产物验证过):
- 多平台 bundle:`plugin.bin.<os>.<arch>` → 按当前平台挑出并**重命名为 plugin.bin**
- 单平台包(qq 的 plg.json 是 bundle:false):只有 `plugin.bin`
- Lua 包(luademo):入口是 `main.lua`,不编译 Go

不设 HMAP_BUNDLE_DIR 时 skip(不作为常规 CI 的必跑项,避免依赖 hmapdev 工具链):

    HMAP_BUNDLE_DIR=/path/to/plugins go test ./internal/plugins/pluginmgr/ \
      -run TestBuildPluginBundlesInstallable -v

实测 21 个真实产物全部通过(含 Lua 与单平台两种非 bundle 形态)。
2026-09-20 09:02:42 +08:00
01113664b4 fix(obs): 抢占日志改在判决点打(修自伤)+ scheduler 事件接进 SSE
## 修我上一版的自伤

上一版把 preempt 日志打在 `executeNewTask`,但那时 `nextRef` 已经把
`s.running` 换成了抢占者自己,于是输出成了

    preempt start: task#2 ... -> victim task#2 (cli)

victim 打印的是入侵者本人。判据必须落在 `registerInterrupt`——那一刻
running 还是真正的受害者。改为在抢占判决点打:

    [agent] preempt: task#2 class=interrupt level=3 from cli (L3) preempts task#1 class=queued level=0 (qq)

## 补上「入队而非抢占」的日志

中断到了却没生效,此前完全不可解释。现在两种成因分开写:

    [agent] interrupt queued: ... vs ... (qq) — running in critical section; queue=N
    [agent] interrupt queued: ... vs ... (qq) — preempt cooldown; queue=N
    [agent] interrupt queued: ... vs ... (qq) — level insufficient; queue=N

没有这条,`interrupt from X` 打过之后任务为什么没让位就只能猜。

## scheduler 事件接进 SSE

`EventScheduler` 此前既不在 `handler_chat.go` 的 subTypes、也没有任何订阅者
(全仓 grep 零命中)——内核里 suspend/resume 只 publishEvent,于是事件发出来
就掉地上,对内对外都不可见。加进 subTypes 后前端/客户端能看到抢占链。

## 验证

- preempt_logging_test.go 增一条:victim 与入侵者必须是不同来源(qq vs cli),
  且排队输入对排队任务 `canPreempt` 必为假。
- 实测输出含 `cli (L3) preempts task#1 class=queued level=0 (qq)`。
- 全量 `go test ./internal/... ./cmd/...` 与 `go vet ./internal/...` 全绿。
2026-09-19 11:57:50 +08:00
ccc2ac2d4d fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML
两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。

## 症状(实测取证)

1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200
   `{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。
2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。

## 根因

停止按钮走的是「空内容中断」,而 interceptLoop 有一行
`if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。
所以停止指令从未到达调度器;接口那个 200 是不诚实的。

另查明两条会放大症状的既有问题(停止后仍在跑):
- `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。
  上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。
- `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。
  这是给「被更高中断抢占」用的(现场要交出去、稍后继续),
  但用户按停止是「不要了」,重跑就是停止没生效。

## 修法(按用户明确的设计)

停止 = ①立即结束当前 LLM 推理(不重试、不恢复);
②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。

- scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/
  `consumeCancel`。配额取快照值(停止后新到的输入不受影响),
  重复按停止取 max 不累加(两个客户端同时按不该翻倍)。
- `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM;
  **纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。
  带注释的停止(`/stop 换个话题`)仍走中断路径。
- `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。
- `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。
- `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是
  Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退,
  否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。
- WebUI handler / CLI `/stop`:空消息时带 `stop:true`。

## 鸿蒙端

- `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名,
  避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。
- `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络),
  纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容,
  Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。
  注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。
- `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明,
  ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。

## 验证

- 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、
  配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。
- `go test ./internal/... ./cmd/...` 全绿。
- 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受
  READ_PASTEBOARD 授权限制装不上,与既有记录一致)。
2026-09-18 11:26:12 +08:00
1f5af1dccf feat(cli,ohos): 补齐两处能力缺口——CLI /memory context|tools、鸿蒙 camerasue 录像回传
核对「CLI 与 WebUI 插件能力对齐」时发现两个此前遗漏的缺口,一并补齐。

1) CLI /memory 缺 context 与 tools(internal/plugins/cli/plugin.go)
   WebUI 有 GET /api/v1/memory/context 与 /api/v1/memory/tools,CLI 只有
   query/graph/text。IndexerAPI 本就对内部插件开放(BuildContext/
   FormatContext/GetToolDefinitions/BuildToolPrompt),不是 SDK 缺口。
   补上后与 WebUI 同源:context 打印「实际会注入什么上下文」,
   tools 打印工具定义 + 工具提示词。
   waiter 同步接上两条远端路由与 help 文案。

2) 鸿蒙 camerasue 录像(BridgeCaps/BridgeRouter/DeviceBridge.ets)
   此前 `camerasue <N秒>` 直接返回「暂不支持录像回传」。查 SDK 后发现
   cameraPicker 本身就有 PickerMediaType.VIDEO 与 PickerProfile.videoDuration
   —— 录像完全可行,只是回传通道没接。
   现改为:VIDEO 模式取回 mp4,经 DeviceBridge.sendDataChunked 按
   cmd_data_start/分块/cmd_data_end 回传(与 GUI/CLI 录像路径一致),
   网关聚合后落盘成文件、agent 拿路径;照片仍走小体积 base64 内联。
   上限 64MB、时长 1~300s,超限明确报错而不是把 WS/上下文撑爆。

   依赖方向处理:BridgeCaps 需要「往本请求回传字节」,但 DeviceBridge 为取
   CapResult 已 import BridgeCaps,反向 import 会成环。改为 BridgeRouter
   注入 DataChunkSender 回调(它同时持有 deviceBridge 与 reqId),
   BridgeCaps 不碰 socket。新增 CapResult.chunked 标记「结果已由能力分块
   发完」,DeviceBridge 据此不再回 cmd_result,避免网关把已完成请求与后续
   分块错配。

验证:hvigor assembleHap BUILD SUCCESSFUL(ArkTS 编译通过,改动文件零告警);
make check-client-versions 一致;go vet 干净;全量 go test ./internal/... ./cmd/... 零失败。
2026-09-18 10:04:53 +08:00
9de3b365a6 fix(agentcli): 修 4 个真实缺陷——停机死锁、超时泄漏、僵尸堆积、孙进程逃逸
jianf 提示 agentcli 可能有问题,系统性审了一遍(含 -race 与线上实证),
确认并修复 4 个互相叠加的真实缺陷,每个都配了「去掉修复即失败」的回归测试。

1) 停机/热重载死锁(plugin_stop_test.go)
   Stop() 先 p.wg.Wait() 再 Close 终端,而 readLoop 自己也记在 p.wg 上、
   只监 t.stopCh 不监 p.stopCh。只要有一个终端开着,wg.Wait() 就永不返回。
   后果:插件卸载/热重载(StopAndUnload/ReloadOne)与停机全挂死,且
   registry 持锁时是整个内核一起挂。
   修:先关活跃终端(move 出 map 后在锁外 Close),再 wg.Wait();
   readLoop 顶部加 p.stopCh 探测;Stop() 用 sync.Once 保证幂等。

2) 终端超时后资源全泄漏(plugin_lifecycle_test.go)
   readLoop 的 IsExpired 分支只 delete(sessions) 后 return,既不 Kill 也不
   Close。终端已被移出 sessions,cleanupLoop 也再看不到它,进程/PTY fd/
   reader 协程无人回收。实测:timeout=1s 的 sleep 300 超时后进程仍在跑。
   修:readLoop 加 defer releaseResources(),保证「只要退出就释放」。

3) 子进程从不回收 → <defunct> 僵尸堆积(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 只 Start 从不 Wait。线上实测 homed 名下已有一个
   [sh] <defunct> 僵尸子进程。
   修:linuxPty 加 Wait()(sync.Once 保证只 Wait 一次),
   releaseResources 通过可选接口 Wait() error 调用(Windows ConPTY 不实现则跳过)。

4) Kill 只杀直接子进程,孙进程逃逸(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 用 Setsid,sh 是新进程组领头,真正的命令(sleep/vim)是
   其孙进程且同组。只 Kill(sh) 会留下孤儿继续跑。实测:`sleep 300; echo done`
   只杀 leader 后 sleep 仍在(被 init 收养)。
   修:改为 syscall.Kill(-pid, SIGKILL) 杀整个进程组,失败再回落单进程 Kill。

测试设计要点:回归用例必须让「sh 保留为父进程 + 孙进程显式 trap "" HUP」,
否则单个 sleep 会被 sh exec 掉、关 PTY 的 SIGHUP 又会顺手带走孙进程,
两个缺陷都测不出来(这两种情况都实际踩过并修正了用例)。

全量 go test ./internal/... ./cmd/... 通过,agentcli 单包 -race 通过。
2026-09-17 20:24:03 +08:00
ec13eb391a fix(agentcli): 终端退出前补推残留输出,短命令输出不再丢失
线上验证「内核开、两个插件接」时发现的真实缺陷:`echo`、`ls` 这类在首个
200ms ticker 之前就结束的短命令,readLoop 走到 `!terminalRunning(t)` 分支
直接 return,残留在 stream 里的输出从未 flush。

症状:输出只留在 session.buf 里——agent 用 terminal_read 能看到,但
terminal_output 事件永远发不出去,于是内核权威视图(以及 WebUI/CLI 的
/terminals)的 output 恒为空。实测 term_2(echo HELLO_KERNEL_REGISTRY)
在 /terminals 里 output="" 而 agent 同期 terminal_read 拿到了正文。

修法:
- 抽出 flushTermStream(s, t),ticker 与所有退出路径共用同一条推送路径
  (避免以后再出现「某条退出路径忘了 flush」)。
- readLoop 顶部加 defer:defer flushTermStream 后于 defer emitTermState
  声明 → LIFO 下先 flush 再报停止,保证「最后一段输出」先于 running=false
  到达订阅者。

测试:plugin_flush_test.go 新增 TestReadLoopFlushesOutputOnExit——走
EventBus 捕获事件,推入输出后立即让进程退出(远早于 ticker),断言输出
已补推且末态 running=false。已验证去掉修复即 FAIL、加回即 PASS。

注:handleRead(clear=true) 会主动 Reset stream(避免与读取结果重复),
属既有设计;本修复针对的是「未被读取就退出」的路径。
2026-09-17 20:00:47 +08:00
e70d2171ee refactor(terminal): 内核开终端/命令历史权威视图,WebUI 与 CLI 都改接内核
按「内核开,两个插件接」重构终端与命令历史的数据归属。

背景:此前 WebUI 与 CLI 各订 EventToolCall/EventTerminalOutput 攅一份状态,
同一件事两份推导,还各自踩过同一个坑——工具 result 是 Go 的 map 文本
(map[cols:80 ... id:term_2 ...]),断言成 map[string]interface{} 永远失败,
terminal_create 的 id 回填不生效,/terminals 因此恒空(WebUI 也一样)。
实测确认:WebUI 自己的 /api/v1/terminals 与 /api/v1/cmd/history 同样是空的。

内核开(权威唯一真相):
- internal/agent/core/terminal_registry.go:TerminalRegistry 归并两类事件——
  EventToolCall(terminal_create/close、cmd_run,id/command 从 args 或 Go map
  文本回填)与 EventTerminalOutput(agentcli 生命周期 + 输出,含 64KB 缓冲上限、
  100 条命令历史、50 个终端上限)。
- internal/sdk/terminal.go:新增 TerminalAPI(ListTerminals/CmdHistory)与
  TerminalStatus/CmdExecStatus DTO。**不塞进 KernelStatus**:那是全量快照,
  前端每 3 秒轮询 /kernel,背上每终端最多 64KB 输出会让轮询成本爆炸;
  终端输出是按需拉取的明细,另开接口。
- Agent 订阅自己的事件总线(subscribeTerminalRegistry),且**只根 agent 建**
  (驻留子共用同一总线,每个子都建会 N+1 份重复记账)。
- SDKConfig/Registry/bootstrap 接线:pluginReg.SetTerminalAPI(agent)。

生产者补全(agentcli):终端无输出时 ticker 不发事件,内核就无从知道终端
存在。新增 emitTermState,在 handleCreate/handleClose/readLoop 退出(超时/
进程结束/读取错误/stopCh)显式上报 running 状态,并给输出事件补 command 字段。
handleClose 改为接收 *sdk.PluginSDK 以便上报。

两个插件接(消费方):
- WebUI:删掉本地 termStates/cmdHistory/subscribeTerminalStream/handleToolEvent
  及不再使用的 getStr;/terminals 与 /cmd/history 直接读 s.Terminal()。
- CLI:删掉上一轮刚加的 subscribeToolEvents 与 cliTermState/cliCmdExec;
  /terminals 与 /cmd/history 直接读 s.Terminal()。两条路(local/remote)都通。

测试:新增 terminal_registry_test.go,锁死 Go map 文本解析(旧缺陷根因)、
生命周期、CLI 直调路径(无 EventToolCall 仅凭 output 事件建条目)、历史与
终端数量上限。全量 go test ./internal/... ./cmd/... 通过。
2026-09-17 18:56:15 +08:00
3cce605722 feat(cli): /terminals、/cmd/history、/terminal 对齐 WebUI(事件面 + ToolAPI,无需新接口)
去看了一遍源码,纠正上轮判断:终端与命令历史也不是 WebUI 插件私有。
- 终端会话:agentcli 插件持有,发 EventTerminalOutput;WebUI 只是订阅该事件
  自己攒视图。命令历史:WebUI 订阅 EventToolCall 的 cmd_run 攒的。
- 于是 CLI 插件订阅同样两个事件即可同口径:/terminals、/cmd/history。
- 开/写/读/关终端:SDK 的 ToolAPI.ExecuteTool 已允许跨插件调用工具,
  CLI 直接调 agentcli 的 terminal_create/write/read/close,新增 /terminal 子命令。

waiter 侧同步:/terminals、/cmd/history 两条路(local/remote)都接;/terminal
仅在 local 可用(远端 WebUI 无对应 REST 端点,明确提示而不是当聊天发出去)。
2026-09-15 15:31:32 +08:00
0227fc2d4d feat(cli): /persona 与 /agents 对齐 WebUI(复用已开放的 SDK 面)
- /persona:读写 core.agent.personal_prompt / core.internal.persona_initialized;
  插件 SDK 的 Settings() 满足 internal/config.PersonaKV,与 WebUI 同一实现。
  GET 等价返回 initialized/current_prompt/file_override;/persona set <mode> [内容] 写。
- /agents:改用 supervisor.ListAgents()(WebUI /agents 同源),此前只回一个
  agent_id、驻留子信息全丢。
- waiter 侧 /persona 在 local/remote 两条路都接上,/help 补齐。
2026-09-15 15:25:20 +08:00
91c25fa536 feat(cli): /runtime 接上(runtime 本就经 KernelStatus 对内部插件开放)
纠正上一轮的判断:runtime 不是“SDK 未暴露”。s.Status().GetKernelStatus()
里的 Scheduler / Residents / Channels / InputChannels 就是 WebUI /runtime
的数据源,内部插件同样拿得到——CLI 只是漏接了这条命令。

- CLI 插件新增 /runtime,输出与 GET /api/v1/runtime 同口径。
- waiter 侧 /runtime 在 local(发 CLI 插件)与 remote(走 REST)两条路都接上,/help 补齐。
2026-09-15 14:29:29 +08:00
bcaa8f3f31 feat(cli): /plugin install 真正可用(直连 pluginmgr 回环端点,与 WebUI 同实现)
原来 CLI 的 /plugin install 只打印“请去 WebUI”。插件安装逻辑在 pluginmgr
插件里(回环 HTTP,默认 127.0.0.1:9876,无鉴权),WebUI 也是转发到它;
CLI 插件改为直连同一端点,能力对齐。
2026-09-15 12:17:34 +08:00
5333a33e20 feat(cli): CLI 插件能力对齐 WebUI(memory/knowledge/config/tracker/adapters/network)
原来 CLI 插件只覆盖 WebUI 的一小部分:/memory 只有 query、/knowledge 只有
list、没有 config/tracker/adapters/network,结构化输出还各拼一套文本格式。

按 WebUI 的 REST 面对齐:
- /memory query|graph|text [n]     (对应 /memory、/memory/graph、/memory/text)
- /knowledge | delete <name> | stats(对应 GET/DELETE /knowledge)
- /config                          (对应 GET /config)
- /tracker | rollback              (对应 GET /tracker、POST /tracker/rollback)
- /adapters | remove <name>        (对应 GET/DELETE /adapters)
- /network                         (对应 GET /network)
- 统一 writeJSONContent:结构化数据一律缩进 JSON,与 WebUI 同口径。

waiter 侧同步:把上述命令在 local(发 CLI 插件)与 remote(走 REST)两条路
都接上,/help 补齐;两条路语义一致。
2026-09-15 12:14:25 +08:00
bc32fbbc98 feat(webui): 阶段管道的工具格改为滚动展示最新一条调用
「工具」是循环格:一轮里可能调几十次工具/输出通道。此前每一次都追加成
chip,这一格被撑成一长条,反而看不出「现在在调什么」。改为固定一行的
滚动视口——只留最新一条,右侧给出本轮累计次数;新调用到来时旧条向上
滚出、新条滑入(morph 就地改文本不会重放 CSS 动画,故摘类 + 强制 reflow
+ 重加类)。并给 chip 名称加 .rt-chip-t 承接省略号,窄框不再硬切半截。

配套 TestStagePipelineToolCellShowsLatestOnly 钉住该口径。
2026-09-14 20:07:29 +08:00
0faf9fb4e8 style(webui): 队列/管道列宽下限收到 100px,消掉「4 个 + 1 个」孤行
118px 时容器 540px(小窗口侧栏展开的宽度)只放得下 4 列,第 5 个框
落到第二行且后面四个位置全空 —— 正是「看着空」的那种观感。
收到 100px 后 540px 也能一行放下 5 个;配套给 .rt-qmeta 加 wrap,
窄框里「登记/抢占」两枚迷你条换行而不是撑破框。

五档实测(1400/1100/900/700/480):1400/1100/900/700 都是 5 框一行
(200/140/100/120px),480 为 3+2;均无横向溢出,框内元素无越界。
2026-09-14 18:45:36 +08:00
f89e57a732 feat(webui): 阶段管道与中断队列统一为等大表框,字体加大加粗
问题:阶段管道是「小圆点 + 一条连接线 + 9px 小字」,中断队列是五行
「名字 | 进度条 | 元数据」的扁条 —— 两块都远小于旁边的 KPI 框,中断队列四级
全为 0 时四行几乎全是空白,既占高度又难看。

改法:两块统一成同一套视觉语言 —— **等大表框**(与 KPI 同一种骨架)。

阶段管道(.rt-pipe-row / .rt-pipe-cell)
- 5 个等大框,框内 = 图标 + 阶段名 + 本阶段本轮发生的事件 chip。
- 阶段名 9px/500 → 13.5px/700;图标 12px → 15px。
- 当前阶段整框点亮(accent 描边 + 淡底 + 内阴影),不再靠一个小圆点表意。
- 删掉圆点、连接线、滑块把手那套已死的 CSS(.rt-pipe-track/.rt-pipe-knob 等)。

中断队列(.rt-queues / .rt-qcell)
- 五行扁条 → 5 个等大框(L4/L3/L2/L1 + 排队),一行排开。
- 框头级别名 16px/800、深度数字 26px/800(原来深度只是行末一个小数字)。
- 级别色同时用在框头、点亮格槽、有积压时的整框描边 —— 一处配色贯穿。
- 排队队列无级别,用虚线框与四级中断区分(另一**类别**,不是另一优先级)。
- 保留可见格槽:0 时也有形状,不会变回一片空白。

列宽自适应:两块共用 repeat(auto-fit, minmax(118px, 1fr)),
118px 而不是 150px 是为了让 640–740px 容器(窄屏侧栏收起后的宽度)也能
5 个框排一行,不出现「4 个 + 1 个」的孤行。chip 补 min-width:0 以免撑破窄框。

顺带清掉一条无用的旧 .rt-chip 规则(与新规则重名且只被阶段事件用到)。

实测(现网 CDP,1400/1100/900/700/480 五档):
- 1400/1100/700px:5 框一行(200px / 140px / 120px);900/480px:换行且框仍等大
- 五档均无横向溢出
- 字号:阶段名 13.5px/700,级别 16px/800,深度 26px/800
- 注入一轮轨迹:输入=输入|行动=思考|工具=qq_get_message x3 qq*|输出=生成|结束=完成
- 注入 L3=3/排队=2:L3 描边 rgba(255,166,87,.55)、框头与点亮槽同为琥珀色;
  排队绿框;空的 L4 保持默认描边(首次读到的默认色是 0.25s 过渡中途,非 bug)
- chip 未溢出所在框;总览/侧栏/顶栏渲染文本无 emoji
2026-09-14 18:38:08 +08:00
37924b295b feat(webui): 总览底部源码区改为独立的「开源许可」框(协议 + 全文 + 源码)
此前总览底部只在 KPI 卡里挂了一行小链接(.ov-foot),既看不出受什么许可
约束,也看不出 AGPL 网络服务场景下的义务。现在单独成一张卡:

  开销许可
    许可协议     AGPL-3.0-only   → GNU 官方全文
    源码仓库     <source_url>    → 仓库
    网络服务条款(§13):把修改后的版本作为网络服务对外提供时,
                        必须向使用者提供取得对应源码的途径。

内核侧(License 是新事实,不能只靠前端写死):
- internal/meta:新增 License(SPDX 标识)与 LicenseURL,都可 -ldflags 覆盖。
  LicenseURL 默认指向 GNU 官方 AGPL-3.0 全文页 —— 与仓库托管方、分支名、
  文件路径都无关,换仓库/换分支不会失效。
- internal/sdk/status.go 的 BuildStatus:新增 License / LicenseURL 两个
  json 字段(additive,旧消费方忽略未知字段即可)。
  ❗注意 internal/sdk 不受公开接口冻结约束(docs/git-branching.md §六),
  本次未触碰 third_party/homeagent-sdk/sdk/。
- internal/agent/core/status.go:从 meta 填充。

前端:
- 骨架里 .ov-foot 换成独立的 <div class="card" id="ov-legal">(放在 KPI 卡之后)。
- 网络条款那一段按许可标识是否含 AGPL 决定是否渲染,不硬写协议名。
- 内容对一次构建是常量,沿用 __html 比对,填一次后不再重建(不引入闪烁)。

验证(现网 1400x920,CDP 实测):
- /api/v1/kernel 的 build 现在带 license="AGPL-3.0-only"、
  license_url="https://www.gnu.org/licenses/agpl-3.0.html"
- 卡片为真框:class=card、border 1px、radius 14px;总览结构 = rt-panel | card | ov-legal
- 两个链接均为真 <a>,target=_blank + rel=noopener noreferrer
- updateOverview() 再跑一次,卡片子节点身份不变(不重建、不闪)
- 回归:KPI 版本副行、阶段管道 5 节点/5 列/6 SVG、队列 5 行×5 格 均正常
- 无横向溢出;总览/侧栏/顶栏渲染文本无 emoji
- go vet 干净;agent/core、plugins/webui、plugins 全量测试通过
2026-09-14 17:48:54 +08:00
bead5746c3 feat(webui): 总览显示内核身份、图标全 SVG 化、队列改格槽、阶段管道下方按阶段列事件
四个问题一起改(都出在总览/内核页的展示层,不动内核逻辑):

1) 内核版本不再"看不见"
   - 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩一个 "v1.4.0",分不清
     是哪个内核、哪次构建。现在 KPI 值给版本号,下面补一行副行
     「HomeAgent · <commit>」(.ov-sub)。
   - 内核页此前**完全没有构建信息**,现在补一张「构建」卡:内核名/内核版本/
     Commit/构建时间/SDK 兼容/源码链接(AGPL §13 的入口页)。

2) 任何位置都不再用 emoji/符号字符充当图标
   - 新增 RT_ICO(纯内联 SVG,24x24 / currentColor),替换:阶段节点的循环
     标记(原 ↻)、轨迹 chip 的工具/输出标记(原 ⚙/⇥)、"立即运行"(原 ⚡)、
     工具卡与思考卡的下拉箭头(原 ▾)。
   - CSS 注释里的同类字符一并去掉。

3) 队列不再"空着只有文字"
   - 原来画的是宽度百分比进度条:深度为 0 时宽度就是 0,五行只剩文字。
     改成 rtSlots 的「车位」式格槽(至少 5 格、最多 16 格,按全场最大深度
     缩放),0 时仍有可见形状,占用多少一眼可数;超出格数时给 +N。
   - 修掉一个真实的 DOM 结构错误:第五条「排队」队列被写在 .rt-levels 闭合
     **之后**,且后面多一个 </div>,多出来的闭合标签会提前关掉祖先节点、
     把整块布局撞歪。现在它回到容器内。

4) 每一步管道的事件显示在管道下方对应阶段列里
   - 原来是一条拍平的 chip 序列,看不出"这件事发生在哪个阶段"。
     现在 .rt-pipe-cols 与上面的阶段节点共用 5 等分栅格,事件按 g(阶段组)
     分列落位;实测列中心与节点中心偏差 ≤ 2px。
   - 轨迹覆盖全部阶段(输入/思考/工具/输出/完成),同阶段重复的同一条
     累加 ×N 而不是刷屏(rtTrailPush)。空列显示一个弱化的「无」。

验证(现网 1400x920,CDP 实测):
- 版本 KPI = v1.4.0+hotfix.0fd4fb1 / HomeAgent · 0fd4fb1;内核页构建卡齐全
- 注入一轮轨迹:col0=输入 col1=思考x2 col2=qq_get_message x3/qq/cmd_run
  col3=生成x2 col4=完成,active 节点=工具,×N 计数正常,3 个 chip SVG
- 队列 L3=3/5、排队=2/5 点亮,L3 取到琥珀色 rgb(255,166,87)
- 页面无横向溢出;总览/侧栏/顶栏/内核页渲染文本无 emoji
- go vet 干净,internal/plugins/webui 测试通过
2026-09-14 17:40:54 +08:00