diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets index c289bb56..08c6348a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets @@ -113,8 +113,25 @@ export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void { } else if (type === 'sync_required') { // 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件) sink.reloadHistory(); - } else if (type === 'agent_error') { - // 后端错误一律转人话,技术细节不上 UI + } else if (type === 'system') { + // 内核级状态事件(目前唯一发布者是 plugin_crashed)。 + // + // ★ 为何必须补上(2026-10-13 协议审计):该事件一直在发 + // (internal/plugin/dynamic_proc.go:175,注释还写着「webui/诊断插件 + // 可订阅」),但鸿蒙端从未处理 —— 插件崩溃对手机用户不可见。 + if (payload['event'] === 'plugin_crashed') { + const nm: string = payload['plugin'] as string ?? '?'; + // 用 stage 条告知(鸿蒙端 sink 没有 toast 能力;stage 是已有的轻量提示位)。 + // 不用 setLoading(false):崩溃不影响本轮对话本身。 + sink.setStage('插件 ' + nm + ' 异常退出,内核正在重启它'); + sink.refresh(); + } + } else if (type === 'error') { + // ★ 这里原本是 'agent_error' —— 那是一个**不存在的类型**(2026-10-13 审计)。 + // events 包与 SDK 都没有这个常量,全仓无任何发布者,生产日志 0 次。 + // 而真正的失败帧叫 'error'(见 webui/handler_chat.go 与 cli/plugin.go + // 都发 {type:"error", ...}),于是「本轮处理失败」提示根本没机会显示。 + // 改用真实类型名。 sink.setLoading(false); sink.setStage('本轮处理失败,请重试'); sink.refresh(); diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets index 366ea3dd..406b966d 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets @@ -1,4 +1,5 @@ import { webSocket } from '@kit.NetworkKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; import { CapResult } from './BridgeCaps'; import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame, bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame, @@ -225,7 +226,22 @@ export class DeviceBridgeClient { } return; } - if (op !== 'cmd' || !this.bound) { + if (op !== 'cmd') { + // ★★ 2026-10-13 协议审计:这里原本是静默 return,而服务端**会**发 + // op=push / cmd_speech_start / cmd_speech_end / hello_ack。 + // 本机(鸿蒙端)只做「被控设备」,不做输出通道,所以收到这些 op + // 属预期——但**静默丢弃**会让「服务端以为发出去了、本端却毫无痕迹」, + // 排查时只能对时序猜。至少留一行日志,让“收到了但不支持”与 + // “根本没收到”可区分。 + // + // 为何不报错回去:这些 op 不要求回执(服务端 pushToDevice 不等 + // device→server 的应答),回一个错误帧反而会被当成未知帧处理。 + if (op.length > 0) { + hilog.debug(0x0000, 'HomeAgent', 'device bridge: 忽略不支持的 op %{public}s', op); + } + return; + } + if (!this.bound) { return; } const reqId: string = obj['req_id'] as string ?? ''; diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index dd058be7..07b2142d 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -486,6 +486,22 @@ func renderPlain(rl respLine, raw string) string { return rl.Content case "error": return "[错误] " + rl.Error + case "stage": + // 过程信息:让无头调用方(脚本、基准 runner)也能看到进展, + // 而不是只能等最终 response 帧。 + if rl.Tool != "" { + return "[阶段] " + rl.Phase + " (" + rl.Tool + ")" + } + return "[阶段] " + rl.Phase + case "system": + // 内核级状态(目前只有 plugin_crashed)。 + // + // 为何要接(2026-10-13 协议审计):该事件一直在发,而 CLI 是唯一 + // 完全接不到的客户端 —— 插件炸了终端里毫无提示,只能去翻 journal。 + if rl.Event == "plugin_crashed" { + return "[系统] 插件 " + rl.Plugin + " 异常退出,内核正在重启它" + } + return "[系统] " + rl.Event default: if raw != "" { return raw @@ -516,6 +532,13 @@ func printServerEventColored(rl respLine, raw string) { fmt.Printf("%s%s%s%s\n", clearLine, colorGreen, rl.Content, colorReset) case "error": fmt.Printf("%s%s%s%s\n", clearLine, colorRed, rl.Error, colorReset) + case "stage": + // 阶段进展:用暗色(与“思考”同级)——它是过程信息,不应比最终回复更显眼。 + if rl.Tool != "" { + fmt.Printf("%s%s▸ %s (%s)%s\n", clearLine, colorDim, rl.Phase, rl.Tool, colorReset) + } else { + fmt.Printf("%s%s▸ %s%s\n", clearLine, colorDim, rl.Phase, colorReset) + } default: text := raw if text == "" { diff --git a/cmd/waiter/state.go b/cmd/waiter/state.go index 03579cf0..eb52be0f 100644 --- a/cmd/waiter/state.go +++ b/cmd/waiter/state.go @@ -126,7 +126,19 @@ type respLine struct { Tool string `json:"tool"` Status string `json:"status"` Result string `json:"result"` - Reset bool `json:"reset,omitempty"` // delta 帧:服务端轮次作废,清空累积 + // Phase 是 stage 帧的阶段名(on_input / pre_action / post_action / + // toolcall / before_output / after_output …)。 + // + // 为何要加(2026-10-13 协议审计):CLI 此前是唯一看不到阶段进展的客户端, + // 服务端补发 stage 帧后,客户端必须有字段承载它。 + Phase string `json:"phase,omitempty"` + // Event / Plugin 是 system 帧的字段(目前只有 plugin_crashed)。 + // + // 为何要加(2026-10-13 协议审计):system 是内核级状态事件, + // 而 CLI 此前既不订阅它、也没有字段承载 —— 插件崩溃对终端用户不可见。 + Event string `json:"event,omitempty"` + Plugin string `json:"plugin,omitempty"` + Reset bool `json:"reset,omitempty"` // delta 帧:服务端轮次作废,清空累积 } // parseRespLineStruct 解析一行 JSON 响应帧,解析失败时将原文放入 Content。 diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index c7cc3f67..a324070e 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -244,10 +244,59 @@ func (p *Plugin) handleChat(w *connWriter, line string, s *sdk.PluginSDK) { } w.writeLine(map[string]interface{}{"type": "content_delta", "content": content}) }) + // stage:阶段管道(on_input / pre_action / post_action / toolcall / output …)。 + // + // ★ 为何要补(2026-10-13 协议审计):CLI 是**唯一**看不到阶段进展的客户端 + // —— WebUI / GUI / 鸿蒙 都消费了 stage。后果具象:agent 跑长任务时 + // 终端长时间静默,用户分不清“在干活”还是“卡住了”。 + // + // 为何单独发一帧而不是当普通文本:stage 是**过程信息**,与最终回复不同 + // 层级;客户端已有 [思考]/[工具] 前缀的渲染惯例,这里沿用同一约定。 + // + // 过滤 _consolidation_:那是内核内部任务(记忆合并/归档),不属于用户 + // 这一轮对话;WebUI 也做了同样的过滤(见 dashboard.js 的 stage 处理)。 + unsubStage := s.Subscribe(sdk.EventStage, func(evt *sdk.Event) { + ch, _ := evt.Payload["channel"].(string) + if ch != cliChannel || ch == "_consolidation_" { + return + } + phase, _ := evt.Payload["phase"].(string) + if phase == "" { + return + } + tool, _ := evt.Payload["tool"].(string) + w.writeLine(map[string]interface{}{ + "type": "stage", + "phase": phase, + "tool": tool, + }) + }) + // system:内核级状态事件(目前唯一发布者是 plugin_crashed)。 + // + // ★ 为何要补(2026-10-13 协议审计):该事件一直在发(dynamic_proc.go:175), + // 注释还写着「webui/诊断插件可订阅」,但 CLI 完全接不到 —— 插件崩溃 + // 对终端用户不可见,只能去翻 journal。 + // + // ★ 不过滤 channel:system 是**内核级**事件,不由某条输入通道产生, + // 所以没有 channel 字段可比(这是它与其他事件的区别)。 + unsubSystem := s.Subscribe(sdk.EventSystem, func(evt *sdk.Event) { + evtName, _ := evt.Payload["event"].(string) + if evtName == "" { + return + } + plugin, _ := evt.Payload["plugin"].(string) + w.writeLine(map[string]interface{}{ + "type": "system", + "event": evtName, + "plugin": plugin, + }) + }) defer unsubReasoning() defer unsubToolCall() defer unsubReasoningDelta() defer unsubContentDelta() + defer unsubStage() + defer unsubSystem() resp := s.InjectTextSync(cliSource, cliChannel, line) if resp != nil { diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 40456e8a..e0b8ae24 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -3520,24 +3520,48 @@ function starmapRebuildEdgePositions(pos) { starmapBuildEdges(pos); starmapRebuildGlowInstances(pos); } +// ===== BEGIN shared:starmap/layout ===== +// 星图力导向布局 —— WebUI 与 GUI 的**唯一事实源**。 +// +// =================================================================== +// 这个文件为什么存在(别把它当成“又一份副本”) +// =================================================================== +// +// 下面这三个函数原本在 `internal/plugins/webui/dashboard.js` 与 +// `cmd/gui/renderer/app.js` 里各有一份手抄。分叉的代价已经兑现过两次: +// +// 1. WebUI 把 O(n²) 布局换成网格近邻(生产库 3754 块:3.52 亿次配对 → +// n·k 次),GUI 那份没跟着改 ⇒ 换客户端卡顿原样存在。 +// 2. 把空闲帧跳过从 WebUI 抄进 GUI 时抄了 `starmapMorphSet`(WebUI 独有), +// GUI 实际用 `state.starmapGrown` ⇒ 渲染循环每帧 ReferenceError、星图 0 帧。 +// +// 所以这里放**唯一一份**实现,由 `tools/starmap-sync.mjs` 写进两个客户端文件 +// 的 `BEGIN/END shared:starmap/layout` 标记块内,并由判据钉住“三处逐字节相同”。 +// +// =================================================================== +// 算法 +// =================================================================== +// +// 被替换掉的旧实现是 50 轮 `for i / for j=i+1` 全对全斥力: +// 生产库 3754 块 ⇒ 每轮 704 万次配对、50 轮 3.52 亿次。实测(CDP Profiler +// + Electron)单次建图阻塞主线程 1.8–10.9 秒。 +// +// 关键观察:原斥力体里本来就有 `if (d < 25)` —— **只对近邻施力**。既然只关心 +// 近邻,就没必要算全对全:把空间切成边长 == 斥力半径的格子,每个节点只与 +// 「自身格 + 相邻 26 格」(3×3×3)内的点比较即可。 +// +// ★ 为何格边长必须恰等于斥力半径:这样「距离 < 25 的点」必然落在相邻 26 格内, +// **绝不遗漏** —— 这是敢跳过远处配对的唯一依据。格边长一旦小于斥力半径, +// 就会漏掉真实邻居,图会塌成一团。 +// +// 复杂度 O(n·k):均匀分布下每格约 1 点 ⇒ 每轮约 27n 次比较。 +// 配合迭代数自适应与收敛早停,3754 节点实测 8365ms → 438ms。 -// ===== 力导向布局(网格近邻版)===== +// 自适应迭代数:小图跑满 50 轮(值得精细),大图退到 14 轮。 // -// ★★ 为何有这个函数(2026-10-13):原先是内联 50 轮全对全 O(n²) 斥力。 -// 生产库 3754 块 ⇒ 3.52 亿次配对,Profiler 实测阻塞主线程 10.9 秒。 -// -// 而原斥力体里本来就有 `if (d < 25)` —— 只对近邻施力。既然只关心近邻, -// 就没必要全对全:空间切成边长 == 斥力半径的格子,每点只比 3×3×3 邻格。 -// 距离 < 25 的点必然在相邻格内,**不遗漏**(这是敢跳过远处配对的唯一依据)。 -// -// 实测:3754 节点 7016ms → ~120ms(约 60×)。迭代数按 n 自适应, -// 并做早停(收敛后不再跑满)。 -// -// ★ 本函数与 static/starmap-layout-worker.js 是**同一算法的两份实现**: -// Worker 版跑在主线程外(正常路径),本函数是串行回退(file://、 -// 构造失败)。两份必须保持逐字对应的数值语义,否则「有 Worker 与 -// 没 Worker 看到的图不一样」——那种分叉极难排查。 -// 判据见 starmap_perf_test.go(两处都不得出现全对全循环)。 +// n=3754 时约 15 轮。旧实现固定 50 轮,且每轮是 O(n²)。 +// 下限 14 是观感值:网格法下每轮很便宜(n·k 而非 n²),轮数多一些布局更舒展; +// 再少会让叶节点分布明显不均(母节点周围挤堆)。 function starmapLayoutIterationsFor(n) { if (n <= 400) return 50; var it = Math.floor(60000 / n); @@ -3546,6 +3570,8 @@ function starmapLayoutIterationsFor(n) { return it; } +// 早停阈值:取「节点平均间距的 0.2%」。 +// 用相对量而不是绝对值,否则同一个阈值在不同规模的图上含义完全不同。 function starmapLayoutMaxMoveThreshold(n, maxR) { var perimeter = 2 * Math.PI * maxR; var gap = n > 0 ? perimeter / n : maxR; @@ -3554,12 +3580,17 @@ function starmapLayoutMaxMoveThreshold(n, maxR) { return th < cap ? th : cap; } +// starmapLayoutRelax 就地松弛坐标。 +// +// 入参/出参见调用方:flat 是扁平 Float64Array(长度 3n), +// nodes 提供 id 与 mention_count(权重),edges 提供 source_id/target_id。 +// 用扁平数组而非对象数组,是为了让可行时能直接走结构化克隆交给 Worker。 function starmapLayoutRelax(flat, nodes, edges, maxR) { var n = nodes.length; if (n === 0) return flat; - var CUTOFF = 25; + var CUTOFF = 25; // == 斥力半径,见文件头说明 var BASE_R = 15; - // 归一化权重(斥力/吸力加权用) + // 归一化权重(斥力/吸力都按它加权) var mn = new Float64Array(n); var maxMc = 1, minMc = 0; @@ -3572,7 +3603,7 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) { for (var b = 0; b < n; b++) { mn[b] = (((nodes[b] && nodes[b].mention_count) || 0) - minMc) / rng; } - // 边下标(对象 → 下标对) + // 边下标(id → 下标对)。下标化是为了内层循环不做字符串比较。 var idx = {}; for (var i0 = 0; i0 < n; i0++) idx[nodes[i0].id] = i0; var edgeIdx = []; @@ -3603,7 +3634,8 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) { shadow.set(flat); for (var it = 0; it < iters; it++) { rebuildGrid(); - // 斥力(只算 3×3×3 邻格内的近邻;每对只处理一次:j > i) + // 斥力:只算 3×3×3 邻格内的近邻;每对只处理一次(j > i), + // 与旧实现 `for j = i+1` 的语义一致,避免重复施力。 for (var i1 = 0; i1 < n; i1++) { var cxi = Math.floor(flat[i1 * 3] / CUTOFF); var cyi = Math.floor(flat[i1 * 3 + 1] / CUTOFF); @@ -3666,7 +3698,7 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) { flat[t2 * 3 + 1] -= uy; flat[t2 * 3 + 2] -= uz; } - // 向心约束 + 早停位移统计(一次遍历做完) + // 向心约束 + 早停位移统计(一次遍历同时做完,省一趟内存读) var maxA = maxR * 1.5; var maxMove = 0; for (var i3 = 0; i3 < n; i3++) { @@ -3694,10 +3726,13 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) { if (mv > maxMove) maxMove = mv; } shadow.set(flat); - if (maxMove < th) break; // 收敛,早停 + if (maxMove < th) break; // 收敛:剩余轮次只是重复同一状态 } return flat; } +// 本块由 tools/starmap-sync.mjs 从 shared/starmap/layout.js 生成 —— 请改共享源,不要直接改这里。 +// ===== END shared:starmap/layout ===== + // ===== 布局 Worker 调度 ===== // @@ -5375,6 +5410,35 @@ function connectSSE() { console.log("[SSE] sync_required received, incremental sync"); syncChatFromHistory().catch(() => {}); }); + // system:内核级状态事件(目前唯一发布者是 plugin_crashed)。 + // + // ★ 为何必须补上(2026-10-13 协议审计):这个事件一直在发 + // (internal/plugin/dynamic_proc.go:175),该处注释还写着「webui/诊断插件 + // 可订阅」,但 SSE 白名单里没有它、前端也没有订阅 —— 事件发出来就掉地上。 + // 后果:插件崩溃重启对用户完全不可见,只能去翻 journal 才知道 + // (而“插件炸了”正是用户最该被告诉的事)。 + // + // 处理分寸:用 toast 一次性告知,不进聊天流 —— 它不属于任何一轮对话, + // 插进消息列表会打乱会话语义(用户会以为自己发过什么)。 + es.addEventListener("system", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.event === "plugin_crashed") { + var nm = p.plugin || "?"; + try { + // toast(m, isError):第二参是**布尔**,传字符串会被当 falsy。 + // 这里用 true:插件崩溃是需要用户知道的问题,不是普通提示。 + toast( + __("插件 " + nm + " 异常退出,内核正在重启它", "Plugin " + nm + " crashed; kernel is restarting it"), + true, + ); + } catch (_) {} + } + } catch (ex) { + /* 诊断事件解析失败不应打断聊天流 */ + } + }); // 终端 / 运行中命令的定时刷新。 // // ★ 原来这段是**无条件**的 5s 轮询(浏览器实测:停在总览页 40s 内 @@ -6163,12 +6227,34 @@ var starmapRaf = null; // 若不用开关,那个 return 只是「不干活但仍在被 60fps 调用」,仍白耗一层 // 调度;而开关让它能真正停下,由 starmapWake 重新点火。 var starmapAnimateRunning = false; -// starmapLastRenderKey / starmapIdleFrame:空闲帧跳过绘制的状态(2026-10-13)。 +// starmapLastRenderKey:空闲帧跳过的状态(2026-10-13)。 // // 为何需要:3753 节点 = 3760 draw call/帧,静止画面重绘一遍是纯浪费 // (实测该环境下每帧把主线程占满)。判据见 starmapAnimate。 var starmapLastRenderKey = null; -var starmapIdleFrame = 0; +// starmapLastRenderAt / starmapLastFrameTs:空闲心跳与星空时间驱动(2026-10-13)。 +// +// ★ 为何要 star 时间驱动:以前星空每帧加固定角度,于是“让星空动”就等于 +// “必须按帧重绘”;而每次重绘在软件渲染下要 90–110ms 主线程。 +// 改成按真实 dt 推进后,星空角度与重绘频率解耦,空闲时可以大幅降频。 +var starmapLastRenderAt = 0; +var starmapLastFrameTs = 0; +// SM_IDLE_RENDER_MS:空闲时的心跳重绘间隔。 +// +// 实测背景(2026-10-13):空闲时**每次**重绘在软件渲染下会让主线程卡 +// ~90–110ms(帧呈现开销,per-call 计时抓不到,只有长任务观测能看见)。 +// 因此重绘间隔直接决定「卡不卡」: +// 0.5s 心跳 → 17% 主线程占用(每秒两次干咳) +// 2s 心跳 → 5% +// 5s 心跳 → ~2% +// +// 为何不做成“完全静止”(归零):星空是画面里唯一的“活物”,彻底冻结会让 +// 星图看着像挂了。5s 是“看得出在转”与“不卡”的折中(星空按真实 dt 推进, +// 所以降频不会让它跳变)。 +// +// ★ 任何交互(拖动/悬停/点击)或活动脉冲都会改变 camKey ⇒ 立即重绘, +// 不受此间隔限制 —— 手感不会因此变粘。 +var SM_IDLE_RENDER_MS = 5000; // starmapDirty:图结构发生变化(新建/删除/合入块),需要重拉全量。 // // ★ 必须显式声明。此前两处 `starmapDirty = true` 赋的是**隐式全局**, @@ -7576,19 +7662,35 @@ function starmapAnimate() { } if (starmapCtrl) starmapCtrl.update(); - if (starmapStarField) starmapStarField.rotation.y += 0.0001; + // ★ 必须先判相机/控制器:starmapAnimate 可能被 starmapWake() 在相机尚未 + // 建好的时刻点火(例如 renderChatStarmap → initChatStarmap 前的路径)。 + // 下面 camKey 会直接读 starmapCam.position,无守卫时是 TypeError —— + // 同一个缺陷在 GUI app.js 已实测抛出(报在 switchChatPanel 调用栈上)。 + if (!starmapRen || !starmapCam || !starmapCtrl) return; + // 星空按**真实流逝时间**旋转(而非每帧固定 0.0001)。 + // + // ★ 为何改成时间驱动(2026-10-13):以前每帧加固定角度,所以为了 + // 让星空“在动”就必须按帧重绘;而每次重绘在软件渲染下要 90–110ms + // 主线程(实测 40 个长任务 / 20s)。改成按 dt 推进后,**任何重绘 + // 频率下星空角度都是连续的**(不会因为降频而出现跳变),于是可以 + // 放心把空闲时的重绘频率大幅降低。 + var nowMs = performance.now(); + var dtSec = starmapLastFrameTs ? (nowMs - starmapLastFrameTs) / 1000 : 0; + starmapLastFrameTs = nowMs; + // 0.0001 弧度/帧 @60fps ⇒ 0.006 弧度/秒(等值换算,保持原观感速度) + if (starmapStarField && dtSec > 0 && dtSec < 1) { + starmapStarField.rotation.y += dtSec * 0.006; + } // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 if (starmapNodeMeshes.length) starmapTickActivity(); // ★★ 2026-10-13:空闲帧跳过绘制。 // - // 为何要做(实测依据):一台机器上 3753 个节点 = 3760 draw call/帧, - // 即使图形**完全静止**也把主线程占满(15s 窗口阻 14.9s)。静止画面 - // 重绘一遍的代价是纯浪费 —— 场景里还有每帧 0.0001 弧度的星空旋转, - // 所以需要一个“真的什么都没变”的判据。 + // 实测依据:一台机器上 3753 个节点 = 3766 draw call/帧,13ms 的 + // render() 提交,而**帧呈现**(swap/flush)在软件渲染下还要 + // 90–110ms,且会阻塞主线程。静止画面按每秒两次重绘就是纯浪费。 // - // 判据取三样:相机姿态、活动集、星空(唯一常动的对象)。 - // 相机用位置+目标(阻尼会让它在拖动中持续微小变化,所以不能只看 - // 离散的“是否正在拖”)。 + // 判据取:相机姿态 + 活动集。任何交互(拖动/悬停/点击)或脉冲都会 + // 改变 camKey ⇒ 立即重绘,不存在“交互了但画面没跟上”。 var camKey = starmapCam.position.x.toFixed(3) + "," + @@ -7611,15 +7713,14 @@ function starmapAnimate() { (starmapSelected ? starmapSelected.userData.nodeId : "") + "," + starmapLabelBaseVer; - // 星空慢旋是唯一常动项:让它每约 0.5s 才触发一次重绘(旋转幅度 - // 0.0001 弧度/帧,隔 30 帧也看不出跳变)。 - starmapIdleFrame++; - var starDue = starmapIdleFrame % 30 === 0; - if (!starDue && camKey === starmapLastRenderKey) { - // 无变化:不 render、不画标签,只保留 RAF 调度与阻尼更新。 + var changed = camKey !== starmapLastRenderKey; + // 空闲心跳:保留一丝“画面还活着”的感知,但频率从 0.5s 降到 2s。 + // 由于星空已改成时间驱动,降低频率不会让星空跳动。 + if (!changed && nowMs - starmapLastRenderAt < SM_IDLE_RENDER_MS) { return; } starmapLastRenderKey = camKey; + starmapLastRenderAt = nowMs; if (starmapRen && starmapScene && starmapCam) starmapRen.render(starmapScene, starmapCam); // 节点名覆盖层:必须紧跟 render 之后,否则会与 3D 帧错位(看着像标签漂移)。 diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go index b3095020..4a1c63ae 100644 --- a/internal/plugins/webui/handler_chat.go +++ b/internal/plugins/webui/handler_chat.go @@ -734,7 +734,19 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { // executeNewTask/resumeTask),而 `scheduler` 曾既不在本列表里、也没有任何 // 订阅方—— 事件发出来就掉地上。后果是「我的任务被谁打断了」对内对外都 // 不可见,排查时只能按时序猜。这里补上对外那一半。 - subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output", "scheduler", + // + // ★★ 2026-10-13 协议审计:这份字面量清单必须与 internal/events 的事件常量对齐。 + // 审计发现两处不一致,均已在此修正: + // ① "agent_error" 是**幽灵类型** —— 全仓无任何发布者(events 包无此常量、 + // SDK 无此常量、生产日志 0 次),而鸿蒙客户端为它写了处理分支 + // (ChatSse.ets:116 显示「本轮处理失败」)⇒ 那是一段不可达代码。 + // 真正的失败走 `error` 帧(HTTP 层)与 `system` 事件。已删除该字面量。 + // ② "system"(插件崩溃通知)被 dynamic_proc.go 发布,注释写明 + // 「webui/诊断插件可订阅」,却不在本清单里 ⇒ 事件发出来就掉地上、 + // 客户端永远看不到插件崩溃。已补上。 + // + // 判据见 handler_sse_protocol_test.go(两份清单必须互为子集)。 + subTypes := []string{"agent_output", "reasoning", "tool_call", "stage", "agent_llm_chain", "terminal_output", "scheduler", "system", // memory_access:图记忆的真实读写(哪个块被读了/写了/删了/合了)。 // // ★ 为何新增而不是用现有的 tool_call:tool_call 只带**工具名** diff --git a/internal/plugins/webui/handler_sse_protocol_test.go b/internal/plugins/webui/handler_sse_protocol_test.go new file mode 100644 index 00000000..73aaa971 --- /dev/null +++ b/internal/plugins/webui/handler_sse_protocol_test.go @@ -0,0 +1,456 @@ +package webui + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// ===== 客户端↔核心协议一致性判据 ===== +// +// ## 为什么需要这一组 +// +// 2026-10-13 做了一次三方客户端(CLI / GUI / 鸿蒙)与核心的协议审计,结果是 +// **四处真实不一致,但全部静默**——没有任何测试或日志会报: +// +// ① "agent_error":SSE 白名单订阅了、鸿蒙写了处理分支,而**全仓无发布者** +// (events 包与 SDK 都无此常量,生产日志 0 次)⇒ 鸿蒙的「本轮处理失败」 +// 是不可达代码。真正的失败帧叫 "error",鸿蒙没接。 +// ② "system":dynamic_proc.go 一直在发(插件崩溃),注释写着「webui/诊断 +// 插件可订阅」,却不在 SSE 白名单里 ⇒ 发出来就掉地上,客户端看不到插件崩溃。 +// ③ CLI 缺 stage:WebUI/GUI/鸿蒙都消费了阶段事件,只有 CLI 没有 ⇒ agent 跑 +// 长任务时终端长时间静默,分不清「在干活」还是「卡住了」。 +// ④ 鸿蒙设备桥对 push / cmd_speech_* 静默丢弃(只实现 bind_ack + cmd)。 +// +// 共同形态是**「写了、发了、不报错,只是对方收不到」**——正是本仓反复记过的那类 +// 静默失效。靠人眼审阅抓不住(本轮就是审出来的),必须变成可执行判据。 +// +// ## 判据的分工 +// +// 这里判**服务端侧**的清单一致性(事件常量 ↔ SSE 白名单 ↔ 各客户端识别集合); +// 客户端侧的细节由各自的判据兜住(cmd/gui/starmap-perf.test.mjs 等)。 + +// eventsBusPath 是事件常量的唯一事实源。 +const eventsBusPath = "../../../internal/events/bus.go" + +// sseSubTypesPath 是 SSE 转发白名单所在处。 +const sseSubTypesPath = "handler_chat.go" + +// eventLiterals 从 internal/events/bus.go 提取全部事件字面量。 +// +// 为何以它为准:事件类型是跨进程/跨客户端的**契约**,而 bus.go 是唯一定义处。 +// 任何客户端只应消费这里有的类型;反过来,核心发布的事件(除明确内销的) +// 都应有对外通路,否则就是「发了没人收」。 +func eventLiterals(t *testing.T) map[string]bool { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", "..", "internal", "events", "bus.go")) + if err != nil { + t.Fatalf("读 events/bus.go: %v", err) + } + re := regexp.MustCompile(`EventType\s*=\s*"([a-z_]+)"`) + out := map[string]bool{} + for _, m := range re.FindAllStringSubmatch(string(b), -1) { + out[m[1]] = true + } + if len(out) < 10 { + t.Fatalf("只解出 %d 个事件常量,疑似正则失效(预期 >10)", len(out)) + } + return out +} + +// sseSubscribedTypes 解析 handler_chat.go 里**实际**被 SSE 转发的类型集合。 +// +// 三个来源,缺一不可: +// - subTypes 字面量清单(含 memory_access 等) +// - appendDeltaSub(sdk.EventXxx) 的 delta 增量 +// - 单独的 Subscribe(sdk.EventRawInput)(转成 channel_input) +func sseSubscribedTypes(t *testing.T) map[string]bool { + t.Helper() + b, err := os.ReadFile(sseSubTypesPath) + if err != nil { + t.Fatalf("读 %s: %v", sseSubTypesPath, err) + } + src := string(b) + // 先剥注释:本文件的注释里出现过被删掉的 "agent_error", + // 不剥的话判据会被注释喂饱(这是本项目踩过的假绿形态)。 + code := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(src, "") + code = regexp.MustCompile(`(?m)^\s*//.*$`).ReplaceAllString(code, "") + + out := map[string]bool{} + + // ① subTypes 清单 + if m := regexp.MustCompile(`(?s)subTypes\s*:?=\s*\[\]string\{(.*?)\}`).FindStringSubmatch(code); m != nil { + for _, lit := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(m[1], -1) { + out[lit[1]] = true + } + } + + // ② delta 订阅:appendDeltaSub(sdk.EventReasoningDelta) → reasoning_delta + litByConst := map[string]string{ + "EventReasoningDelta": "reasoning_delta", + "EventContentDelta": "content_delta", + } + for _, m := range regexp.MustCompile(`appendDeltaSub\(sdk\.(Event\w+)\)`).FindAllStringSubmatch(code, -1) { + if lit, ok := litByConst[m[1]]; ok { + out[lit] = true + } + } + + // ③ raw_input 单独订阅(SSE 上以 channel_input 名义发出) + if strings.Contains(code, "Subscribe(sdk.EventRawInput") { + out["raw_input"] = true + } + + if len(out) < 8 { + t.Fatalf("只解出 %d 个 SSE 订阅类型,疑似解析失效(预期 >8):%v", len(out), out) + } + return out +} + +// TestSSESubTypesAreRealEventTypes 钉住:SSE 白名单里的每个名字都必须是真事件。 +// +// 这正是 "agent_error" 那类**幽灵类型**的判据:它既不在 events 包里、 +// 也没有任何发布者,却进了白名单并被客户端实现 —— 客户端为它写的分支 +// 永远不会执行,而真正的错误帧被漏掉。 +func TestSSESubTypesAreRealEventTypes(t *testing.T) { + real := eventLiterals(t) + sub := sseSubscribedTypes(t) + + var ghosts []string + for name := range sub { + if !real[name] { + ghosts = append(ghosts, name) + } + } + sort.Strings(ghosts) + if len(ghosts) > 0 { + t.Errorf("SSE 白名单里有 %d 个**不是事件类型**的名字:%v\n"+ + " 它们是幽灵:没有任何发布者,客户端为它们写的分支永远不执行。\n"+ + " 修法:从 handler_chat.go 的 subTypes 里删掉,并同步删掉各客户端的处理分支。", + len(ghosts), ghosts) + } +} + +// TestEveryPublishedEventHasAnOutwardPath 钉住:核心发布的事件都要有对外通路。 +// +// 这是 "system" 那类的判据:dynamic_proc.go 一直在发插件崩溃事件,注释还写着 +// 「webui/诊断插件可订阅」,但 SSE 白名单没订阅 ⇒ 事件发出来就掉地上。 +// +// ★ 例外清单必须显式写下来并给理由 —— 「内销」「给插件总线用」都必须是**声明过的** +// 决定,而不是漏了。放行清单越长,这份判据的价值越低,所以每加一条都要有理由。 +func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) { + sub := sseSubscribedTypes(t) + + // 允许不进 SSE 的事件:事件名 → 理由。 + // + // ★ 加这一条前先问:前端真的不需要知道这件事吗? + // 「system」曾经可以放在这里(当年就是漏了),而插件崩溃恰恰是用户 + // 最该被告诉的事 —— 所以它现在**在**白名单里,不在这里。 + allowed := map[string]string{ + // 已是 SSE 通路(raw_input 以 channel_input 名义发出,见测试 ①) + "raw_input": "经 channel_input 轻量转发(不带正文,避免每次说话都多背一份用户消息)", + // 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目, + // 属内核内部的职责链,前端无需感知。 + "skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程", + // delta 已由 appendDeltaSub 覆盖(见 sseSubscribedTypes ②) + "reasoning_delta": "由 appendDeltaSub 订阅", + "content_delta": "由 appendDeltaSub 订阅", + // 事件总线的通配订阅用,不是可发布的具体类型 + "": "EventAll 通配", + } + + real := eventLiterals(t) + var missing []string + for name := range real { + if sub[name] { + continue + } + if _, ok := allowed[name]; ok { + continue + } + missing = append(missing, name) + } + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("这些事件有发布者但**没有任何对外通路**:%v\n"+ + " 症状是「发了、没报错、客户端收不到」,只能翻 journal 才发现。\n"+ + " 修法:加进 handler_chat.go 的 subTypes(并在各客户端加处理分支),\n"+ + " 或在本判据的 allowed 里显式声明理由(声明 = 这是个决定,不是漏了)。", + missing) + } +} + +// TestClientsRecognizeServerEventTypes 钉住:三方客户端都认得「它应当认得的」事件。 +// +// ★ 不能一刀切(第一版就是这么写错的):三方客户端的**通信模型不同**。 +// +// CLI 走 `InjectTextSync` 同步拿最终回复(response 帧)+ 事件订阅做过程输出, +// **不做 SSE 重连**;所以它需要 stage/tool_call/reasoning/delta, +// 但既不需要 agent_output(正文来自同步回执),也不需要 sync_required +// (那是 SSE 断线重连专用的补拉信号)。 +// +// 而 GUI / 鸿蒙 是真正的 SSE 客户端,两者都需要全量对话事件。 +func TestClientsRecognizeServerEventTypes(t *testing.T) { + // SSE 客户端(GUI / 鸿蒙)应认得的核心对话事件。 + // 不含 skill_detected(内核内部握手)。含 system(插件崩溃要到达用户)、 + // 含 sync_required(两者都用 Last-Event-ID 重连,需要这个补拉信号)。 + sseMustKnow := []string{ + "agent_output", "reasoning", "content_delta", + "tool_call", "stage", "sync_required", "system", + } + // CLI 应认得的事件:**按它的实际通信模型**定,而不是照搬 SSE 那套。 + // + // ★ 这份清单本身就是要审的东西:每加一项都要回答「CLI 真的会收到它吗」。 + cliMustKnow := []string{ + "reasoning", "reasoning_delta", "content_delta", + "tool_call", "stage", "system", "response", "error", + } + + clients := []struct { + name string + path string + root bool + mustKnow []string + }{ + {"GUI", "../../../cmd/gui/renderer/app.js", false, sseMustKnow}, + {"鸿蒙", "../../../cmd/ohos/HomeAgent/entry/src/main/ets", true, sseMustKnow}, + {"CLI", "../../../cmd/waiter", true, cliMustKnow}, + } + + for _, c := range clients { + p := c.path + var blob string + if c.root { + var sb strings.Builder + _ = filepath.Walk(p, func(fp string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return nil + } + ext := filepath.Ext(fp) + if ext != ".ets" && ext != ".ts" && ext != ".go" { + return nil + } + b, rerr := os.ReadFile(fp) + if rerr == nil { + sb.Write(b) + } + return nil + }) + blob = sb.String() + } else { + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("读 %s: %v", p, err) + } + blob = string(b) + } + if len(blob) < 1000 { + t.Errorf("[%s] 源码只有 %d 字节,疑似路径写错(测了等于没测)", c.name, len(blob)) + continue + } + + var missed []string + for _, e := range c.mustKnow { + // 接受单/双引号两种字面量风格 + if !strings.Contains(blob, `"`+e+`"`) && !strings.Contains(blob, `'`+e+`'`) { + missed = append(missed, e) + } + } + if len(missed) > 0 { + t.Errorf("[%s] 不认识这些它应当认得的事件:%v\n"+ + " 症状是「另一端的表现静默缺失」(CLI 曾因此看不到阶段进展;\n"+ + " 鸿蒙曾因此收不到插件崩溃)。", c.name, missed) + } + } +} + +// TestNoPhantomFrameTypes 钉住:客户端不得实现服务端**从不发送**的帧类型。 +// +// 这是 "agent_error" 在客户端一侧的判据:鸿蒙曾为它写了「本轮处理失败」分支, +// 而该类型全仓无发布者 ⇒ 那段代码不可达,且掩盖了「真正的错误帧叫 error」这个事实。 +func TestNoPhantomFrameTypes(t *testing.T) { + // 已确认的幽灵:类型名 → 为什么它不存在。 + phantoms := map[string]string{ + "agent_error": `events 包无此常量、SDK 无此常量、全仓无发布者;真正的失败帧叫 "error"`, + } + + // 客户端源码里搜幽灵字面量(排除注释行,避免判据被说明文字喂饱)。 + scan := []string{ + "../../../cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets", + } + + for _, p := range scan { + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("读 %s: %v", p, err) + } + src := string(b) + var lines []string + for _, ln := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(ln) + // 跳过纯注释行:注释里提到幽灵名是**解释**,不是实现 + if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") { + continue + } + lines = append(lines, ln) + } + code := strings.Join(lines, "\n") + for name, why := range phantoms { + if strings.Contains(code, `'`+name+`'`) || strings.Contains(code, `"`+name+`"`) { + t.Errorf("%s 仍实现了幽灵帧类型 %q(%s)\n"+ + " 那段分支永远不会执行,还掩盖了真实类型名。", p, name, why) + } + } + } +} + +// TestStageFrameFieldsMatchServer 钉住 CLI 的 stage 帧字段与服务端 payload 一致。 +// +// 为何单判这一条:stage 是 CLI 补上的新通路,而「服务端发 A 字段、客户端读 B 字段」 +// 是协议不一致最安静的形态 —— JSON 解析不报错,只是恒为空值。 +func TestStageFrameFieldsMatchServer(t *testing.T) { + // 服务端(internal/agent/core/stage.go)发的是 phase + tool + stageGo, err := os.ReadFile("../../../internal/agent/core/stage.go") + if err != nil { + t.Fatalf("读 stage.go: %v", err) + } + srv := string(stageGo) + for _, f := range []string{`"phase"`, `"tool"`} { + if !strings.Contains(srv, f) { + t.Errorf("服务端 stage 事件 payload 里找不到字段 %s —— 判据的前提变了,请同步更新", f) + } + } + + // CLI 插件发出的帧必须带同样的字段 + cli, err := os.ReadFile("../../../internal/plugins/cli/plugin.go") + if err != nil { + t.Fatalf("读 cli/plugin.go: %v", err) + } + cb := string(cli) + if !strings.Contains(cb, `"type": "stage"`) { + t.Error("CLI 插件未发送 stage 帧 —— CLI 用户看不到阶段进展") + } + for _, f := range []string{`"phase"`, `"tool"`} { + if !strings.Contains(cb, f) { + t.Errorf("CLI 的 stage 帧缺字段 %s(服务端发的是它),客户端会恒读到空值", f) + } + } + + // CLI 客户端必须有承载 phase 的字段,否则解析后丢弃。 + // + // ★ 必须匹配**完整的 json tag**(含结束引号),不能只查前缀 `json:"phase` + // —— 变异测试实测:把 tag 改成 `json:"phaseX"` 时,只查前缀的判据依然通过 + // (M5 未报红)。这是「判据太松」的典型形态。 + st, err := os.ReadFile("../../../cmd/waiter/state.go") + if err != nil { + t.Fatalf("读 waiter/state.go: %v", err) + } + stc := string(st) + if !strings.Contains(stc, `json:"phase,omitempty"`) { + t.Error("cmd/waiter 的 respLine 缺 Phase 字段(必须是 `json:\"phase,omitempty\"`)—— stage 帧的 phase 会被静默丢弃") + } + if !strings.Contains(stc, `json:"event,omitempty"`) || !strings.Contains(stc, `json:"plugin,omitempty"`) { + t.Error("cmd/waiter 的 respLine 缺 Event/Plugin 字段 —— system 帧(插件崩溃)会被静默丢弃") + } +} + +// TestSseWhitelistParsesToExpectedSet 反向自检:确认判据的解析器真的能工作。 +// +// 为何要自检:本组判据全靠从源码里解出「类型集合」。解析器一旦失效 +// (正则不匹配、文件改名),判据会**静默变成空集**并全绿 —— +// 那是最危险的假绿。这里钉一个下限与几个必含项。 +func TestSseWhitelistParsesToExpectedSet(t *testing.T) { + sub := sseSubscribedTypes(t) + // 必含项:既有聚合事件、也有 delta、也有 raw_input 通路 + for _, must := range []string{"agent_output", "tool_call", "memory_access", "scheduler", "reasoning_delta", "content_delta", "raw_input"} { + if !sub[must] { + t.Errorf("解析器没解出 %q —— 解析器坏了(而不是白名单少了它)\n"+ + " 这类假绿最危险:解析器失效会让整组判据静默变成空集", must) + } + } + if len(sub) < 10 { + t.Errorf("只解出 %d 个订阅类型,预期 >=10;解析器疑似失效", len(sub)) + } + // 反向:幽灵必须已被清除(本次修复的目标) + if sub["agent_error"] { + t.Error("subTypes 里仍有 agent_error —— 幽灵类型未清除") + } + // 反向:system 必须已在(本次修复的目标) + if !sub["system"] { + t.Error("subTypes 里缺 system —— 插件崩溃事件仍会掉地上") + } +} + +// 确保 json 包被使用(保留给后续判据扩展 payload 形状检查)。 +var _ = json.Marshal + +// TestDeviceBridgeOpCoverage 钉住:设备桥的下行 op 要么被实现、要么被显式声明忽略。 +// +// ## 为什么需要 +// +// 2026-10-13 协议审计发现:鸿蒙端 `DeviceBridge.ets` 的帧分发是 +// +// if (op !== 'cmd' || !this.bound) { return; } +// +// —— 即除了 bind_ack 与 cmd,**其余全部静默丢弃**。而服务端会下行 +// hello_ack / push / cmd_speech_start / cmd_speech_end 四个 op。 +// +// 静默丢弃是最难排查的形态:服务端以为发出去了、设备端毫无痕迹, +// 「收到了但不支持」与「根本没收到」在日志上完全一样。 +// +// ## 判据的分工 +// +// 这里**不**要求鸿蒙实现 push(它只做被控设备、不做输出通道,这是合理收窄); +// 要求的是:每个服务端会下行的 op,在客户端源码里**有明确交代** +// —— 要么有处理分支,要么出现在一份显式声明的「已知但不支持」清单里。 +// 这样「新增一个下行 op 却没人管」会立刻暴露。 +func TestDeviceBridgeOpCoverage(t *testing.T) { + // 服务端会下行到设备的 op(事实源:remotedevice 的 Push* 帧构造)。 + downOps := []string{"bind_ack", "cmd", "hello_ack", "push", "cmd_speech_start", "cmd_speech_end"} + + // 客户端源码:鸿蒙端设备桥。 + const p = "../../../cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets" + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("读 %s: %v", p, err) + } + code := string(b) + + // 已知但不支持的 op:必须**显式列在这里**并给理由。 + // 空清单意味着「全部下行 op 都已实现」。 + knownUnsupported := map[string]string{ + "hello_ack": "鸿蒙端只做被控设备:hello 由本端发出,ack 仅确认注册,无需额外处理", + "push": "鸿蒙端不注册输出通道(不做“被推送消息”的一方),push 属预期忽略", + "cmd_speech_start": "大负载分块的前半帧;鸿蒙端不接收下发的大负载", + "cmd_speech_end": "同上,分块结束帧", + } + + var missing []string + for _, op := range downOps { + if strings.Contains(code, "'"+op+"'") { + continue // 有处理分支 + } + if _, ok := knownUnsupported[op]; ok { + continue // 显式声明不支持 + } + missing = append(missing, op) + } + if len(missing) > 0 { + t.Errorf("设备桥下行 op 无人处理也未声明:%v\n"+ + " 后果:服务端发出去、设备端静默丢弃,「收到但不支持」与「没收到」\n"+ + " 无法区分。修法:加处理分支,或在 knownUnsupported 里显式声明理由。", missing) + } + + // 反向自检:确认真的有能力丢弃日志(而不是又回到静默 return)。 + // 这是本次修复的落脚点 —— 没有它,上面那份清单只是纸面声明。 + if !strings.Contains(code, "hilog") { + t.Error("DeviceBridge.ets 没有任何日志调用 —— 「忽略不支持的 op」会是静默的,排查时无从下手") + } + if !strings.Contains(code, "忽略不支持的 op") { + t.Error("DeviceBridge.ets 未在丢弃不支持 op 时记录日志:静默丢弃是最难排查的形态") + } +}