一次三方(CLI/GUI/鸿蒙)与内核的协议审计,发现四处**全部静默**的不一致
(无任何测试或日志会报),本次一并修复并把它们变成可执行判据。
## 四处不一致
1. `agent_error` 是**幽灵类型**:SSE 白名单订阅了它、鸿蒙还写了处理分支
(显示「本轮处理失败」),但全仓**无任何发布者** —— events 包与 SDK 都
没有这个常量。于是那段分支永远不可达,而真正的失败帧叫 `error`
(webui/cli 都发 `{type:"error"}`),鸿蒙从未接住。
2. `system` **发了没人收**:internal/plugin/dynamic_proc.go 一直在发插件崩溃
(注释还写着「webui/诊断插件可订阅」),却不在 SSE 白名单里 ⇒ 事件发出来
就掉地上,插件崩溃对用户完全不可见。
3. CLI 缺 `stage`/`system`:CLI 是唯一看不到阶段进展的客户端,agent 跑长任务
时终端长时间静默,用户分不清「在干活」还是「卡住了」。
4. 鸿蒙设备桥**静默丢弃**:只处理 bind_ack 与 cmd,服务端下行的
push / cmd_speech_start / cmd_speech_end / hello_ack 直接 return ——
「收到了但不支持」与「根本没收到」在日志上完全一样,无从排查。
## 修法(注意:三方客户端的通信模型不同,清单不可一刀切)
CLI 走 InjectTextSync **同步拿最终回复**(response 帧)+ 事件订阅做过程输出,
**不做 SSE 重连** ⇒ 它需要 stage/tool_call/reasoning/delta,
但**不需要** agent_output(正文来自同步回执)与 sync_required(那是 SSE
断线重连专用的补拉信号)。GUI/鸿蒙是真 SSE 客户端,两者都需要全量对话事件。
第一版判据按同一份清单要求三者,误报了 CLI —— 那是判据的错,不是代码的错。
- handler_chat.go:subTypes 删 `agent_error`、补 `system`
- dashboard.js / ChatSse.ets:补 `system` 消费(WebUI 用 toast,鸿蒙用 setStage)
- ChatSse.ets:幽灵分支 `agent_error` 改为真实类型 `error`
- cli/plugin.go:补 `stage`(过滤 `_consolidation_`)与 `system` 订阅
- cmd/waiter:补 Phase/Event/Plugin 字段与 stage/system 渲染
- DeviceBridge.ets:静默丢弃改为带 hilog 日志(区分「不支持」与「没收到」)
## 判据(internal/plugins/webui/handler_sse_protocol_test.go,7 条)
TestSSESubTypesAreRealEventTypes(幽灵类型)/
TestEveryPublishedEventHasAnOutwardPath(发了没人收,例外须显式声明理由)/
TestClientsRecognizeServerEventTypes(按客户端分档)/
TestNoPhantomFrameTypes / TestStageFrameFieldsMatchServer /
TestSseWhitelistParsesToExpectedSet(**解析器反向自检** —— 解析器失效会让整组
判据静默变成空集并全绿,是最危险的假绿)/
TestDeviceBridgeOpCoverage。已接入 `make check-client-protocol`(进 make test)。
变异 M1–M8 全部验证。其中 M5 首轮未报红:判据只查前缀 `json:"phase`,
把 tag 改成 `phaseX` 仍匹配 ⇒ 改为匹配**完整 tag** 后才咬住。
## 端到端实测(不只看测试绿)
- 真实 SSE 流收到 stage×15 / content_delta×111 / memory_access×3 /
agent_llm_chain / tool_call×2,**agent_error 0 次**(幽灵已清)
- kill 一个 plugin.bin → SSE 收到 `event: system`
`{"event":"plugin_crashed","plugin":"a2a"}`,日志确认「自动重启 a2a」
- WebUI 真弹出 toast「插件 acp 异常退出,内核正在重启它」(MutationObserver 捕获)
HomeAgent 鸿蒙客户端
HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebView 套壳)。 功能与 WebUI 对齐:SSE 流式对话、工具调用卡片、思考过程折叠、附件上传预览、 设备桥、插件管理、设置编辑、宽屏双栏、深浅色主题。
工程结构
HomeAgent/
├── AppScope/ 应用级配置与图标
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
├── entry/src/main/
│ ├── ets/
│ │ ├── common/ 通信、状态与纯逻辑(无 UI)
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
│ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
│ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
│ │ │ ├── UserError.ets 错误转人类可读文案
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
│ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
│ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译(ChatStreamSink 接口)
│ │ │ ├── ChatSession.ets 发送/中断(POST /chat、/chat/file)
│ │ │ ├── ChatHistory.ets 历史载荷与 tool_calls 解析
│ │ │ ├── ChatFormat.ets ForEach 键、工具卡状态/配色、渠道判定
│ │ │ ├── AttachmentMeta.ets 附件解析与格式化(纯函数)
│ │ │ ├── AttachmentImage.ets 附件字节获取与解码(沙箱/远端)
│ │ │ ├── DeviceBridge.ets 设备桥客户端(socket 生命周期与命令分发)
│ │ │ ├── BridgeProtocol.ets 设备桥协议消息与帧构造
│ │ │ ├── BridgeRouter.ets 桥请求路由
│ │ │ ├── BridgeCaps.ets 能力声明
│ │ │ ├── DeviceBridgeSession.ets 前台桥生命周期、网关地址推导
│ │ │ ├── DeviceModel.ets 设备页纯逻辑(device_id 兜底、在线设备解析)
│ │ │ ├── PluginApi.ets 插件列表/详情接口
│ │ │ ├── PluginStatus.ets 插件状态判定与配色
│ │ │ ├── SettingsModel.ets 设置载荷解析、分类归并、分页、路由 id
│ │ │ └── MarkdownParser.ets Markdown 解析(块/行内/表格)
│ │ ├── components/ 可复用组件
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
│ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
│ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
│ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
│ │ │ ├── ChatToolCard.ets 思考过程卡 + 工具调用卡
│ │ │ ├── ChatComposer.ets 悬浮输入区(选图/选文件/上传/发送)
│ │ │ ├── ChatAttachBar.ets 加号菜单 + 待发送附件条
│ │ │ ├── SettingsHome.ets / SettingsRootEntries.ets / SettingsEntryCard.ets 设置一级页
│ │ │ ├── ConnectionsPane.ets / AppearancePane.ets / BackendSettingsPane.ets 设置二级页
│ │ │ ├── PluginListView.ets / PluginDetailPane.ets / PluginsOverlays.ets 插件页
│ │ │ ├── DeviceRootEntries.ets / DevicePanes.ets 设备页
│ │ │ ├── SettingsEditor.ets 配置编辑器
│ │ │ ├── StatusCards.ets 状态卡片
│ │ │ ├── ToastBar.ets 统一提示条(插件页与设置页共用)
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
│ │ │ ├── SubPage.ets 二级页容器 / NavGroup / NavRow / PlainCard
│ │ │ ├── MotionBase.ets 统一按压反馈与入场动画
│ │ │ └── GradientBackground.ets
│ │ ├── model/Model.ets 共享类型定义
│ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
│ │ │ ├── Index.ets Tab 容器(入口)
│ │ │ ├── ChatPage.ets 对话
│ │ │ ├── DevicePage.ets 设备
│ │ │ ├── PluginsPage.ets 插件
│ │ │ └── SettingsPage.ets 设置
│ │ └── entryability/EntryAbility.ets
│ ├── module.json5 权限、能力声明
│ └── resources/ 字符串、颜色、图标、页面路由表
├── build-profile.json5.example 构建/签名配置模板(复制后填本机签名材料)
└── oh-package.json5 依赖
约定:单个 .ets 不超过 400 行,页面只做页面壳(导航栈 + 数据编排),
可复用结构进 components/,无 UI 的逻辑进 common/。
这条约定有一个边界,别用反了:
不到 400 行的文件不要为了拆分而拆分。
@Component的build()只允许一个根节点, 把原来多节点的@Builder改成组件时会多出一层Column包裹 —— 布局等价是推理出来的、 不是看出来的,每拆一次都要付一次"未上机验证"的账。所以拆分只用来解决真实的可读性/维护性 问题(超长文件、职责混杂),而不是凑行数。pages/Index.ets目前 396 行就属于"不动"的一类: 没越线,余量本身也是有用的缓冲;等它真越线了再拆,并且优先看是不是又长出了大@Builder。
编译
需要 DevEco Studio 或 command-line-tools。
本工程用 compatibleSdkVersion 6.1.1(24) / compileSdkVersion 26.0.0。
-
前置条件:
oh_modules/必须存在(ohpm install的产物)。它被
.gitignore忽略,所以干净 clone 后没有;而 hvigor 不会自动补齐它 —— 实测把oh_modules/移走后构建不会触发ohpm install,而是直接报一堆arkts-no-untyped-obj-literals(依赖类型声明缺失),且不会重建该目录。 所以:clone 后先ohpm install,之后别把这个目录当垃圾清掉。entry/build/、.hvigor/是纯构建产物,可以随时删除(冷构建 ~8s)。 -
准备签名配置(
build-profile.json5含密码明文,未入库):cd cmd/ohos/HomeAgent cp build-profile.json5.example build-profile.json5把
REPLACE_WITH_YOUR_*换成本机 DevEco 生成的调试签名材料, 默认在~/.ohos/config/下(.cer/.p7b/.p12三件套 + 两个密码)。 用 DevEco Studio 打开工程会自动生成,命令行可参考deveco-cli生成签名材料。 -
构建 HAP:
cd cmd/ohos/HomeAgent # ⚠️ 不要用仓库里的 ./hvigorw:它是符号链接,启动脚本按 $(dirname $0) 定位, # 会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw。 # 一律用 command-line-tools 里的绝对路径(本机为 /opt/huawei/command-line-tools/bin/hvigorw): /opt/huawei/command-line-tools/bin/hvigorw \ assembleHap --mode module -p product=default --no-daemon产物在
entry/build/default/outputs/default/entry-default-signed.hap。 -
安装到设备(
entry/build/是纯构建产物、不入库,所以必须先跑完第 2 步, 否则下面这个路径不存在):hdc install entry/build/default/outputs/default/entry-default-signed.hap路径里的目录名随构建模式而变:默认是
default/,若用-p product=<名字>则是该产品名。 拿不准就先find entry/build -name '*.hap'找一下。同目录还有entry-default-unsigned.hap,hdc install要用带-signed的那个。
连接 homed
首次启动在「设置」里填:
- 服务地址:
http://<homed 主机>:8080(WebUI 插件监听端口) - API Key:homed 的
plugin.webui.api_key
客户端所有请求走 <服务地址>/api/v1/*,带 X-API-Key 头。
附件路径 /files/ /uploads/ 不带 /api/v1 前缀,同样携带鉴权头。
设备桥需要 homed 启用 remotedevice 插件(默认 9890),
在「设备」页填 ws token 后本机能力即可被 agent 调用。
注意事项
- 聊天历史分页:首屏只拉最新
CHAT_PAGE_SIZE(40)条,向上滚动触顶自动加载更早的。 服务端/chat/history支持limit/before游标;工具调用详情与思考内容完整下发不裁剪。 - 修改主题色:改
common/Constants.ets的DARK_PALETTE/LIGHT_PALETTE,全局生效。 - 新增页面:同时在
resources/base/profile/main_pages.json注册,且只有入口页带@Entry。 - 项目代码部分由 AI 辅助生成,改动请自行评估。
改动后的运行时验证清单
构建通过只能证明编译期没问题;ArkUI 的状态绑定、过渡动画与手势行为 必须上设备/模拟器点一遍。UI 相关改动(尤其拆分、状态搬家)请至少走完:
- 发一条消息,确认流式输出、滚动到底、"AI 思考中/工具调用"状态条正常
- 点开思考过程卡与工具调用卡,确认能展开/收起且有过渡动画
- 传一张图片与一个文件,确认预览条、上传进度、发送后附件卡正常
- 进设置的四个二级页(状态/连接/外观/后端),确认进出场与保存生效
- 进插件列表与插件详情,确认状态色、开关与卸载正常
- 进出设备页四个二级页,确认授权开关与在线设备列表正常
- 宽屏(>=600vp)下确认左右分栏、返回手势与返回键行为
模拟器在无图形/无提权环境里可能起不来(需要写 ~/.Huawei 等宿主目录),
此时请在真机或有权限的机器上补这轮验证,并在提交信息里注明"未做运行时验证"。