65 Commits

Author SHA1 Message Date
9b92a04230 docs: 文档与发布脚本同步到 v1.0.0 子进程架构
README/架构文档仍在描述 C ABI 动态库加载,与 v1.0.0 实际实现不符。
新用户按文档走会去做 -buildmode=c-shared,产物新内核根本不加载。

README.md / README_EN.md:
- 设计要点补子进程架构段(三面通信、崩溃自愈、真热重载)
- 代码结构 plugin/ 描述:.so 动态加载器 → 子进程加载器
- 项目状态补 v1.0.0 条目(6 类缺陷 + 实测数字),v0.9.0 标注 ABI 已退场
- 新增「下载」章节:三变体对照 + 各平台包格式 + macOS 限制

assets/docs/{zh,en}/ARCHITECTURE.md:
- 四种加载方式表:外部 .so/C ABI → 外部子进程/握手+stdio JSON-RPC
- 加载流程改写为 exec.Command → 继承 fd → 握手 → init → start
- 内置 vs 外部对照表 7 行更新
- 新增「子进程插件的三个通信面」小节,含每个面的选择理由

assets/docs/{zh,en}/OVERVIEW.md:插件系统段落改写

deploy/ 发布脚本三处回归(v0.7.2 的 2c5f9ff 把 package/ 移到
deploy/packaging/ 使目录深度 1→2,但没改相对路径,此后两个版本
的发布都没有二进制资产):
- build.sh:.syso 按目标平台 hide/restore(trap 兜底),恢复
  windows 目标的 CXX,arm64 刻意不带 CXX
- installer.nsi:5 处 ..\build → ..\..\build,PRODUCT_VERSION 可注入
  (原先硬编码 0.8.0)
- homeagent.spec:server 变体补装 waiter(control-server 声明了 CLI 却没装)

deploy/scripts/upload_assets.py:release 资产上传(两步签名 URL → OBS
PUT)。放 deploy/scripts/ 而非 scripts/,因为后者在 .gitignore 里。
支持 GITCODE_REPO/ASSET_DIR 环境变量以复用于 SDK 仓。
2026-09-03 19:26:17 +08:00
f91b27aedb fix: WebUI 版本显示 + Windows 交叉编译 + 发布脚本三处回归
## WebUI 版本链路修复

问题:handler.go:949 报的是 sdk.SDKVersion,那条链最终指向
SDK 仓 meta.Version 的硬编码值,与 -ldflags 注入的内核版本
完全不相交。构建时间、commit hash 全部丢失。
dashboard.html 兜底值是 '0.1.0'——碰巧版本号相等时不显眼,
一旦不等就报错。

修复:
- KernelStatus 新增 BuildStatus 字段(Version/Commit/BuildTime/SDKCompatible/KernelName)
  取自 internal/meta(-ldflags 注入点),与接口冻结无关(KernelStatus 只在 internal/sdk)
- /api/v1/status 改用 meta.Version,另加 sdk_version 字段暴露 SDK 版本
- dashboard.html 概览卡显示 HomeAgent vX.Y.Z + commit/日期/SDK 兼容版本,
  去掉 || '0.1.0' 误导性兜底

## Windows 交叉编译修复

问题:internal/plugin/dynamic_proc_windows.go(Part 1 的桩,610e9d0)只定义了
tryLoadProc,但平台中立的 registry.go 还在调 loadProc / closeProcHost——这两个
函数只在 dynamic_proc_unix.go 里。Windows 下整个 homed 从 Part 1 起编译不过。

plan.md §12.5 声称「Windows 只做了交叉编译,无真机验证」——实际是连编译都没通过。

修复:dynamic_proc_unix.go / dynamic_proc_windows.go 合并为平台中立的
dynamic_proc.go(文件内无任何平台专属调用,proc 包内部通过
shmalloc_* / evtfd_* / shmpass_* / procattr_* 各自带构建标签处理差异)。

## 发布脚本三处回归修复

deploy/packaging/build.sh(从 package/build.sh 移到 deploy/packaging/ 后):
1. PROJECT_ROOT 少一层目录(.. → ../..),产物落进 deploy/build/ 而非根目录
2. initconfig 从未被构建,但 installer.nsi 和 package-linux.sh 都引用它
3. GO 兜底路径指向 /home/jianf/go1.26.5(陈旧硬编码)改为 command -v go
4. electron-builder --config package.json 校验整个文件导致 devDependencies 被判为 unknown
   property,去掉 --config 让它从 build 键读配置

deploy/packaging/package-linux.sh:
1. build_go() 补上 initconfig 构建步骤
2. GO 兜底路径同步修复

知识库 3 条重写 + 1 条新增:
- homeagent_identity:v0.9.0 C ABI → v1.0.0 子进程
- homeagent_architecture:全篇重写为子进程架构(三面通信、Supervisor 台账、
  崩溃自愈、权限三道闸)
- homeagent_recent_updates:在 v0.9.0 前插入 v1.0.0 主线摘要
- changelog_v1.0.0(新建):6 类缺陷消除、架构、实测、已知限制、迁移指引
2026-09-03 15:38:31 +08:00
525aa1f943 Merge branch 'feature/plugin-proc-migration' into main
插件架构子进程化迁移(8-9 周)+ 崩溃自愈收尾。

## 消除的 6 类 C ABI 前提缺陷(对照 plan.md §11.0)

| 缺陷 | 原状 | 现状 |
|---|---|---|
| 热重载失效 | DF_1_NODELETE 让 dlclose 成 no-op | 换 plugin.bin 即生效 |
| 崩溃隔离缺失 | 插件 panic 带崩 homed | 子进程独立崩溃 + 自动重启 |
| stage lost update | 副本模型互相覆盖 35.8~36.8% | 共享内存段,0% |
| cgo 超时泄漏 | 现网泄漏 26 次线程 | 整套架构零 cgo,Kill 真取消 |
| output_send 假成功 | 永远返回 queued+nil | RPC 同步等真实结果 |
| 能力断层 | Windows 只见 3 字段无写回 | 18 字段全可见可写回 |

## 架构

- 控制面:stdio JSON-RPC(51 个 core.* method)
- 数据面:共享内存段(全部子进程共用一块,避免退化成副本模型)
- 通知面:事件环 + 三平台通知(Linux eventfd / macOS pipe / Windows Event)
- 权限梯度显式化为三道闸:procCore 命名字段 + manifest 能力声明 + RPC 边界拒绝
- C ABI 通道整体删除(-3198 行)

## 子进程生命周期管理

- 每子进程专职 waitLoop(cmd.Wait 唯一调用点),不依赖 stdout EOF
- proc.Supervisor 集中台账,Host.Close 先 StopAll 再拆段
- 崩溃自愈:摘注册面(工具+stage+IO通道)→ 移除注册表 → 退避重启
- Linux Pdeathsig 兜底 homed 被强杀场景

## 门禁

make test 零失败 / go vet 无告警 / SDK 公开接口 diff 为空(接口冻结不变量)
2026-09-03 13:46:51 +08:00
02cc74ce11 fix(proc): 子进程崩溃自愈 + 集中台账 + 注册面摘除
根因:子进程插件被 kill 后,内核只发了一个无人订阅的事件,
工具/stage handler/IO 通道全留在注册表里指向死进程,
模型继续调用只吃 ErrProcessExited,没有任何路径把插件拉回来。

## 四层修复

### 1. 专职 waitLoop(进程收割)
- 每个子进程配一根 waitLoop goroutine,是 cmd.Wait() 的唯一调用点
- 不再依赖 stdout EOF 判定死亡(孙子进程继承 stdout 时 EOF 永不到来)
- 手工 os.Pipe 替代 cmd.StdinPipe/StdoutPipe,避免 waitLoop 与
  os/exec 的内部关闭竞争
- host.go: Host.Supervisor(),Host.Close() 先 StopAll 再拆段

### 2. 集中台账 Supervisor
- proc/supervisor.go: 插件 Spawn 握手成功即 track,进程退出即 untrack
- StopAll: 并发发 plugin.stop 走优雅路径,到期仍在的一律 Kill
- 关停后才完成握手的进程被立即结束,不会活过内核
- 消除「孤儿进程持共享段映射 → SIGBUS」的隐患

### 3. 注册面摘除(detachPlugin)
- 新增 StageHost.UnregisterPluginStages:摘除指定插件的全部 stage handler
- 新增 Registry.pluginChannels 台账:记录每个插件注册的 IO 通道
- 三条路径统一走 detachPlugin:Disable / ReloadOne / RemovePlugin
- StopAndUnload 漏了 IO 通道也一并补上

### 4. 自动重启
- onProcCrash 从「只发事件」改为「摘注册面 → 从注册表移除 → 异步排重启」
- scheduleProcRestart: 窗口 5 分钟内最多 3 次,线性退避 1s/2s/3s
- 超限停手留日志;重启前复核是否已被 Disable 或被其他路径加载
- 崩溃计数窗口过期自动归零

### 5. 主动停止 vs 崩溃的区分
- proc.Plugin 新增 stopping 标志:Stop()/Close() 里 Set(true)
- handleExit 读 stopping 标志,主动停止不上报 onCrash
- 防止重载/禁用/卸载被误判为崩溃触发多余重启

### 6. Linux Pdeathsig 兜底
- procattr_linux.go: SysProcAttr.Pdeathsig = SIGKILL
- 兜 homed 自身被 SIGKILL/OOM 时子进程变孤儿的场景
- macOS/Windows 无等价物,空实现

### 7. pluginmgr 升级
- PluginManager 接口新增 PluginRuntime / ListPluginRuntimes
- plugin_list 输出运行态:loaded / alive / pid / crash_count / channel
- 新增 plugin_status: 全量运行期快照 + dead/unhealthy 汇总
- 新增 plugin_restart: 无条件重启单个插件(plgreload 不动未改二进制的插件)

### 测试
- process_test.go: 3 例(grandchild stdout 感知 / Supervisor track-untrack /
  StopAll 无孤儿)
- crash_recovery_test.go: 8 例(detach 三项齐全 / 通道重注册 / 崩溃不阻塞 /
  退避阈值 / 窗口过期 / 关停中跳过 / PluginRuntime 通道识别)
- stages_plugin_test.go: 4 例(stage 按插件摘除 / 空 stage 清理 / 空名 no-op /
  工具+stage 双摘后可重新注册同名)
2026-09-03 12:37:58 +08:00
351e99dd70 docs(plan): 第 12 节 —— 子进程化迁移剩余工作与目标效果
迁移主体已完成上生产(内核 v1.0.0,17 插件全部子进程化),
但有若干项未做完或未达成。写进 plan.md 而非只留在对话里,
避免下次接手时靠猜。

八个小节按「阻塞程度」排:

**12.1 合并到 main + 发布分支** —— 卡在四个决策点,非技术阻塞:
merge 方式(--no-ff vs squash)、是否删 feature 分支、release 构建是否
再替换生产二进制、SDK 仓是否同步。附完整执行序列。

记了一个易错点:v1.0.0 tag 当前打在 feature 分支中间点 670efcd,
按规范应在 release 分支上,需删除重打。

**12.2 验收清单两项未达成** —— 这两项在迁移计划里已如实标 ⚠️:
- SetToolBlocks 仍未实现。C ABI 时代也是空实现故不算回归,
  但 §3.8 明确承诺过「二进制写入 arena + Slice 描述符回传」,没兑现。
- 内存 88MB 远超「基线 +29MB」。根因是每插件静态链接整个 Go runtime,
  15 个不同二进制无共同物理页(PSS/RSS 99.9% vs 基线 44%)。
  基线用 2.68MB 最小插件复制 17 份,绝对数字本就不可比。

**12.3 事件环零真实负载检验** —— 机制完成、压测通过(2.29ms 与实验 4
一致),但 grep 确认无任何插件使用 Events().Subscribe。压测是我构造的
负载,生产上这条路径从未被真实插件走过。只写  会掩盖这一点。

**12.4 三套 ABI 只收敛两套** —— C ABI 删了、Windows DLL 收敛了,
Lua 仍走独立解释器路径。§9.2 那句「三套收敛为单一 RPC」本轮兑现 2/3。
不阻塞是因为 Lua 不经 C ABI,不属于要消除的 6 类缺陷。

**12.5 Windows 无真机验证** —— 只做了交叉编译 + 单元测试。
已知语义差异(Event 是二元信号非计数器)推理上不影响正确性,
但没在真机确认过。§9.2 声称 Windows「从受害者变受益方」缺实证。

**12.6 stage 往返省两次 IPC** —— 132µs 里编解码只占 3.7µs,
其余是 3 次进程往返(invoke + 插件侧反向 lock/unlock)。合并后预期
降到 ~30µs。风险是锁持有时机改变,插件若在 handler 里再请求锁会死锁。

**12.7 遗留项** —— homed 主 heap 2.36GB(与插件无关)、鸿蒙端未提交改动。

**12.8 已达成目标留档** —— 6 类缺陷逐条对账,每条附证据
(测试名或生产日志),便于日后确认哪些是真解决了。
2026-09-03 08:50:48 +08:00
12259ed4ec chore: 修 .gitignore 误伤源码目录(4 类,28 个已跟踪文件)
`git ls-files | xargs -n1 git check-ignore --no-index` 查出 49 个已跟踪
源码文件落在 ignore 规则下。它们现在能提交只是因为「已跟踪文件不受
.gitignore 影响」这条 git 规则在兜着——**新增文件会默默不入库**。

## 根因一:缺前导斜杠(28 个文件)

`knowledge/` / `memory/` / `scripts/` 不带前导斜杠,git 把它们当作
「任意层级的同名目录」:

    memory/     → 吞掉 internal/memory/         24 个文件
    knowledge/  → 吞掉 internal/knowledge/       3 个文件
                          assets/knowledge/
    scripts/    → 吞掉 deploy/scripts/           1 个文件

本意只是忽略根级运行时数据目录。旁边的 `/adapters/` 就写对了,
这三条是漏了斜杠。

修法:补 `/` 前缀。验证两侧行为:
  根级 knowledge/y.md memory/z.db scripts/tmp.sh   仍被忽略 ✓
  深层 internal/memory/new.go internal/knowledge/new.go
       deploy/scripts/new.sh                        可入库 ✓

## 根因二:internal/meta/ 整目录被忽略(1 个文件)

引入于 1f1233b(2026-07-12),同批还有 `.go/`、`.local/`、
`internal/plugins/openclaw/{manager,pysimulator}/`(后两个目录现已不存在)。
`internal/meta/meta.go` 是内核版本号与元数据的唯一数据源,本轮升 1.0.0 时
`git add` 报「paths are ignored」,就是这条。已跟踪所以改动能提交,
但新增 meta 文件会丢。直接删掉这条规则。

## 保留的 20 项不是缺陷

`third_party/homeagent-sdk/example/` 下 20 个已跟踪文件(plg.json +
plugin.go)仍在规则覆盖下,这是**有意的**:外部插件维护在独立 SDK 仓
(决策 sdk_repo_only),主仓只需要这 20 个源码文件参与构建,不要 SDK 仓的
main.go/go.mod/go.sum。删掉该规则会让 60+ 个文件涌进主仓——实测确认过。

已在两处 ignore 段落写明理由,避免后来人「修」错方向。

验证:修复后 check-ignore 扫描从 49 降到 20(全部是有意保留的 example/);
go build ./... 通过。
2026-09-03 08:39:23 +08:00
2a03a83ce0 docs: Part 6.6 文档收尾 —— 迁移计划/接口矩阵/PLUGIN_DEV 全量更新
## plugin-migration-plan.md

Part 6 标记完成,并**记录实际执行与计划的偏离**而非假装一致:

原计划「逐插件迁移,随时回退」。用户决策改为彻底舍弃 .so、无回退通道,
本轮直接删 internal/plugin/cabi/。因此【V】的「.so ↔ .bin 混跑集群冒烟」
不再适用——新内核根本不认 .so。改为验证「新内核面对旧 .so 给可操作错误
且不崩溃」,已在真实二进制上确认。

最终验收清单加「结果」列,13 项逐项对账。**两项未完全达标,如实标注**:

- #9 SetToolBlocks:method 已定义并划入 CapCore,但内核侧仍返回未实现。
  C ABI 时代它也是空实现(§1.4),故不是回归,但也没兑现 §3.8 的承诺。
- #13 内存:15 进程 RSS=88.0MB,远超「基线 +29MB」。根因是每插件静态链接
  整个 Go runtime,15 个不同二进制无共同物理页(PSS/RSS 99.9% vs 基线 44%)。
  实验 5 基线用的是 2.68MB 最小插件,绝对数字不可比;结构性指标
  (均摊线程 5.5 vs 4.9)同量级。

新增两节实录:Part 6.5 生产切换(执行顺序为何不能反过来、hmap 正规通道
vs 手工拷贝的对照表、真实 QQ 消息的端到端证据链)与 Part 6.6 压测数据。

## plugin-interface-matrix.md

状态从「基线 v1」升为「完成 v2」。三个合同面逐一标注达成情况:

- 合同面 B:51 个整数 method id 已平移为 Method* 字符串常量。保留原表作
  历史对照,但注明 case 25(CoreFreeString)无对应 method(内存管理是 C 层
  特有问题),以及 io.setToolBlocks 已定义但内核侧未实现。
- 合同面 C:C1 标题从「今天」改为「迁移前」(迁移已完成,「今天」会误导);
  C2 补上「全部插件共享同一块 memfd」这个关键决定及其理由——第一版设计
  是每插件一段,那会退化成副本模型复现 lost update。
- 第六节「新获得的能力」加「实际结果」列。事件订阅标注机制已完成但
  零用户使用,故未经真实负载检验——这比只写  诚实。

「刻意不给」清单同步为带 API 后缀的新命名(SelftestAPI 等),与
capability.go 的 withheldCapabilities 对齐,并说明为何加后缀:
不加时子串匹配会把 tool.register / io.setToolBlocks 误判为泄漏 ToolAPI。

## PLUGIN_DEV.md(中英双份)

C ABI 时代的描述全部改掉:
- 「动态 .so/.dll 插件」→「子进程插件」
- 「生成 C ABI bridge(z_bridge_gen.go + z_entry.c)」→ 子进程运行时三文件
- 「go build -buildmode=c-shared」→「go build(CGO_ENABLED=0)」
- 平台二进制表:三平台统一 plugin.bin(bundle 包内按 goos.goarch 区分)
- 「不能跨 C ABI 边界序列化」→「不能跨进程序列化」
- 「ABI v2 写回」→「Stage 写回」

新增 v1.0.0 破坏性变更提示框,五条要点:.so 不再加载、业务代码不需改、
entry 字段对 Go 插件已无意义、不再需要 cgo、Windows 从 3 字段升到全字段。

保留 .so 字样的只有变更说明本身(3 处),其余全部清理。
2026-09-03 08:12:30 +08:00
670efcd426 sdk: 同步 SDK 仓 meta 到 1.0.0(vendored 侧)
SDK 仓的 5ed8d65 在主仓这边的对应提交。主仓经 replace 引用
third_party/homeagent-sdk,其 sdk/ 与 meta/ 由主仓一并跟踪
(只有 example/ 与 tools/ 被 .gitignore 忽略)。
2026-09-02 23:02:26 +08:00
62bdfa2b54 meta: 内核版本升到 1.0.0;生产切换脚本改走 hmap 正规通道(Part 6.5)
## 版本号

1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。首个不再加载
`.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须用新版 plugindev 重编)。
SDKCompatibleVersion 同步升 1.0.0。

同时删掉 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID —— 随 Part 6.2
删 internal/plugin/cabi/ 就已无使用者(grep 确认只剩定义处)。留着会让人
以为 C 层协商还在生效,或以为加 method 要同步维护那张整数表。

⚠️ 注意 Makefile 的 `VERSION ?= $(shell git describe --tags --dirty)`:
实际注入值来自 git tag,meta.go 里的默认值只在不带 ldflags 时生效。
make build 当前注入 v0.9.1-56-g2572688-dirty。要让 1.0.0 真正生效需打
v1.0.0 tag 或显式传 VERSION=1.0.0。

## 生产切换脚本重写

第一版是手工拷 plugin.bin + 手改 plugin.json 的 entry —— 那等于**重新实现
了一遍 hmap 解包逻辑,且实现得更差**。漏掉的东西:

  platforms 字段          hmap 内的 plugin.json 本来就写对了
  平台二进制选择          我硬编码 _linux_amd64,正规路径用 platformBinary()
  overwrite 语义          StopAndUnload 停旧实例但**保留配置表**
  失败回滚                os.Rename 备份旧目录,解包失败自动恢复
  校验                    validatePackage 查 manifest + 各平台二进制齐全

配置保留那条尤其关键:生产 17 个插件都有配置(qq 账号、weather 默认城市、
browser profile 路径)。我的脚本恰好没碰配置表所以侥幸不丢,但那是运气
不是设计。

改为 POST 到 pluginmgr 的 HTTP 端点(127.0.0.1:9876/plugins),
传 {path, overwrite:true} 走 installFromPath → installFromData。

保留的一个设计:**先全部校验再动手**。任一插件缺 hmap 就整批中止——
新 homed 不认 .so,「一半装了一半没装」的中间态最难排查。

## 生产切换已执行

顺序(先换二进制再装包,而非反过来):
  1. systemctl stop homeagent
  2. 换 /usr/local/bin/homed
  3. 起服务 —— 15 个 .so 插件报可操作错误被跳过,homed 本体与 16 个内置正常
  4. 逐个 POST 装 17 个 hmap(overwrite=true)
  5. 待重启核对

第 3 步顺带在真实二进制上验证了 Part 6.2 的可操作错误:
  [plugin] dynamic weather: 检测到旧 C ABI 产物(plugin.so/.dll/.dylib)。
  外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin
  (业务代码无需修改)
不崩溃,只跳过。若反过来先装包,旧 homed 的 StopAndUnload 会停掉 qq
消息通道且无法重载 .bin,会卡在「插件全挂」的状态。

结果:17/17 成功,全部 config_kept=true;0 个残留 .so;17 个 plugin.bin
均有执行位;17 个 manifest 的 entry 均为 plugin.bin;无 .bak 残留。
bundle 包正确挑了当前平台(weather 目录只留 8.7MB 的 linux/amd64 那份)。

备份:/home/newqqagent-migration-backup-20260902-214812
(plugins 全目录 + homed.old + homeagent.service,162MB)

验证:go build ./... 通过;go test ./... 全仓无失败。

Ref: docs/zh/plugin-migration-plan.md Part 6.5
2026-09-02 22:40:47 +08:00
2572688c51 proc: 性能基准 + 流式压测(Part 6.6 验收项)
此前只做了功能冒烟与内存快照,延迟与压测都没测。这两项是计划里
明确列出的验收条件,补上。

## 基准结果(AMD Ryzen 7 7840HS)

| 项目 | 实测 | 基线 |
|---|---|---|
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs(同量级) |
| 锁仲裁(内核侧) | 0.76 µs | 见下注 |
| 事件环写入 | 95 ns | — |
| 事件环并发写入 | 83 ns | 无锁竞争恶化 |
| 完整 stage 往返 | 132 µs | 含 3 次进程间往返 |
| 共享段编解码 | 3.7 µs | 占 stage 的 2.8% |

**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——测的不是同一个东西:
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
lockRegistry.acquire/release。真实成本仍在 20µs 量级。
基准原名 BenchmarkStageLockRoundTrip 有误导性,已改为
BenchmarkStageLockArbitration,并在注释里写明不可对照的理由——
否则日后有人拿 0.76µs 去比 19.4µs 会得出「优化了 25 倍」的错误结论。

**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs,其余是
一次 stage 要走 3 次进程间往返(stage.invoke + 插件侧反向的
stage.lock / stage.unlock)。相对 LLM 往返 2-8 秒可忽略;要优化的方向是
把 lock/unlock 合入 stage.invoke 的请求/应答,省掉两次往返。

## 流式压测:§4.3 标记「风险高」的那一项通过

原文担忧:「Bus.Publish 路径禁用任何锁/阻塞——流式输出逐 token 发布,
任何等待都会卡顿」。事件环是 Part 5 新加在这条路径上的,必须验。

```
5000 次 Publish + 每条睡 20µs 的慢消费者
  实测 2.29ms,均摊 457 ns/token
  同步语义理论下限 100ms

订阅者 1 个:1.547ms(515 ns/次)
订阅者 8 个:1.518ms(506 ns/次)   ← 无线性恶化

环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次   ← 仍 O(1)
```

2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token),
post-and-forget 在实现中成立。

第三项的意义:消费者完全停摆时写端覆盖最旧 slot,这条路径仍是 O(1),
故「消费者卡住」不会连带拖慢内核主循环。

Ref: docs/zh/plugin-migration-plan.md Part 6.6、docs/zh/架构迁移评估.md §4.3
2026-09-02 21:42:18 +08:00
2ebdb9a5b7 plugin: 权限梯度显式化(Part 6.4)
迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的
**意外产物**——C 结构体不好传函数指针,这些能力自然到不了插件侧。那是运气
不是策略:任何人给 dispatch 加个 case 就能捅穿。

现在变成显式声明并强制,分三道闸:

1. **类型层**(proc_core.go,Part 6.2 已落地):procCore 用命名字段持有
   内核 SDK 而非嵌入,未在收窄面写出的方法编译期就不存在。
2. **能力集**(新增 capability.go):54 个 plugin→kernel method 划入 11 个
   capability 组,manifest 未声明的组被拒。
3. **RPC 边界**(corehandler.Handle 入口):被拒时返回**明确错误**而非
   静默忽略。

第 3 条针对一类真实故障:C ABI 时代 case 23/24(事件订阅)是空实现,
返回成功但永远收不到事件(§1.3 的「给不了」而非「不给」),插件作者无从得知。
错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。

## 能力划分的两个判断

**粒度按能力域而非单 method**。逐 method 授权看似更精细,但插件作者要在
manifest 里列 60 个名字,且内核每加 method 所有 manifest 都得改。

**空声明 = 不受限,而非「只有 core」**。17 个存量插件的 plugin.json 都没有
capabilities 字段。若空声明当作最小权限,它们会全部失去 IO 注入、记忆读写
而**静默降级**——违反「外部插件零改动」的硬约束。收紧的路径是让插件显式
声明,而不是默默拒绝老插件。

## core 与受限能力的边界

core(无需声明,始终可用):注册自身工具/阶段/通道/API、读写**自己的**配置、
共享段锁仲裁、握手、autoRestart 自述、setToolBlocks。没有这些插件无法工作。

受限(需声明):io / memory / doc_memory / knowledge / text_memory / llm /
social / events / plugin_mgr / settings_cross。

settings 刻意拆成两级:读写自己的配置属 core(正常工作所需),读写**其他插件**
配置或**内核核心**配置属 settings_cross(能改别人/内核的行为)。

## withheldCapabilities:让「不给」可见

10 项刻意不提供的内核内部机制列在表里并附理由。它们没有对应 method 常量——
不是忘了加,是决定不加。列表存在本身就是「这是策略而非疏漏」的证据,
读代码的人能看到边界在哪,而不是从「protocol.go 里没有」这个负面事实去推断。

## 测试

proc 包 10 项:
- AllMethodsClassified:**最重要的一项**。漏登记的 method 会按 CapCore 放行,
  等于绕过整套检查。新增 method 忘登记时当场报出。
- EmptyDeclarationIsUnrestricted / DeclaredSetRestrictsOthers / CoreAlwaysAllowed
- SettingsScopeSeparation:自身配置 vs 跨插件配置的归属
- DeniedErrorIsActionable:错误消息四要素
- HandleEnforcesAtRPCBoundary:被拒的调用不进 switch
- WithheldListIsDocumented:每项都有理由,且不被任何 method 暴露
- UnknownMethodFallsThrough:未知 method 报「未知」而非「权限被拒」,
  否则作者会以为是漏声明能力

写这个测试时踩到自己的坑:第一版用子串匹配查 withheld 泄漏,"Tool" 匹配到
tool.register 和 io.setToolBlocks 误报——那两个是合法开放的(注册自己的工具)。
改成前缀 + unregister 关键字匹配,withheld 项也改名带 API 后缀以示区分。

internal/plugins 2 项接线验证:
- RestrictedPluginStillLoads:只声明 io 的 weather 仍能加载并注册工具
  (它在 Start 里读 Settings,属 core)
- LegacyManifestUnrestricted:无 capabilities 字段的存量插件正常加载

真实 homed 实测:
  [plugin] weather-capped 声明能力: [io]
  [plugin] weather-capped: 经 proc 通道加载(子进程)
  registering tool: weather-capped_current / _forecast / _set_location

验证:go build ./... 通过;go test ./... 全仓无失败;
go test -race ./internal/plugin/... 全绿;go vet 干净。

Ref: docs/zh/架构迁移评估.md §3.8、docs/zh/plugin-migration-plan.md Part 6.4
2026-09-02 21:27:13 +08:00
1d7f011e5d plugin: 17 插件全量重编 + 端到端冒烟验证(Part 6.3)
## 17 个插件源码零改动,全部重编为 plugin.bin

16 个 × 3 平台(linux/darwin/windows),qq 1 平台(plg.json 自己声明
bundle:false)。luademo 走 Lua 解释器不适用。

git status example/ 无输出 —— 这是「业务代码零改动」的硬证据。
批 3 那些预估高风险的插件(qq 2686 行双向通道、browser 12 工具 +
InjectInterruptText、a2a/acp 的 InjectInputSync 同步注入)一次全过,
因为它们只碰公开 SDK 合同面,而合同面在 Part 2 已 51 个 method 全量平移。

唯一一次失败与迁移无关:rss 的 github.com/mmcdole/gofeed 不在本地模块
缓存且 proxy.golang.org 不通,换 GOPROXY=https://goproxy.cn 后通过。

## 真实 homed 加载验证

15 个外部插件全部经 proc 通道建链(protocol=1 sdk=0.9.2,各自独立 PID),
31 个插件 loaded(15 外部 + 16 内置)。

ai_image / files 未走 proc 通道:同名内置插件优先(工厂编译期注册),
外部插件被遮蔽。这是既有行为,与迁移无关。

事件环与共享段均正常创建,且共享段是**一块** 256KB 服务全部 15 个插件。

## 冒烟测试 4 项(internal/plugins/real_plugin_smoke_test.go)

用真实 example 产物而非 testdata 假插件;manifest 刻意写 "entry":"plugin.so"
验证工具链与内核都已不看 entry 值。未重编时 skip 而非 fail。

- ToolInvokeRoundTrip:工具真实调用往返(此前只验证到"注册")。
  weather_current 返回结构化参数校验错误——这恰是链路通的证据。
- StageRewriteTakesEffect:sanitizer 清洗 ANSI 序列,改写经共享段回到
  内核 StageContext
- MultiPluginShareOneSegment:sanitizer + weather 并发,清洗结果不被覆盖
- CrashDoesNotKillKernel:SIGKILL 插件进程后 homed 存活、17 插件仍在
  (对比 C ABI 下插件 panic 直接带崩 homed,§1.2 现网已发生)

## 开销实测与基线偏差

15 个插件进程 RSS=88.0MB PSS=87.9MB 线程=82,均摊 5.87MB / 5.5 线程。

RSS 88MB vs 实验 5 基线 29.1MB **不是回归,是基线不可比**:实验 5 用
2.68MB 最小插件,真实插件 3.1~14.8MB。可比的结构性指标:
- 均摊线程 5.5 vs 4.9 —— 同量级,无线程膨胀
- PSS/RSS 99.9% vs 44% —— **明显差于基线**

第二项是真实发现:基线里 PSS 远低于 RSS 说明 Go runtime 只读代码页在
进程间共享;实测几乎不共享,因为 15 个插件是 15 个不同的二进制,没有
共同物理页可映射。这是「每插件独立二进制」的固有代价,意味着实际内存
开销高于 §4.3 的乐观估计。压这一项的方向是共享 launcher 二进制。

## 工具脚本入 experiments/19-migration-verify

scripts/ 被 .gitignore 排除,故放到已跟踪的 experiments 目录下,
与 01~18 的可复跑实验并列。

measure-plugin-overhead.sh 第一版有统计口径 bug:RSS 读 status 的 VmRSS、
PSS 读 smaps_rollup 的 Pss,输出 PSS(87.9MB) > RSS(69.1MB) —— 物理上不可能。
两者对共享内存段计入方式不同(smaps 的 Rss 含 Pss_Shmem)。已统一从
smaps_rollup 读。另修 bc 不可用导致 MB 全显示 0.0(改用 awk)。

Ref: docs/zh/plugin-migration-plan.md Part 6.3、docs/zh/架构迁移评估.md §4.3
2026-09-02 20:20:25 +08:00
b20121f703 plugin: 删除 C ABI 通道(Part 6.2 完成,-3198 行)
外部插件统一走子进程 + stdio RPC,三套独立 ABI 实现收敛为单一 RPC 实现。
用户决策:彻底舍弃 .so 能力,不保留双通道回退。

## 删除清单

internal/plugin/cabi/                     1156 行(loader.go/loader.c/types.go/output_test.go)
internal/plugin/dynamic_dll_windows.go     272 行(§9.2 记录的能力退化实现)
internal/plugin/dynamic_loader_unix.go      79 行(唯一 cabi 引用点)
internal/plugin/dynamic_dll_test.go         32 行
internal/plugin/dynamic_dll_stub.go         11 行
internal/plugin/dynamic_loader_windows.go   11 行
internal/plugin/bridge_e2e_test.go              (测的是 cabi 路径)
third_party/.../plugindev/templates.go    1296 行(取消跟踪,SDK 仓才是权威副本)

dynamic.go:entryCABI 通道删除,soEntry/dllEntry 常量删除。
registry.go:tryDynamic 探测顺序从 .so → .dll → .lua 变成 proc → lua。

## 旧 .so 给明确错误,不静默跳过

静默跳过会让「插件目录在但没加载」看起来像配置问题,而实际原因是需要
用新版 plugindev 重编。故保留 legacyCABIEntries 表专门用于识别残留:

  plugin legacy: 检测到旧 C ABI 产物(plugin.so/.dll/.dylib)。
  外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin
  (业务代码无需修改)

错误消息里「业务代码无需修改」这句是有测试守着的——迁移的核心承诺就是它。

## pluginmgr 安装逻辑跟进 bundle 命名

子进程模式下各平台产物统一叫 plugin.bin(进程边界即 ABI 边界),故 zip 内
按平台加后缀 plugin.bin.<goos>.<goarch>,解包时挑当前平台那一份重命名。

platformBinary 改为按 runtime.GOOS+GOARCH 生成条目名;platformBinaries 固定表
换成 isPlatformBinary 前缀判断(平台组合会增长:linux/arm64、darwin/arm64…,
按前缀判断无需维护清单)。

新增 chmod 0755:zip 保留了原权限位,但经某些工具链/传输后可能丢失,
内核加载时会因缺执行位报错。提前补上比事后让用户 chmod 更好。

## 测试

entry_dispatch_test.go 重写(12 项):
- classifyEntry 对 .so/.dll/.dylib 现在返回 unknown
- LegacyManifestFallsBackToProbe:存量插件 manifest 仍写 "plugin.so"
  (17 个插件没人去改),须靠目录探测找到 plugin.bin —— 这是
  「外部插件零改动」的直接后果
- LegacyCABIGivesActionableError:错误消息须含 plugindev / plugin.bin / 业务代码
- PluginEntryHash_IgnoresLegacyCABI:.so 不参与 hash(内核已不认它)

upgrade_test.go 的 .hmap 构造改用 plugin.bin。

验证:go build ./... 通过;go test ./... 全仓无失败;
go test -race ./internal/plugin/... 全绿;三平台构建通过。

Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 19:26:40 +08:00
d027c964e2 proc: Windows 共享内存 + 事件通知适配(Part 6.2 内核侧)
补齐内核侧的 Windows 创建端,与 6.1 的插件侧打开端配对。三平台
(linux/darwin/windows)现在都能构建 internal/plugin/proc。

## Windows 走命名内核对象(无 fd 继承语义)

os/exec 的 ExtraFiles 在 Windows 实现里不被支持,故:
- shmalloc_windows.go:CreateFileMappingW(INVALID_HANDLE_VALUE + 命名 →
  系统页文件支撑的匿名段,不落盘)+ MapViewOfFile
- evtfd_windows.go:CreateEventW 命名 Event 对象 + SetEvent 通知
- shmpass_windows.go:把段名/对象名经环境变量注入子进程
  (HOMEAGENT_SHM_STAGE / HOMEAGENT_SHM_EVTRING / HOMEAGENT_EVT_EVENT)

名字带 PID + 递增序号:多个 homed 实例并存时不能撞名。

Event 与 eventfd 的语义差异:Event 是二元信号,多次 SetEvent 只对应一次
唤醒,不累积。不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq 批量
drain,丢的是"唤醒次数"不是"事件";事件环本身就允许溢出丢弃并让消费者
知道丢了(dropped 计数),通知面从来不是可靠投递语义。

## 传递机制抽象为 shmpass_*.go

Plugin.Start 不再直接构造 ExtraFiles 列表,改为问 Host 要:
  Env:        p.host.procEnvForShm()        // Windows 返回段名,Unix 返回 nil
  ExtraFiles: p.host.procExtraFilesForShm() // Unix 返回 fd 列表,Windows 返回 nil

平台差异被收敛到这一对函数,Plugin/coreHandler/stage 全部平台无关。

## macOS pipe 生命周期修正

原实现只返回读端 fd,写端 *os.File 无人持有 → 可能被 GC 回收 →
读端收到 EOF 而非阻塞 → 消费循环变忙转。改为 pipePair 表同时持有两端,
evtfdClose 一并关闭。

## E2E 测试跟进模板拆分

模板从单文件拆成三个(主体 + unix/windows 挂载),测试需要一并落盘,
否则编译报 attachStageShm undefined。procRuntimeTemplates 表必须与
SDK 仓 proc_runtime.go 的 procRuntimeFiles 一致。

验证:三平台 go build ./internal/plugin/... 通过(gojieba 的 cgo 依赖
导致 internal/memory 在非 linux 失败,与本次无关);
go test -race ./internal/plugin/... 全绿,含 2 项真实模板 E2E。

Ref: docs/zh/架构迁移评估.md §9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 19:07:14 +08:00
53a148cb54 docs: Part 5 标记核心已完成,进度快照更新事件环
Part 5 通知面内核侧 + 模板侧均已完成,端到端测试通过。
事件订阅从 C ABI 的空实现(case 23/24)变成真正可用。
测试数量更新:proc 38 项 + plugin 16 项(含 -race)。
下一步转 Part 6 逐插件迁移。
2026-09-02 17:07:43 +08:00
5bbfcc02fb plugin: 事件环内核侧实现(§3.6 Part 5 核心)
事件环(EvtRing)是子进程首次获得事件订阅能力的基础设施。
此前 case 23/24 明确返回未实现,现在经事件环真正可用。

核心设计(§3.6,实验 4 已验证 post-and-forget 加速比 2218x):
- 事件环放**独立共享段**(不与 StageContext 混放):stage compact 会清 arena,
  事件要独立于 stage 生命周期。Host 持有两块 memfd:fd 3 = StageContext,
  fd 4 = 事件环段,fd 5 = eventfd。
- 无锁数据结构:内核 WritePush 追加写 slot,子进程 EvtConsumer 消费。
  writeSeq 原子递增(Bus.Publish 并发调用),readSeq 每订阅者独立。
- eventfd 通知:Linux 用 unix.Eventfd(计数合并,1000 token 只唤醒几次),
  macOS 用 os.Pipe(阻塞模式走 netpoller,只 park goroutine,实验 1 验证
  200 等待者仅 +1 OS 线程)。两者行为一致:Read 阻塞直到有新事件。
- 溢出语义:落后超 cap 时跳到最新,丢弃计数记入 dropped(消费者知道丢了)。
  不静默覆盖最旧(写端直接覆盖 slot,读端靠 seq 判断跳过)。
- 事件类型编码:pubsdk.EventType 字符串 ↔ uint32 位索引(编译时映射表),
  typeMask 位掩码过滤(1<<idx)。

Host 改动:
- NewHost 同时创建事件环段和 eventfd(惰创建,一次分配)。
- Host 持有 evtSubscriber 接口(EvtRingSubscriber),由 Registry 注入
  EventRing 实现——proc 包不依赖 internal/plugin(避免循环依赖)。

corehandler 改动:
- events.subscribe(原 case 23):子进程传事件类型列表,coreHandler
  通过 evtRing 接口调用 EvtRingSubscribe,注册到 Bus 上。
  事件经 EventRing 写入环后由子进程 mmap 读取。
- events.unsubscribe(原 case 24):当前由内核统一清理(子进程 Stop 时)。

Registry 改动:
- ensureProcHost 在创建 Host 后同时创建 EventRing(Bus → EvtRing → eventfd),
  并通过 Host.SetEvtSubscriber 注入给 coreHandler。

测试 3 项:
- BasicWriteAndConsume:Host 创建 → EventRing 写入 → 消费者读到
- OverflowStillDelivers:写入超过 cap 后消费者仍能读到最新事件
- TypeMaskFiltering:typeMask 只订阅 tool_call,agent_output 被过滤

验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
既有事件环测试 3/3 通过;proc 包测试未受影响。

Ref: docs/zh/架构迁移评估.md §3.6、docs/zh/plugin-migration-plan.md Part 5
2026-09-02 16:48:27 +08:00
4f14d0947f docs: 更新迁移计划进度(Part 2/3/4 完成标记)
Part 2(子进程通道)、Part 3(plugindev .bin 构建)、Part 4(RunStage 接线)
标记为已完成,下一步转 Part 5 通知面。

记录两处与原计划的偏差及原因:
- Part 3 模板落地方式从 templates.go 的 raw string 换成真实 .go 源文件 +
  //go:embed —— 900+ 行代码塞在字符串里写错只能等生成插件时才炸。
- Part 2 最初每插件一块共享段,等于副本模型换壳,已改为全部插件共享同一 memfd。

另记 lifecycle.autoRestart 缺口:公开 SDK 的 SetAutoRestart 是纯 setter
无 hook,隔着进程边界内核读不到,需模板在 Start 返回后显式上报。
2026-09-02 13:12:08 +08:00
11c1bbcebb plugin: 子进程通道接通 registry(proc 通道端到端可运行)
Part 3 收尾。tryLoadProc 从桩位变成真实加载路径,plugin.bin 插件现在
经 registry 完整跑起来:spawn → 握手(共享段 fd 3)→ init/start →
反向注册 → 工具调用 → stage 共享内存读改写。

registry 侧:
- Registry 持有 procHost(惰性创建,**全部 .bin 插件共用一块段**)。
  每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成
  副本模型,lost update 原样复现(§8.4 实测 35.8~36.8%)。
- tryDynamic 分派到 Registry.loadProc;tryLoadProc 退为纯静态校验
  (构造需要 Host,只有 Registry 有)。
- StopAll 在锁外释放共享段:插件还持有映射时拆段,它们下一次访问就是
  SIGBUS;且持锁调用会与 onProcCrash 回调产生锁序风险。
- onProcCrash 把子进程退出转成 EventSystem 事件,不在回调里直接重载
  (重载需 registry 锁,而回调可能来自持锁路径的 goroutine)。

proc_core.go —— 权限梯度的类型系统落点(§3.8):
- procCore 用**命名字段**持有 *isdk.PluginSDK,不是嵌入。嵌入会提升全部
  方法,外部插件就能经类型断言拿到 Supervisor/Tracker/Adapter/Indexer/
  Status/Selftest。命名字段下只有显式写出的方法存在——权限梯度从
  「C ABI 表达能力的意外产物」变成显式声明并强制的策略。
- 能力访问器把内部超集接口收窄到公开面(isdk.KnowledgeAPI 内嵌
  pubsdk.KnowledgeAPI 再加 Stats/Remove,isdk.MemoryAPI 加 GraphData,
  isdk.LLMAPI 加 Chat/ReloadFromConfig);nil 保护避免类型化 nil 让
  corehandler 的判空失效。
- procPluginAdapter 转接 Start(*isdk.PluginSDK) → Start(proc.CoreSDK),
  Close 对 closeDynamic 可见故重载能真 kill 子进程(对比 dlclose 对
  Go c-shared 是 no-op,§1.1)。

共享段分配按平台拆分(原先 host.go 直接调 unix.MemfdCreate,darwin/windows
交叉编译失败):Linux memfd;macOS 立即 unlink 的临时文件(无 memfd_create,
但语义一致:无残留、fd 可经 ExtraFiles 传递、子进程 mmap 同一 inode);
其余平台明确报错而非静默降级成「无共享段」——那会让 stage 静默失去数据面。

测试 +13 项:
- e2e_template_test.go 用**真实 plugindev 模板**(而非 testdata 手写假插件)
  编译插件跑全链路,验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己
  跟自己对齐。含 lifecycle.autoRestart 上报、工具调用、stage 读改写、
  FinalText 回传(C ABI 下 after_toolcall 看不到此字段,§8.3 10→16)、
  只读插件不覆盖改写插件。
- proc_load_test.go 验证 Host 唯一性/惰性、chmod +x 错误提示、
  Close 可见性,以及 procCore 不暴露内核内部机制的断言。

验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
全仓 go test 无新增失败;git diff third_party/homeagent-sdk/sdk/ 为空。
既有告警 cabi/loader.go:156 unsafe.Pointer 非本次引入。

Ref: docs/zh/架构迁移评估.md §3.3/§3.4/§3.8、docs/zh/plugin-migration-plan.md Part 3
2026-09-02 13:03:57 +08:00
dev
82dcc86173 feat(proc): Plugin 加载器 + 51 method handler + RunStage 接共享段(Part 2 完成 / Part 4 闭环)
corehandler.go —— cabi/loader.go 51 个 case 体的整块平移(§3.2):
- 参数从「s1/s2/s3 + i1/i2 五个固定槽」改为结构化 JSON,语义不变
- CoreSDK 接口刻意只含外部插件应得能力:无 Selftest/Supervisor/Tracker/
  Status/Adapter/Config/Tool/Indexer/OutputChan/Publish
  → 权限梯度从「C ABI 表达能力的意外产物」变成「显式声明并强制的策略」(§3.8)
- 事件订阅(case 23/24)与 SetToolBlocks 明确返回未实现,不再像 C ABI 那样静默成功
  (静默成功后收不到事件比报错更难排查)
- ToolDef.Cleaner / ChannelDef.Cleaner 是函数,跨进程置 nil(§3.5 回调型资源)

host.go —— 共享段所有权中心:
-  全部子进程插件共享**同一块 memfd**。若每插件一段,
  「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,
  最后回读者覆盖前者,§8.4 的 35.8~36.8% lost update 原样复现
- stageMu 串行化整次 stage 对段的独占(内核可能并发触发 RunStage)
- 首个进入者写入段,最后离开者回读 + Compact(此时无插件持锁,满足 §3.3 前提)

stage.go —— RunStage 接线(风险 3.4 落点):
- 并发扇出保留(§0.2 第 1 条:并发扇出是原始设计,不是缺陷)
- 插件失败时 ForceReleaseLock,避免后续插件死锁(实验 9,无需 robust mutex)

plugin.go —— registry 可加载的插件实体:
- Start: spawn(fd 3 传共享段)→ 握手 → plugin.init → plugin.start
- Close: **真 kill + wait**,对比 cabi 的 Close 只做 dlclose 而后者是 no-op(§1.1)
- invokeOutput **同步等真实结果**,失败上报 error —— §9.4 根治

验证(34 项测试全绿,含 -race,全部用真实子进程):
- 单插件 stage 读改写经共享段回到内核 StageContext
- sanitizer(改写) + weather(只读) 并发:清洗结果不被覆盖(现网场景)
- **5 个独立进程并发 append 同一 FinalText:5 个标记全部保留,零丢失零撕裂**
  (实验 8 在真实 RPC + 真实 RunStage 下的复刻)
- 工具注册可调用 / 输出通道真实失败上报 / start 期间反向调用
- 未知 method 与未实现能力被拒绝 / stage 外加锁被拒绝

接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
2026-09-02 11:34:33 +08:00
dev
d62430a71b feat(proc): 子进程通道 —— RPC 协议 + 进程管理(Part 2 核心)
协议面(protocol.go,§3.2 method id 平移为 method 名):
- NDJSON 帧,双向复用同一对 stdio;ID>0 需应答,ID==0 为通知(post-and-forget)
- 51 个 C ABI method id 全部平移为可读 method 名并标注原编号对照
  编号本身扔掉——加能力不用改两边常量表,不再有 47 夹在 7 和 8 之间的痕迹
- case 25(CORE_FREE_STRING) 无对应 method:进程模型下各自 GC,概念消失
- case 23/24(事件订阅) 与 io.setToolBlocks 今日均为空实现「给不了」,
  子进程下首次真正可给(§3.8 能力对齐)
- 新增 stage.lock/stage.unlock(C ABI 下不存在跨进程锁概念)
- StageInvokeParams 不含 StageContext 数据本身——数据在共享段,只带 stage 名 + seq

进程面(process.go,§2.3 保留现有生命周期机制):
- Spawn: 启动 + 握手(协议版本不匹配显式拒绝,不半兼容运行)
- readLoop: NDJSON 分派应答/插件反向请求,1MB 单帧上限(大 payload 走 arena)
- CallContext: ctx 取消时立即返回**且清理 pending 条目**
  对比 cgo:超时只让调用方返回,goroutine 永久卡在 C 调用里(现网泄漏 26 次)
- Notify: ID=0 不占 pending 表,满足约束 B(流式逐 token 发布不得等待消费者)
- markExited: EOF/退出 → 唤醒全部在途调用 → onExit 回调
  这是「把 panic 捕获换成进程退出检测」的落点,plugin_health 逻辑完全复用
- Stop: plugin.stop → 宽限期 → 超时 Kill;Kill 后 OS 回收全部资源,零泄漏
- serveRequest 带 panic 隔离:内核 handler panic 不带崩 readLoop

验证(10 项,真实子进程而非 mock,含 -race):
- 握手/工具调用/错误上报(插件失败调用方收到 error,非假成功)
- 插件反向调用内核(tool.register + settings.get 双向往返)
- **崩溃隔离**:插件 panic → 子进程 exit 2,内核存活、收到 onExit、在途调用不挂死
- 优雅停止 / **Kill 卡死插件**(ctx 超时返回 + pending 清零 + 资源回收)
- 通知不等应答(100 条 < 1s)/ 50 并发调用应答不串 / 协议版本不匹配拒绝

接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
2026-09-02 10:59:59 +08:00
dev
bfa95ba320 docs(plan): Part 1 完成 + Part 4 核心完成标记,0.3/0.4 标记跳过
- Part 0.3/0.4 标记 ⏭️ 跳过并记录理由(子进程模型下问题整体消失,不给待删代码打补丁)
- Part 1 加载分派骨架  完成(修改/审查/验证三步逐条勾选)
- Part 4 共享内存数据面  核心完成(段/编解码/锁仲裁,RunStage 接线待 Part 2)
- 目录加进度快照
2026-09-02 10:43:40 +08:00
dev
610e9d0bbb feat(plugin): entry 双通道分派 + 共享内存 stage 数据面(Part 1 + Part 4 核心)
Part 1 加载分派骨架(迁移可逐插件推进、随时回退的前提):
- dynamic.go: 新增 binEntry/skillEntry 常量 + entryKind 枚举 + classifyEntry/detectEntryKind
  manifest entry 优先级最高(改回 plugin.so 即回退 cabi);无 manifest 时按目录探测,.bin 优先
- registry.go: tryDynamic 按 entry 分派 proc/cabi 双通道;
  entry 声明 .bin 但二进制缺失时报明确错误,不静默回退(否则'已迁移插件跑回旧通道'极难排查)
- registry.go: pluginEntryHash 候选顺序与 detectEntryKind 对齐(.bin 优先),
  否则增量重载会用错文件算 hash
- dynamic_proc_{unix,windows}.go: tryLoadProc 桩位(权限/类型校验已实现,进程管理属 Part 2)

Part 4 共享内存数据面(迁移评估 §3.3/§3.4/§3.7,最关键一环):
- proc/shm.go: 段布局(Header + ShmStageCtx 描述符数组 + append-only arena)
  相对偏移设计——各进程 mmap 到不同虚拟地址仍能正确解引用
  arena 用尽显式报错而非静默截断(§4.4 风险登记);Compact() 回收 append-only 垃圾
- proc/shmcodec.go: StageContext 16 字段跨进程编解码
  字段级描述符消除 lost update:只改 FinalText 的插件不触碰 ToolResults 描述符
  WriteDirty 只写脏字段——只读插件零写入,不可能覆盖他人改写
  Snapshot 存序列化字符串(切片共享底层数组的坑,C ABI 侧修 11.3 时已踩过)
  Extra 4 键提升为具名字段;Response 用标志位表达 nil vs 空串
- proc/lock.go: 锁仲裁回归内核(§3.7 已裁定,零 cgo)
  ForceRelease 实现实验 9 的崩溃自愈——排除 robust pthread_mutex 必要性
  重复加锁显式拒绝(否则死锁 30s);等待超时有补偿 goroutine 防锁泄漏

验证:
- proc 包 16 项测试全绿(含 -race):全字段往返/只读零写回/原地改切片识别/
  现网 sanitizer+weather 场景/5插件×40轮并发零丢失/arena 耗尽报错/压实不破坏字段/
  锁互斥·串扰拒绝·崩溃自愈·临界区串行化
- entry 分派 9 项测试全绿;go build ./... exit 0;接口冻结 git diff sdk/ 为空
2026-09-02 10:41:18 +08:00
dev
fe2fdc9692 docs: 固化当前分支对齐记录(update→feature/plugin-proc-migration) 2026-08-31 12:37:43 +08:00
dev
69a138c1af docs: Git 分支管理规范(main 长命 + feature/release/hotfix cherry-pick 流程)
- main 唯一长命、永远可部署;现网永远部署 release tag 构建
- feature/xxx 从 main 开出合回;release/vX.Y.Z 切出打 tag 构建
- hotfix 提交 release 分支 + 版本号分离提交,只 cherry-pick 修复回 main
- 明确'不需合并 release 回 main'(hotfix 已逐个 pick 回,避免冲突)
- 两仓(TrueAgent + homeagent-sdk)同用本规范
2026-08-31 12:36:44 +08:00
dev
2e2602f437 docs(plan): Part 0.1/0.2 勾选 + 迁移计划进度标记
- plan.md 11.1 (3 checkbox)、11.3 (3 checkbox) 全部勾选
- plugin-migration-plan.md: 0.1/0.2 标记  完成(含踩坑记录与待部署项)
2026-08-31 12:33:24 +08:00
dev
9bb9cb3b1a fix(cabi): applyStageResult 支持 diff 回传(plan 11.3 配套)
外部插件 bridge 模板改为只回传变更字段后(SDK 仓 5648519),内核侧配套:
- applyStageResult 的 tool_calls/tool_results 去掉 len(v)>0 拦截——改为键存在即应用,
  使插件「清空全部工具调用」的显式回传 [] 能被表达(旧插件仅 len>0 才带键,不误清空)
- 逐字段应用,未回传的键保持原值(diff 语义:只改变更字段,不覆盖他人改写)
- output_test.go 新增 TestApplyStageResult_ClearedSlicesAreApplied / _OnlyPresentKeysApplied

验证: go build exit 0; go test ./internal/plugin/... ./internal/agent/... 全绿
2026-08-31 12:30:04 +08:00
dev
b74ee15321 fix(cabi): output_send 等待真实发送结果,消除假成功(plan 11.1 / Part 0.1)
根因:CORE_REGISTER_OUTPUT_CH handler 无条件返回 {status:queued}+err=nil,
模型永远收到「已发送」,实际失败(如 meta 缺 user_id)只写日志,模型无法感知不会重试。
现网近 7 天成功 44 次、失败 2 次全部谎报成功。

改动:
- loader.go: 新增 awaitOutputResult(+可注入版 awaitOutputResultWith)+ outputSendTimeout=10s
  goroutine 执行 cgo 发送 + 带超时 channel 等结果 → sent / error / unconfirmed 三态
  handler 由 executeOutputSendTool 从 Go 侧调起,非 cgo 栈,不构成 cgo 嵌套
- output.go: executeOutputSendTool 识别 unconfirmed|queued,回报「发送结果未确认」而非「已发送」
- output_test.go: Success/Failure/Timeout 三用例

验证: go build exit 0; go test ./internal/plugin/... ./internal/agent/... 全绿
接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
2026-08-31 12:16:39 +08:00
dev
8a1a7df406 docs(plugin-arch): 外部插件多进程化适配计划(修改→审查→验证三步微循环)
7 个 Part,每 Part 一个微循环:
- Part 0 脆弱基线先行(11.1/11.3/11.6,现网止血,不依赖迁移)
- Part 1 加载分派骨架(entry 双通道共存,可回退前提)
- Part 2 子进程通道原型(spawn/JSON-RPC/procPlugin,参考 sidecar.go)
- Part 3 plugindev 工具链改造(.bin 产物,SDK 仓)
- Part 4 共享内存数据面(StageContext 并发改写,最高风险)
- Part 5 通知面(EvtRing + eventfd,post-and-forget)
- Part 6 迁移收尾(17 插件 + 删 cabi + 权限显式化)
每 Part 含修改对象/审查要点/验证标准 + 13 项最终验收 + 风险回退表
2026-08-31 12:01:11 +08:00
dev
fa99f6b4eb docs(plugin-arch): 外部插件接口不变矩阵(多进程化整改基线 v1)
钉死「暴露给外部插件的接口不变」约束的合同面:
- 合同面A: 公开SDK类型/接口(sdk/plugin.go等,纯Go无cgo)
- 合同面B: bridge 51个method id ↔ SDK方法映射表(RPC平移清单)
- 合同面C: StageContext 跨ABI 10字段 → 共享内存16字段(能力扩展)
- 外部插件实测触达面 ⊆ 公开SDK合同面(接口不变成立的依据)
- 迁移后新获得能力/刻意不给项/检查点
2026-08-31 11:52:31 +08:00
dev
304cad0648 docs(plugin-arch): 归档插件架构迁移评估 + plan 第11节整改计划
- docs/zh/架构迁移评估.md: C ABI→子进程+共享内存完整迁移论证(1621行)
- docs/zh/experiments/: 18项可复跑可行性实验(架构评估的所有数字来源)
- plan.md §11: 11.1~11.9 插件架构缺陷修复清单(唯一权威编号)
- main 保持干净,本批次为 update 特性分支的整改起点
2026-08-31 11:45:52 +08:00
48b5c2401c feat(ohos): MotionBase 统一按压动效 + 分层图标随主题切换
## 动效统一

各组件重复实现按压反馈(@State pressed + scale + animation + onTouch 四件套),
时长各写魔数导致全局手感不一致。

- 新增 components/MotionBase.ets:通用动效的"父组件"。ArkUI V1 的 @Component
  struct 无法继承他人 build,改用组合表达继承——调用组件把内容经 @BuilderParam
  内容插槽传入,MotionBase 在包装节点统一挂动效修饰器。
  pressEnabled 默认关(纯展示容器零开销);fillWidth=false 供气泡内卡片按内容
  自适应宽度;flexWeight 供等分排列按钮参与剩余空间分配。
  onPress 只作按压瞬时轻量钩子,导航/提交语义仍由调用组件 onClick 负责,
  避免"按下即触发"的手感偏差。
- Constants.ets 新增动效 token:ANIM_FAST(150) / ANIM_NORMAL(220) /
  ANIM_ENTER(280) / ANIM_SLOW(400) / PRESS_SCALE(0.97)。
  取值依据:状态切换 150-250ms(>300ms 显拖沓),大位移进出场 300-400ms
  才不突兀;曲线统一 EaseOut 起步快收尾缓。
- NavRow / StatusSummaryCard / AttachmentCard / 插件卡片等公共组件接入
  MotionBase,移除各自的按压四件套。组件专属动效(聊天输入框上弹、加号菜单
  浮起、折叠面板展开、toast 进出场)保留在各组件内,不塞进父组件。

## 图标与启动页随主题切换

原先直接指向位图 app_icon.png,浅色底被烧进图标,深色模式下桌面与启动页跳脱。

- 改用分层图标 layered_image:foreground 为字形,background(沉淀色)在
  base/ 与 dark/ 各一份,随系统主题切换。app.json5 与 module.json5 的
  icon 均指向 :layered_image。
- startWindowIcon 改用透明底 start_icon.png,配合 start_window_background
  的 base(#F1F3F5) / dark(#000000) 两份取值,浅深模式遮罩与图标都能对上。

## 验证

清空 entry/build 后全量重编:hvigorw assembleHap BUILD SUCCESSFUL(9.8s),
零 ArkTS 错误。提交内容已确认不含 build/ oh_modules/ .hap 与签名材料。
2026-08-30 17:23:40 +08:00
eba300aec2 chore: 同步 vendored SDK(qq v1.2.0 + plugindev bridge 修复)
third_party/homeagent-sdk 同步至 SDK 仓 61f307b:
- fix(plugindev): bridge 模板补 dispatchIO.SetToolBlocks
  SDK v0.9.2 给 IOInjector 加了该方法但 C ABI bridge 模板未同步,
  导致任何外部插件编译失败(missing method SetToolBlocks)
- feat(qq) v1.2.0: msg_id→get_history 7 天兜底(不缓存正文)
  + qq_list_chats / qq_mark_read 会话列表(按最新消息排序 + 未读数)
  + 中断模板补 fallback 路径与私聊 user_id

.gitignore 补 HarmonyOS 构建工具链缓存(.hvigor-home/ .npm-cache/ .ohpm/),
这些由 HVIGOR_USER_HOME/ohpm 生成,非源码。

go build ./cmd/homed/ 通过。
2026-08-30 17:14:07 +08:00
a3a5cd4fee fix(agent): 修正系统提示词的回复投递规则,补充事实性约束
问题一:提示词说反话,导致 qq 回复大量丢失。
v2 架构曾有「回复自动回投来源通道」的能力(IOManager.routes + RegisterOutputRoute),
但 304c3ae 'remove IO route mapping' 删掉了整套路由映射。此后:
- outputCh 唯一消费者(cmd/homed/main.go)只处理 memory_candidate,其余静默丢弃,
  output.go 末尾的 EmitTextTo 兜底成为死路
- ResponseCh 只剩 webui /api/v1/chat、cli、clawhubadapter 三处同步 HTTP 用途,
  不再承担渠道投递;qq 走 InjectInterruptText → interruptCh,ResponseCh 恒为 nil
- emitResponse 从不调 GetDevice(),纯文本对异步通道 = 丢弃
提示词却仍写着「直接返回纯文本即可送达,无需额外工具」「output_send 不是回复的必要步骤」。
实测近 12h 6 次 qq 输入仅 1 次送达,规律是 tools 含 output_send__qq 才到,否则全丢,
且 agent 自认为已回复。现改为明确区分同步/异步通道并要求显式 output_send__。

问题二:无事实性约束,工具失败时模型编造内容。
qq_get_message 30 次调用有 9 次返回 not_found:true(NapCat 响应解析失败),
模型未如实说明,转而虚构消息正文——包括一条不存在的 message_id=1321159191
(全 journal 零命中、不在任何调用记录里)配上完全虚构的正文
「我想搭一个 Dify 工作流,想做一个人脸识别系统 demo」,用户从未说过。
虚构内容经 formatMergedTimeline 回灌【对话时序】后被当作既有事实反复复述放大
(两条消息 reasoning_content 达 180KB / 220KB)。
现补充:工具返回 not_found/空结果必须如实说明不得猜测;【对话时序】是历史事实摘要
不是当前任务;无依据的人名/需求/数字/路径直接说不知道。

注:set() 用 INSERT OR IGNORE,改此默认值只影响新部署;
本机运行实例的 config.db core.agent.system_prompt 已同步更新(改前备份)。

验证:重新部署后实测 agent 明确回答 qq 需 output_send__qq 且纯文本会被丢弃;
对 message_id=1321159191 如实报告 not_found 并声明「正文完全不知道,绝不猜」。
2026-08-29 14:43:23 +08:00
8510a2f2eb fix(ohos): 移除硬编码后端凭据,地址规范化默认 https
- Model.ets: defaultConnection 不再内置 url/apiKey,改为空白模板
- 新增 normalizeBaseUrl:补协议(默认 https)、去尾斜杠、去误粘的 /api/v1
  裸域名走 http 会被反代 302 到门户站,客户端只拿到 404,表现为"连接直接失败"
- ConnStore: 增删改与读取存量数据时统一规范化 url
- 去掉 ensureDefaultConnection,首启不再预置连接,未配置时走统一提示
2026-08-29 13:49:30 +08:00
46e942f0c2 feat(ohos): 鸿蒙端聊天历史分段懒加载 + 首次提交完整工程
原有 cmd/ohos/HomeAgent 是未入库的鸿蒙原生 ArkTS 工程,本次随改动一并入库,
保证他人 clone 后可直接编译(含 .gitignore 排除 build/oh_modules/签名材料,
提供 build-profile.json5.example 模板)。

本次功能改动(与 WebUI / GUI 三端对齐):
- /chat/history 首屏只拉最新 CHAT_PAGE_SIZE(40) 条,1.26MB → 48.5KB
- 抽出 parseHistoryPayload() 复用解析,记录 chatOffset/chatHasMore
- 新增 loadOlderChat():向上滚动触顶(yOffset<60)懒加载更早页
- 工具调用 args/result 与 reasoning_content 完整还原,不做裁剪

构建验证:hvigorw assembleHap BUILD SUCCESSFUL(7.8s,ChatPage 零告警)
2026-08-29 10:25:12 +08:00
3de6b0426f revert(webui): 移除 chat/history 的 lean 裁剪模式
工具调用详情(args/result)与 reasoning_content 是排查问题和还原上下文的
关键信息,不应裁剪下发。瘦身只保留分页一条路径(limit/before 控制条数)。

- 删除 lean 查询参数与 leanChatMsgs()
- 删除 ChatToolCall.Truncated 字段
- 分页仍生效:limit=40 首屏 1287.9KB → 48.5KB,且 tool_calls args/result
  与 reasoning_content 完整下发
2026-08-28 23:38:11 +08:00
a0f7c9b5eb perf(webui): /chat/history 分段懒加载 + lean 瘦身模式
问题:/api/v1/chat/history 无分页返回全量 1.26MB(200条),移动端/弱网首屏很慢。
实测体积构成:tool_calls args/result 占 71.8%,reasoning_content 11.6%,content 仅 3.4%。

后端 handler.go:
- 新增查询参数 limit(1..maxChatHistory)/ before(游标)/ lean(瘦身)
- 全部可选,省略时返回全量 → 向后兼容旧客户端
- 响应加 total/offset/has_more,供前端判断能否继续向上加载
- lean=1 裁剪 tool_calls 的 args/result 并置 truncated 标记、省略 reasoning_content
- ChatToolCall 新增 Truncated 字段(前端可显示'详情需展开加载'而非误判执行失败)

前端 WebUI + GUI(两端同步):
- 首屏只拉最新 40 条(CHAT_PAGE_SIZE),1.26MB → 48.5KB(lean 26KB)
- 新增 loadOlderChat():滚动触顶(<60px)自动拉上一页,插入后按 scrollHeight 差值补偿
  滚动位置避免视口跳动
- state 新增 chatOffset/chatTotal/chatHasMore
- syncChatFromHistory 改用重叠区对齐(分段后不能再用长度比较判断新增),
  找不到重叠点安全退化为全量刷新最新页

实测:无参数 1287.9KB / limit=40 48.5KB / limit=40&lean=1 26.0KB;
before 游标翻页、limit 越界/非法值、before=0 等边界均正确
2026-08-28 23:21:13 +08:00
5fccc31afc fix(plugin): DisablePlugin 禁止禁用未安装插件,防止脏写 disabled_plugins
问题:POST /api/v1/plugins/<name>/disable 对不存在的插件也会把它写进
disabled_plugins 表(registry.go DisablePlugin 无条件 AddDisabledPlugin),
产生脏数据堆积,且同名插件日后真实安装会被误判为已禁用。

修复:
- registry.go 新增 pluginInstalled(name):已加载 / 已注册工厂(内置) / 插件目录存在,
  任一命中视为已安装
- DisablePlugin 开头校验:未安装返回 'plugin X not installed',不写 disabled_plugins
- webui handler:未安装→404,已禁用→409(原都返回500);enable 失败含'failed'→404
- 新增 registry_disable_test.go:覆盖未安装拒绝/内置判定/目录存在判定/普通文件不算

本机端到端验证:disable 不存在插件返回404且表无脏数据;真实插件 disable/enable 正常
2026-08-27 23:29:49 +08:00
2564e53342 feat(webui): 请求IP日志记录中间件
- clientIP(): 提取真实客户端IP,优先 X-Forwarded-For(取第一跳) / X-Real-IP,回退 RemoteAddr
- logged(): 最外层中间件,覆盖全部路由,记录方法/路径/IP/认证方式/状态码/耗时
- 认证方式判定: api-key(X-API-Key/Bearer) / session(cookie) / none
- SSE长连接(/chat/events)启动即记,不阻塞等待完成
- statusWriter 捕获响应状态码,支持 Flush/Hijack 透传
- 用于排查'谁调用了什么接口'(如插件禁用等变更操作)
2026-08-27 23:04:48 +08:00
b902d61bb9 fix(gui): SSE消息同步不及时,同步webui修复
- 新增 syncChatFromHistory():增量同步,仅追加新消息不重建已有DOM→无闪烁
- handleSSEEvent 监听 sync_required 事件 → 增量补拉历史
- SSE 断连(pump退出)后先 syncChatFromHistory 补偿再重连
- startUptimeTicker 增加30s轮询兜底(补偿跨渠道消息丢失,CLI连接跳过)
2026-08-27 11:29:20 +08:00
4def5e9ed4 fix(webui): SSE消息同步不及时 + 后端缓冲加固
handler.go:
- writeCh 512→2048,新增 sendSSE() 函数(100ms短超时重试替代立即丢弃)
- After(id) 为空时发送 sync_required 事件通知前端补拉历史
- 批量 flush 阈值 64→128

dashboard.html:
- 新增 syncChatFromHistory():增量同步,仅追加新消息DOM节点,不重建已有消息→无闪烁
- 监听 sync_required 事件触发增量补拉
- SSE onerror 立即 close 阻止双连接竞态,2s后手动重连(原5s)
- init 顺序:先 loadChatHistory 再 connectSSE(避免事件与历史加载竞态)
- 30s轮询兜底(补偿SSE断连窗口期丢失的跨渠道消息)
2026-08-27 11:10:53 +08:00
14f3605f6e chore: sdk v0.9.2 vendored副本同步 + go.mod 依赖更新
- third_party/homeagent-sdk/meta/meta.go: 0.9.1 → 0.9.2
- go.mod: require v0.9.2(replace 保留,指向本地 vendored 目录确保可编译性)
- CoreVersion 同步更新
2026-08-27 09:36:49 +08:00
c44ec0f210 feat(waiter): daemon模式 + localuse本机外设插件
waiter 新增 --daemon 后台驻留模式 (daemon.go):
  - 维持 homed 连接,TUI实例经 Unix socket 接入
  - 单客户端串行模型:每个TUI独占homed响应,新客户端回放缓冲(256行)
  - 设备桥场景可无 homed 运行(纯设备桥驻留)
  - 设备桥看护循环:WS断开自动重连(3-5s间隔)
  - conn.go 新增 daemonConn 类型,dial() 优先检测 daemon

localuse 插件 (internal/plugins/localuse/):
  - local_screensee / local_camerasue / local_speakeruse
  - local_screensue / local_clipboardsee / local_clipboardsue / local_computeruse
  - 跨平台实现(Linux/macOS/Windows),能力与 waiter device.go 对齐
  - headless服务器上缺依赖工具自动返回安装提示

SDK sync: third_party SetToolBlocks 多模态类型同步
README 补充 daemon 模式使用文档
2026-08-27 09:27:15 +08:00
b777322b95 feat(multimodal): 内置多模态感知插件 + process.go 原生支持 tool message 多模态块
【新插件 internal/plugins/multimodal】
- see_picture(path): 读取本地图片/URL,base64 注入 image_url block,
  模型在下一轮 LLM 请求的 tool message 里直接看到图(1024×1024 图约 8500 token)。
  自动识别 MIME,限 3MB 防爆 context。
- see_video(path, frames): ffmpeg 提取关键帧,多帧作为 image_url block 注入。
  默认 4 帧,最大 10 帧,每帧限 2MB。
- listen(path): 读取音频文件,转为 audio_url block 注入,支持 mp3/wav/ogg/m4a。
  限 5MB。

【内核多模态 tool message 支持】
- agent/api 新增 ToolOutput 类型(为后续 handler 直接返回 blocks 预留)
- SDK 公共层新增 ContentBlock/ImageURL/AudioURL(OpenAI 多模态格式)
- IOManager 新增 SetToolBlocks/ConsumeToolBlocks(interface{} 避免循环依赖)
- PluginSDK.SetToolBlocks(blocks) 插件工具调用后注入 blocks
- ioAdapter 桥接 IOInjector.SetToolBlocks
- process.go 工具执行后消费 pending blocks → 追加到 tool message 的 Blocks 字段
  → MarshalJSON 输出 content 数组格式 → LLM 看到图/音频

【验证】
multimodal_see_picture 注入 1024×1024 PNG 后 llmsproxy 统计:
  prompt_tokens=44407(含 ~8500 image token),模型正确描述了图片内容。
2026-08-27 08:39:21 +08:00
f0cdbdb030 feat(sdk): SettingsAPI.DataDir() 插件专属数据目录 + ai_image 本地交付
【SDK DataDir API】
- SettingsAPI 新增 DataDir() string:返回插件专属数据目录
  <data>/plugin_data/<name>(内核保证存在),解决此前插件只能
  靠 GetCore("daemon.data_dir") 手工解析的缺陷
- settingsImpl 新增 dataDir 字段 + SetDataDir;Registry buildSDK
  注入(<data>/plugin_data/<name> 并 MkdirAll);main.go 接线
- cabi 新增 CORE_SETTINGS_DATA_DIR (id 51);plugindev dispatchSettings
  模板补 DataDir() 实现

【ai_image 交付本地路径】
- 生成后下载临时 S3 URL 到插件数据目录,返回本地文件路径(永久不
  过期),而非 1 小时过期的 S3 URL。带 UA 规避图床对无 UA 客户端拦截
  (此前 agent 裸 curl 验证被拒导致误报失败)
- 返回 local_paths 字段 + 提示用 output_send(type=image) 展示

端到端:ai_image_generate → plugin_data/ai_image/*.png 有效 PNG(1024²),
经 llmsproxy→siliconflow 生成。
2026-08-26 21:40:29 +08:00
d0fc7e03c1 feat(ai_image): base_url 设置项支持自定义 OpenAI 兼容网关 v1.1.0
generateOpenAI 原硬编码上游为 https://api.openai.com,无法接入
本机 llmsproxy(ModelRouter) 等标准 OpenAI 兼容网关。新增:

- 设置项 base_url(string,默认空):为空保持官方直连;非空时
  上游改为 {base_url}/v1/images/generations(约定不带 /v1 尾缀,
  拼接时自动去重避免 /v1/v1)
- Plugin.baseURL 字段 Start 时读入;注册 ConfigDef(category
  ai_image)供 WebUI 配置页展示

部署:plugindev 打包 1.1.0 → pluginmgr overwrite:true 升级安装
config_kept=true → plgreload 热加载。

配置(category ai_image):api_key=sk-gw-local-0001,
base_url=http://127.0.0.1:8081, provider=openai,
model=Kwai-Kolors/Kolors, size=1024x1024

验收:agent 实调 ai_image_generate 出图成功——llmsproxy audit
记录 type=image src=siliconflow model=AUTO ok=true(经网关非直连),
返回临时 S3 URL 下载为有效 PNG (1024x1024)。
2026-08-26 19:46:59 +08:00
5fb15f5045 feat(a2a/acp): 会话历史查询 session.get + 客户端 session_id 透传
a2a:
- tasks.get 从空壳改为按 session_id 返回会话内近 N 条消息(默认10);
  新增 session.get 别名同语义
- a2a_query(出站)接受 session_id 参数透传给目标 agent,
  响应回显 session_id + 延续提示
- A2AParams/A2AResult 加 session_id 字段;工具描述补说明

acp:
- 新增 JSON-RPC method session/get:按 session_id 返回近 N 条消息
- acp_query(出站)接受 session_id 透传给 session/new,
  响应回显 + 延续提示
- params 结构体加 limit 字段

端到端验证(回环本机):
  a2a tasks.send→建会话;session.get→返回[user/agent]交替消息列表
  同 session 第二轮延续上下文正确(记数字→答数字)
  acp session/new + session/get 同样通过
2026-08-26 19:30:32 +08:00
14aa0c880b fix(a2a/acp): 回复闭环 + 会话延续 + 同步注入(不再抢占打断)
【问题】
1. 入站请求用 InjectInterruptText 抢占打断当前对话,立即回 202 submitted,
   agent 的回复 emit 到未注册的 channel(a2a/acp)→ 请求方永远拿不到回复文本,
   只能干等超时。(acp 的 session.Replying 从未被填真回复 → SSE 永远 "(未收到回复)")
2. 无法指定/延续 session:a2a 无 session 概念;acp session/new 每次新建、
   不接受调用方 session_id,多轮上下文断裂。
3. a2a/acp 通道未注册为输出设备 → emitResponse 的回复无落点,
   output_list_channels 也不可见 → agent 困惑"回复该发到哪"。

【修复】
- 入站改用 SDK InjectInputSync 同步注入:阻塞等待 agent 处理完成,
  直接把最终回复文本返回给 HTTP 请求方(不再回 202)。
  这是 A2A/ACP 协议的合理形态——客户端控制超时,服务端同步返回。
- session 支持:a2a tasks.send 与 acp session/new 均接受 params.session_id,
  指定则延续已有会话(拼上下文前缀),不指定则新建并返回 session_id。
  单会话保留最多 10 轮历史防膨胀;30min GC 清理 2h 未用会话。
- a2a/acp 注册为输出通道(RegisterOutputChannel):回复有落点,
  output_list_channels 可见,agent 可主动 output_send 推消息。
- 注入提示词明确"直接以文本回复即可,无需调 output_send"——
  agent 不再把回复走 queued 入队而返回干净文本。

端到端验证:
  单轮:status=completed, reply=真实回复文本(非 submitted)
  多轮:同 session_id 第二轮准确复述第一轮问题(上下文生效)
  a2a v1.2.0 / acp v1.1.0 安装 config_kept=true
2026-08-26 19:16:46 +08:00
a82b5b1626 fix(webui): /files/ /uploads/ 静态路由接受 X-API-Key 鉴权
requireWeb 此前只认 cookie session,ArkTS/GUI 等 API key 客户端
加载 agent 输出的附件 URL(/files/xxx、/uploads/xxx)一律 302 到
/login。现 requireWeb 先校验 validAPIKey 放行非浏览器客户端;
无凭证仍 302 登录页,行为不变。

handleFiles/handleUploads 已有严格防穿越(拒 / \ ..),暴露给
key 客户端安全面可控。

端到端验证:X-API-Key 访问 files/uploads 均 200,无凭证 302。
2026-08-26 18:54:26 +08:00
2d5d246606 fix(webui): SSE writer 批量合并 flush 修复流式 delta 丢包延迟
【根因】SSE writeCh 缓冲仅 64 且 writer 每条 delta 单独 flush。
reasoning/content 增量是高频小包(单轮 200+ 条),socket
写慢时 writeCh 迅速填满,delta 大量 DROPPED——浏览器收不到
逐 token 增量,只能等最终 agent_output 整段到达,体感明显延迟。

实测一轮 16s 纯文本回复:content_delta DROPPED 202 次、
reasoning_delta DROPPED 345 次,前端全程无流式渲染。

【修复】
- writeCh 缓冲 64 → 512
- writer 加 16ms 批量合并窗口:窗口内收集的增量一次性 flush,
  或满 64 条立即 flush;done 退出前 flush 残留。
  flush 次数从 N 降到约 N/64,socket 写压力骤降。

修复后实测 0 DROPPED,delta 全部实时送达前端。
2026-08-26 17:59:33 +08:00
41c46d131e fix(plugins): bili output_dir 系统目录黑名单 + recoverydiag db_path 沙箱限制
P3 bili: output_dir 配置项此前未校验,yt-dlp 可被配置写到任意系统目录。
加系统目录黑名单(/、/etc、/usr、/var 等),命中直接拒绝执行。

P4 recoverydiag: db_path 参数 LLM 可控,可探测读取任意 sqlite 文件。
现强制限制在 data 目录内(前缀校验),越界返回提示。

两插件重打包升版(bili 1.2.0 / recoverydiag 0.2.0)安装验证
config_kept=true,内核重启加载正常。
2026-08-26 16:57:34 +08:00
75447aa7f8 fix(plugins): 全插件审查修复(qq/a2a/memo/calendar/rss/browser)
17 个生产插件全量审查:编译/vet 全过、无硬编码密钥、内核
executeToolCall 有 panic recover + 超时兜底。发现并修复:

P1 qq: downloadURL 裸 http.Get 无超时 → 120s client(挂起泄漏)
P2 a2a: inbound http.Server 无超时 → Read 30s/Write 120s/Idle 60s
   (慢速连接占用 goroutine)
P5 memo/calendar/rss: 数据持久化直写 → atomicWriteJSON temp+rename
   (崩溃截断 JSON 丢全部数据)
P6 qq: 3 处后台 goroutine(已读标记/rcon转发/下载任务)加 recover
   (工具调用外 panic 会带崩 homed 进程)
P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程

已知可接受项:bili output_dir 用户可控(本机单用户)、recoverydiag
db_path 可读任意 sqlite(诊断工具固有权限,argv 传参无注入)。

全部经 plugindev 重打包 v+0.1 安装验证 config_kept=true。
2026-08-26 16:46:06 +08:00
ddef1956b5 fix(agent): 流式并行 tool_call 按 JSON index 分桶,修复空参数调用
【根因】内核流式解析层丢弃了上游 SSE 分片的 OpenAI index 字段:
- openAIToolCall 结构体无 index 字段,JSON 解析即丢
- homed 的 openai.lua 转换为扁平结构时同样未透传 index
- accumulateStream 退而用 Go range slice 序号做累积桶 key,
  但每个 SSE chunk 只含一个 tool_call 元素,序号恒为 0

于是并行多工具调用(index=0,1,2,3)的所有分片全部写入同一个桶:
name 相互覆盖、args 碎片混拼成非法 JSON → parseToolArgsJSON
失败返回空 map → 工具以空参数被调用(spawn_child 报'请提供 task'、
cmd_run 报'command is required'等),agent 只能串行重试自愈。

单工具场景只有一个 index 无污染,故简单请求一直正常;
pi 直连同一 llmsproxy 正常(其实现标准按 index 累积)。

【修复】
- ToolCall 增加 StreamIndex(json:stream_index),openAIToolCall
  解析上游 index 并透传;openai.lua 输出 stream_index 字段
- accumulateStream 以 tc.StreamIndex 为累积 key
- flushToolCall 区分三种空参:未收到分片/碎片非合法 JSON/合法空
  对象({}),分别打诊断日志,避免误报
- 回归测试 TestAccumulateStreamParallelToolCallsByIndex 模拟
  4 路并行分片流验证按 index 正确分组与参数完整性

另含 spawn_child max_turns 参数、child_result 运行中状态区分、
provider 层非流式空参诊断日志。
2026-08-26 16:10:02 +08:00
6009ce801f feat(agent): spawn_child 支持 max_turns + 并行策略引导
1. spawn_child 新增 max_turns 参数(1-30,默认 5)
   子 Agent 工具轮数此前硬编码 5,复杂任务跑不完即截断。现可按任务
   复杂度调整;返回消息带轮数上限提示。

2. 工具描述与 system prompt 增加并行策略引导
   明确'多个互不依赖的子任务应并行 spawn 多个子 Agent,不要串行
   逐个执行;长耗时任务交给子 Agent 避免阻塞对话'——针对生产实例
   观察到的 agent 倾向自己串行处理所有子任务的问题。

小宅自定义 prompt 同步补充并行策略段。
2026-08-26 13:38:27 +08:00
c431af0902 fix(browser): render 改走共享后端(带登录态),v2.2.1
browser_render 原实现每次独立 chromium --dump-dom 冷启动:不带共享
profile 登录态、每次 ~2s 启动开销。现改为主路径经 CDP 9222 开临时
标签页渲染(Title+OuterHTML)后即关——登录态与 interactive 会话一致;
后端不可用时保持 dump-dom failback(补 30s 超时防挂死)。
返回新增 mode 字段(backend-tab / local-dump-dom)便于 agent 判断。

修复过程中清理 python 重写残留的重复函数定义。
2026-08-26 12:38:53 +08:00
bb0c274563 feat(browser): systemd 托管共享浏览器后端 + 全机 agent 标签页架构 v2.2.0
浏览器插件重新定位为本机所有 agent 的统一浏览器操作壳:

1. 主路径:homeagent-browser.service(systemd 托管)
   - chromium --headless --remote-debugging-port=9222
     --user-data-dir=<data>/browser_profiles/shared
   - 独立于 homed 生命周期,崩溃自动重启,登录态持久保存
   - 本机所有 agent(HomeAgent/pi/opencode/deepseekharness 等)
     共享同一实例:登录一次全机可用

2. 每 agent 一个标签页(CDP Target 隔离),同 source 复用已有标签页

3. browser_start 探测链:CDP 9222 在线 → 直连;服务已装未跑 →
   systemctl start 等待就绪;未安装 → 返回 need_install+guide 引导

4. 新工具 browser_install:探测 chromium 二进制 → 写 systemd 单元 →
   daemon-reload + enable --now → 验证 CDP → 返回全机共享使用指南
   (含其他 agent 经 connectOverCDP 接入的说明);无 root 权限时
   返回手动安装命令清单

5. failback:无法联网装 chromium 的机器,重试 start 自动降级本地
   spawn 临时模式(登录态不持久,仅保证功能可用)

工具链打包 v2.2.0 已部署验证:need_install 引导 / install 装服 /
start 复用与开新标签页全链路通过。
2026-08-26 12:22:15 +08:00
c945eace1a fix(webui): 附件死锁 + qq 插件文件发送收敛到 output 通道
1. webui 附件死锁修复(output_send__webui 发图 60s 超时根因)
   EventAgentOutput 订阅者已持 chatMu,附件分支调 addChatMsg(内部
   再次 Lock)——sync.Mutex 不可重入导致 Publish 永久阻塞,工具超时、
   图片永远发不出来。改为持锁状态下直接操作 chatHistory+persist。

2. qq 插件 image/file 分支收敛到 output 通道
   output_send__qq 的 type=image/file 此前只透传 payload 给 CQ 码,
   发本地文件必须绕道 upload_group_file 独立工具。现在与 voice 分支
   同模式:本地路径拷入 NapCat 共享目录转 file:///app/files/<name> URI,
   http(s)/file:// URL 保持透传。upload_group_file 保留作为群文件柜
   专用入口。

验证:工具链重编译 → qq.hmap 重打包 → pluginmgr overwrite 安装
(config_kept=true)→ agent 经 output_send__qq 用本地路径发图成功,
mascot.webp 已落共享目录。
2026-08-26 11:14:03 +08:00
cb76828e43 fix(cmd/files/webui): shell 语义修复 + 根沙箱误判 + 上传注入走 interrupt + UI 区分附件来源
1. cmd_run 改经 /bin/bash -c 执行完整 shell 语法
   旧实现 shellUnquote 拆词后直接 exec:'pwd; ls /' 变成执行名为
   'pwd;' 的程序(exit -1)、heredoc 被截断、管道/命令替换全部失效——
   agent 多次反馈命令解析奇怪即此。危险命令拦截(kill homed 等)保留。

2. files 沙箱根目录判断修复
   pathWithinSandbox 在 base='/' 时 prefix 变 '//',所有绝对路径误判
   逃逸(生产实锤:files.dir=/ 下 files_read/write/ls 全部报 outside
   sandbox)。根沙箱直接放行。

3. webui 文件上传注入改走 interrupt(system 角色)
   文件元信息不再混入用户消息气泡;用户附言作为正常消息先行注入,
   文件说明紧随其后以 no_memory interrupt 补充——对齐 terminal_watch/
   timer 工具提醒模式,聊天流保持干净。

4. 前端附件卡片按 role 区分来源
   user=右侧+『你发送的』标签+accent 底色;assistant=左侧+『小宅发送的』。
   📌 emoji 按钮换为 SVG 图标,前端 emoji 清零。
2026-08-26 10:24:47 +08:00
534232b768 fix(webui): 附件消息历史持久化 + 用户文件上传(对齐 qq 插件收文件设计)
1. 附件展示链路补全
   - ChatMsg 新增 Attachment 字段(type/url/size/name),channel_output
     订阅提取 output_type/url/size 存档——修复刷新后附件变纯文本路径
     (如 '/tmp/homeagent.png')的问题
   - EventRawInput 订阅支持 upload_* 字段:用户上传的消息也带附件卡片

2. 用户→agent 文件上传(POST /api/v1/chat/file)
   设计对齐 qq 插件收文件模式:
   - multipart(file + message) 落盘 <data>/uploads/<原文件名>(重名加
     毫秒后缀,路径穿越消毒),单文件上限 64MB
   - 注入文本「[用户通过 webui 发送了图片: 名字 (大小)] + 保存路径」,
     agent 用 files_read 等工具按路径消费
   - 回复走与普通消息相同的 SSE 流式管道
   - GET /uploads/<name> 下载,与 /files/ 同一鉴权与安全模型

3. 前端
   - 输入区 📎 按钮;拖拽到聊天区直接发送;粘贴截图即发送
   - 本地预览用 URL.createObjectURL 即时显示
2026-08-26 10:03:46 +08:00
0afa84a13f feat(webui): agent 可向 webui 发送图片/文件,前端内联展示与下载
输出通道能力升级:webui 通道从 CapText(1) 扩展为
CapText|CapFile|CapImage(7),agent 经 output_send__webui 即可发送
image/file(此前仅文本)。

服务端:
- stageWebFile 把本地路径文件拷贝到 <data>/webui_files/<hex>.<ext>
  (随机名防猜测、危险扩展名强制 .bin),http(s) URL 直接透传不落盘
- 新增 GET /files/<name>(requireWeb 与 dashboard 同鉴权):扩展名
  白名单映射 Content-Type,图片/音视频 inline、其余 attachment 下载,
  nosniff + 路径穿越拒绝
- SSE agent_output 事件携带 output_type/url/size 字段

前端(dashboard.html):
- channel_output 识别附件消息:image 渲染内联预览(点击原图)、
  file 渲染下载卡片(含大小);formatBytes 人性化显示

典型场景:agent 把 remotedevice 回传的录像/截图(device_media/*.mp4)
直接发给 webui,用户在聊天里看到视频预览或一键下载。

新增 TestStageWebFileAndDownload / TestHandleFilesAuth 覆盖。
2026-08-26 09:17:07 +08:00
fad490dca0 fix(remotedevice): 媒体回传落盘 + webui SSE panic + GUI 相机跨平台
1. remotedevice 媒体落盘(核心改动)
   设备录像/照片二进制聚合后写入 <data>/device_media/<reqID>.<ext>,
   cmd_result 返回 file 路径,不再 base64 内联——10s 录像数 MB 的
   base64 会撑爆 LLM 上下文与工具结果管道。未配置目录时保持旧内联行为。
   新增 TestWSBinaryMediaToFile 覆盖。

2. webui SSE 'send on closed channel' panic(生产单日 4924 次)
   handleChatEvents 的 defer close(writeCh) 与 Subscribe 回调闭包竞态:
   handler 退出后总线仍可能异步触发回调向已关闭 channel 发送。
   改为 writer goroutine select on done 退出,不 close channel;
   defer 中等待 writerDone 保证无残余写入。
   顺带补 mockPluginMgr.StopAndUnload(ae42e48 接口变更漏改测试)。

3. GUI camerasue 平台分支
   ffmpeg 参数原硬编码 Linux v4l2(/dev/video0),Windows 上必然失败。
   现按平台探测:win32=dshow(枚举设备名取第一个视频设备)、
   darwin=avfoundation、linux=v4l2;录像编码 Windows 交给 mp4 muxer 默认。
2026-08-26 01:25:40 +08:00
168c88593d fix(remotedevice): WS 握手 Accept 改用 SHA-1(RFC6455 合规)
wsAccept 误用 sha256 计算 Sec-WebSocket-Accept,RFC6455 §4.2.2 规定
必须为 base64(SHA1(key + GUID))。后果:所有标准 WS 客户端(浏览器/
Electron/各语言标准库)校验 Accept 失败后立即断开连接,设备永远无法
完成 hello 注册——devicedetect 恒返回空列表,核心看不到任何设备,
而设备端本地授权状态正常,形成'已授权但核心看不见'的表象。

验证:openssl sha1 对照 + 干净实例端到端握手 Accept 完全一致。
2026-08-25 23:48:20 +08:00
ae42e486de feat(pluginmgr): 插件更新接口(upgrade/downgrade 保留配置)+ skill_install overwrite
内核 Registry 拆出 StopAndUnload:
- 停止并从注册表移除插件但保留 config_<name> 表
- 不触发 onRemove 回调(那是删除专用语义)
- RemovePlugin 改为追加清理配置表示清除,更新场景调 StopAndUnload

pluginmgr:
- installFromData/installFromURL/installFromPath 加 overwrite 参数
- 已存在+overwrite=true:StopAndUnload→备份旧目录→解压新包→失败回滚→
  返回 action=upgraded/downgraded/reinstalled+previous_version+config_kept
- 已存在+overwrite=false:返回 error+hint(指向 overwrite 用法)
- cmpVersion 点分版本号数字比较(非字典序)
- 测试覆盖:首次安装→重装拒绝→升级保留配置→降级→失败回滚

skill_install 加 overwrite 参数:
- 同名技能存在时先卸载旧实例+删除目录再安装新包

SDK PluginMgr 接口同步加 StopAndUnload(name string) error

工具链 plugindev 已重建到 /usr/local/bin(7/29→8/25 版本)
QQ 插件诊断日志版(webhook recv 到达+isAtBot 失败日志)已打包并
通过 upgrade 接口热更新部署,配置保留验证通过。
2026-08-25 22:02:17 +08:00
5f126d4d10 feat(skillmgr): 原生技能管理器插件 + OpenClaw 兼容层职责分离
新增 internal/plugins/skillmgr(native skill 全生命周期 owner):
- skill_list/info/load/unload/enable/disable/create/export/install
- skill_create 两步式:先生成骨架模板,LLM 补全后传 content 覆盖写入
  (plugin.ValidateSKILLContent 校验)并自动加载生效
- .skm 分发包(tar.gz):packSkill/unpackSkill 含 TarSlip 防护
  (拒绝绝对路径/../逃逸、强制单根目录、校验包内 SKILL.md)
- skills 目录扫描:纯 SKILL.md/skill.json 条目归本插件;
  sidecar(main.js/main.py)/OC plugin(openclaw.plugin.json) 留给兼容层

clawhubadapter 职责分离(OpenClaw 兼容层不再持有 native skill):
- 删除 p.skills 字段与 default 分支 LoadSKILL 逻辑
- 发现纯 SKILL 条目改为发布 events.EventSkillDetected 移交事件,
  由 skillmgr 订阅注册;启动时序 c<s 下全扫兜底,事件用于热新增
- claw_list/plugin_info 不再输出 SKILL 段,统一走 skill_list

方案B prompt 注入:
- agentCore 新增 SkillIndexProvider 接口 + SetSkillIndexProvider
- buildSystemPrompt 注入【可用技能】轻量索引(名称+版本+描述),
  LLM 匹配场景时主动 skill_info 拉全文按文档执行
- main.go 在插件加载后将 skillmgr 实例接线到 agent

内核小修:
- extractDescription 跳过 YAML frontmatter 块(此前所有带 frontmatter
  的 SKILL.md 描述都被误判为 '---')
- extractField 剥离 YAML 成对引号(version: "1.0" 不再带尾引号)
- plugin.ValidateSKILLContent 导出供生成侧校验
2026-08-25 20:25:57 +08:00
51eb98e0ae fix(mcp+adapter): 流式 tool_calls 解析修复 + MCP transport 超时保护
openai adapter transform_stream_chunk:
- OpenAI 流式分片是嵌套格式 function.{name,arguments},原样透传后
  json.Unmarshal 到扁平 ToolCall{name,arguments} 时 name 恒为空,
  accumulateStream flushToolCall 因 acc.name=="" 静默丢弃整个工具调用
  (流式路径自上线起 tool_calls 全部丢失的根因)
- 现在正确解包 function.name → name,function.arguments → raw_arguments
  (保留原始 JSON 字符串分片,由 accumulateStream 按 index 拼接)
- 不按 name 过滤分片:OpenAI 流式续传块 name 为空但携带 arguments 分片

mcp stdio/sse transport:
- stdio Send() 无超时:server 进程卡死时插件加载永久阻塞
- sse http.Client 无超时:远程 server 网络抖动/无响应时永久阻塞,
  导致 webui 等后续插件全部无法启动(生产实例偶发启动卡死根因)
- stdio 加 60s 请求超时;sse client 加 30s 整体 + 10s 拨号超时
2026-08-25 18:59:00 +08:00
287 changed files with 45919 additions and 2713 deletions

24
.gitignore vendored
View File

@ -10,7 +10,6 @@ data/
.tmp-plugins/
.go/
.local/
internal/meta/
*.hmap
dev/
@ -24,6 +23,11 @@ cmd/gui/dist/
.gopath/
# SDK 工具链 — 核心仓不追踪
#
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
# example/ 下已跟踪的 20 个文件plg.json + plugin.go早于本规则
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
third_party/homeagent-sdk/bin/
third_party/homeagent-sdk/tools/
third_party/homeagent-sdk/package/
@ -36,13 +40,23 @@ third_party/homeagent-sdk/example/
codegraph.json
# 运行时产物(不提交)
adapters/
knowledge/
memory/
scripts/
#
# ⚠️ 这些必须带前导斜杠。不带斜杠的模式(如 `memory/`)会匹配**任意层级**的
# 同名目录,把 internal/memory/、internal/knowledge/、deploy/scripts/ 一起吞掉
# ——24 + 3 + 1 个已跟踪源码文件曾因此落在 ignore 规则下,只靠「已跟踪
# 文件不受 .gitignore 影响」这条 git 规则兜着,新增文件会默默不入库。
/adapters/
/knowledge/
/memory/
/scripts/
terminal_locked_log.txt
dist/
.pi-glla/
.omo/
.pi/
# HarmonyOS 构建工具链缓存HVIGOR_USER_HOME / ohpm 生成,非源码)
.hvigor-home/
.npm-cache/
.ohpm/

View File

@ -12,6 +12,8 @@
homed内核零 IO PluginSDK 插件所有 IO 能力
```
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
## 设计要点
**核心域与应用域分离** — 内核职责限定为 LLM 编排、记忆管理与知识检索;所有 IO 能力(消息收发、文件读写、网络请求、硬件交互等)由插件实现。这种划分在 Agent 框架层面进行领域边界界定,内核与插件各有其责任范围。
@ -151,6 +153,23 @@ make build build-cli
echo "你好,记住我喜欢喝咖啡" | ./build/waiter
```
### 后台驻留模式daemon
waiter 也支持后台驻留,保持与 homed 的持久连接并等待 TUI 实例接入,适合让 agent 主动召唤用户/设备桥持续存活:
```bash
# 后台驻留(默认连 ~/.homeagent/cli.sock
./build/waiter --daemon
# 指定 socket
./build/waiter --socket /path/to/cli.sock --daemon
# 随后任意 TUI/一行实例都会自动接入正在运行的 daemon而不是直连 homed
./build/waiter
```
daemon 监听 `~/.homeagent/waiter.sock`新客户端连入时会回放缓冲的最近对话256 行),断连后 daemon 持续存活、自动重连 homed并保持设备桥若配置了 `device_gateway`/`device_token`)。
API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQLite 中。
## 代码结构
@ -163,7 +182,7 @@ internal/
├── agent/api/ LLM Provider + 8 个 Lua 适配器
├── memory/ 三层记忆Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
├── knowledge/ 知识库(文件系统 + TF-IDF
├── plugin/ 插件注册表 + .so 动态加载器
├── plugin/ 插件注册表 + 子进程加载器stdio RPC + 共享内存段 + 事件环)
├── plugins/ 内置 11 个插件webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr
├── sdk/ PluginSDKTool/Stage/Event 三通道)
├── config/ SQLite 配置中心
@ -174,7 +193,9 @@ internal/
## 项目状态
**v0.9.0** — C ABI v2外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x → ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。
**v1.0.0** 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**v0.9.0** C ABI v2外部插件 Stage 回调支持写回`invoke_stage` 增加 result 输出插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题配套 SDK 提供增强版 sanitizer 示例 UTF-8/U+FFFD/ANSI 转义全链路清洗)。** ABI 已随 v1.0.0 退场。**
**v0.8.0** 核心可用插件系统增强内置 20+ 插件外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库新增输入通道 `NoMemory`/`Cleaner``ChannelDef`插件禁用/启用系统CLI + WebUI`plugindev` 工具链完成 C ABI `ChannelDef` 传递
@ -186,6 +207,23 @@ internal/
- [Lua Adapter](assets/docs/zh/ADAPTER.md) | [English](assets/docs/en/ADAPTER.md)
- [知识库演示](assets/knowledge/homeagent_architecture/content.md)
## 下载
[Releases](https://gitcode.com/JianFeeeee/HomeAgent/releases) 提供三种变体
| 变体 | 内容 | 适用 |
|---|---|---|
| **full** | homed + waiter + 桌面 GUI + systemd unit | 单机全功能 |
| **server** | homed + waiter + systemd unit | 服务器无桌面环境 |
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
- Linux`.deb`amd64/arm64)、`.rpm`x86_64)、`.tar.gz`
- Windows`HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe`NSIS 安装向导
- 免安装`homeagent-bin-<os>_<arch>.tar.gz` homed/waiter/initconfig
- 校验`SHA256SUMS`
macOS `homed` 需在原生 macOS 构建CGO + sqlite3发布包仅含 `waiter`/`initconfig`
## 构建
```bash

View File

@ -12,6 +12,11 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
homed (kernel, zero IO) PluginSDK plugins (all IO capabilities)
```
**Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over
stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
`plugin.bin` gives true hot-reload.
## Design Principles
**Separation of Core Domain and Application Domain** — The kernel's responsibilities are limited to LLM orchestration, memory management, and knowledge retrieval; all IO capabilities (message send/receive, file read/write, network requests, hardware interaction, etc.) are implemented by plugins. This separation defines domain boundaries at the Agent framework level, with distinct responsibility scopes for the kernel and plugins.
@ -163,7 +168,7 @@ internal/
├── agent/api/ LLM Provider + 8 Lua adapters
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
├── knowledge/ Knowledge base (filesystem + TF-IDF)
├── plugin/ Plugin registry + .so/.dll dynamic loader
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
├── config/ SQLite config center
@ -174,7 +179,9 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
## Project Status
**v0.9.0**C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline).
**v1.0.0**External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
**v0.8.0** — Core is functional, plugin system enhanced. 20+ built-in plugins. External plugin development via [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo. Added input channel `NoMemory`/`Cleaner`, `ChannelDef`, plugin disable/enable system (CLI + WebUI), `plugindev` toolchain C ABI `ChannelDef` support.
@ -186,6 +193,23 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
- [Lua Adapter](assets/docs/en/ADAPTER.md) | [中文](assets/docs/zh/ADAPTER.md)
- [Knowledge Base Demo](assets/knowledge/homeagent_architecture/content.md)
## Downloads
[Releases](https://gitcode.com/JianFeeeee/HomeAgent/releases) ship three variants:
| Variant | Contents | For |
|---|---|---|
| **full** | homed + waiter + desktop GUI + systemd unit | Single-machine, everything |
| **server** | homed + waiter + systemd unit | Servers (no desktop environment) |
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
- Windows: `HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe` (NSIS installer)
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
- Verification: `SHA256SUMS`
The macOS `homed` requires a native macOS build (CGO + sqlite3), so release packages ship only `waiter`/`initconfig`.
## Build
```bash

View File

@ -259,12 +259,18 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
| Method | Registration Mechanism | Compilation | Usage |
|--------|----------------------|-------------|-------|
| Built-in | `init()``RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
| External `.so` | C ABI dynamic loading | `-buildmode=c-shared` + bridge | qq/browser/files etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
Built-in plugin registration: `internal/plugins/all.go` blank imports → each plugin `init()``Registry.Load()` scans directory to match factory.
External plugin loading: `internal/plugin/dynamic.go` → copy to SHA256 temp path (bypass `plugin.Open` path cache) → `Open` + `Lookup("NewPlugin")`.
External plugin loading (since v1.0.0): `internal/plugin/dynamic_proc.go``exec.Command(plugin.bin)`
→ inherit shared-segment fds → handshake (protocol version check) → `plugin.init``plugin.start`
(the plugin reverse-registers tools/stages/channels during this window).
**The C ABI channel (`-buildmode=c-shared` + bridge) was removed entirely in v1.0.0**
the old `plugin.Open` path-cache workarounds (SHA256 temp-path copies) retired with it.
Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter executes `main.lua` (at load time `sdk.register_*` only buffers handlers), then `Start()` swaps in the real SDK implementation and registers them in batch. The script is read only once at load time; runtime execution happens via callbacks.
### Built-in vs External Plugins
@ -272,20 +278,39 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
| Dimension | Built-in Plugin | External Plugin |
|-----------|----------------|-----------------|
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `.so`/`.dll` (`-buildmode=c-shared`), loaded via C ABI bridge |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, etc.) |
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + binary |
| SDK permissions | Full PluginSDK (SocialAPI read/write, Publish events) | Restricted SDK (SocialAPI read-only, Subscribe-only events) |
| Lifecycle | Starts/stops with kernel, no individual hot-reload | Independent Start/Stop, supports hot-reload (ReloadOne) and enable/disable |
| Crash recovery | No independent recovery | Supports `SetAutoRestart(true)` for automatic crash restart |
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
| SDK permissions | Full PluginSDK (SocialAPI read/write, Publish events) | Narrowed `procCore` surface + manifest capabilities declaration + RPC boundary rejection |
| Lifecycle | Starts/stops with kernel, no individual hot-reload | Independent process; true hot-reload by swapping `plugin.bin` (ReloadOne) plus enable/disable |
| Crash recovery | No independent recovery | Process-level isolation: a crash cannot take down the kernel; the kernel detaches its registrations then restarts it with backoff (`SetAutoRestart(false)` opts out) |
Common ground:
- Built-in `RegisterFactory` and external `NewPluginFactory` share the same `NativeFactory` type signature
- `Registry.Load()` handles both uniformly: checks factory table first (built-in), falls back to dynamic loading (external)
- Both use the same `Plugin` interface and `PluginSDK`; tool registration, stage hooks, and output channel APIs are identical
- `Registry.Load()` handles both uniformly: checks the factory table first (built-in), otherwise dispatches by the manifest `entry` to the proc / lua / skill channel
- Both use the same `Plugin` interface and public SDK API; tool registration, stage hooks, and output channel APIs are identical
- Both share the same tool registry (`StageHost`); LLM invocations treat them identically
### The Three Communication Planes of Subprocess Plugins (v1.0.0)
| Plane | Mechanism | Why this choice |
|---|---|---|
| Control | stdio JSON-RPC (NDJSON frames), 51 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
| Data | Shared memory segment, **one segment shared by all subprocesses** | One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly |
| Notification | Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) | The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter |
**Subprocess lifecycle management**:
- One dedicated `waitLoop` per subprocess (the sole `cmd.Wait()` call site)—it does not rely on
stdout EOF, because grandchild processes forked by a plugin (browser spawning chromium,
editdoc spawning python) inherit the same stdout, so EOF never arrives after the plugin itself dies
- Central ledger `proc.Supervisor`: registered on successful handshake, unregistered on exit;
`Host.Close()` runs StopAll before tearing down the segment (reversing that order leaves plugins
holding a mapping that has been unmapped—SIGBUS on their next access)
- Crash self-healing: detach registrations (tools + stage handlers + IO channels) → remove from
the registry → restart with backoff
- Linux `Pdeathsig` is the last-resort guard so subprocesses do not linger as orphans when homed is SIGKILLed
### PluginSDK Four Channels
```

View File

@ -50,7 +50,10 @@ Code is in the project root, implemented in Go.
**Plugin System** (`internal/plugin/`):
- Built-in plugins: Go `init()` self-registration, compiled into kernel
- External plugins: Go `-buildmode=c-shared` compiled to `.so`, dynamically loaded via C ABI bridge; also supports Lua script plugins
- External plugins (since v1.0.0): compiled to an ordinary Go binary `plugin.bin`, spawned by the
kernel as an **independent subprocess**, communicating over stdio JSON-RPC (control plane) +
a shared memory segment (data plane) + an event ring (notification plane); Lua script plugins are
also supported (the C ABI shared-library channel, `-buildmode=c-shared`, was removed entirely in v1.0.0)
- PluginSDK (`internal/sdk/`) defines four channels: RegisterTool / RegisterStage / Subscribe / RegisterOutputChannel
- 7 stage hooks: on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output

View File

@ -29,7 +29,7 @@ type Plugin interface {
| Method | Use Case | Complexity |
|--------|----------|------------|
| **Dynamic .so/.dll plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
@ -114,7 +114,7 @@ myplugin/
└── thirdpart/ — Optional external source code directory
```
C ABI bridge files (`z_bridge_gen.go` + `z_entry.c`) are auto-generated at build time.
Subprocess runtime files (`z_proc_gen.go` and friends) are auto-generated at build time.
**Lua plugin**:
@ -142,8 +142,8 @@ plugindev build --replace <mod@path> # append a go.mod replace directive (repeat
Execution process:
1. Reads `plg.json` `targets`/`bundle` fields to determine build targets (bundle takes priority, see below)
2. Auto-generates C ABI bridge code (`z_bridge_gen.go` + `z_entry.c`; Windows only `z_bridge_gen.go`)
3. **Go plugin**: Runs `go build -buildmode=c-shared` (produces `.so` / `.dylib` / `.dll`)
2. Auto-generates subprocess runtime code (`z_proc_gen.go` + `z_proc_shm_unix.go` + `z_proc_shm_windows.go`)
3. **Go plugin**: Runs `go build` (a plain executable, `CGO_ENABLED=0`)
4. **Lua plugin**: Packages source code directly, no compilation needed (contents: `plugin.json` + `main.lua`, plus optional `README.md`, `LICENSE`, `thirdpart/*.lua`)
5. Generates `plugin.json` output manifest
6. Packages as `.hmap` distribution (zip format, containing `plugin.json` + binary)
@ -155,13 +155,28 @@ Execution process:
| `plg.json` | Project metadata, maintained by developer | `targets` — single-target build list (e.g. `"linux/amd64,windows/amd64"`); `bundle` — multi-platform bundle switch (default `true`) |
| `plugin.json` | Build artifact manifest, auto-generated | `entry` — entry filename; `platforms` — declared platforms |
Each target produces a separate `.hmap`; binary name by platform:
Each target produces a separate `.hmap`. Subprocess plugins are plain executables with
**no platform-specific extension**:
| Platform | Binary |
|----------|--------|
| Linux | `plugin.so` |
| macOS | `plugin.dylib` |
| Windows | `plugin.dll` |
| Linux / macOS / Windows | `plugin.bin` |
Inside a bundle package the per-platform entries are named `plugin.bin.<goos>.<goarch>`;
the kernel picks the one matching the current platform and renames it to `plugin.bin`.
> ⚠️ **v1.0.0 breaking change**: external plugins moved from C ABI shared libraries to
> **subprocess + shared memory**.
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
> skips legacy artifacts with an actionable error instead of crashing.
> - **Business code needs no changes** — the public SDK interface is unchanged; just
> rebuild with the new `plugindev`.
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
> - Windows went from "only 3 stage fields delivered, no writeback" to all 16 fields
> visible plus writeback, sharing the same RPC implementation as Unix.
### Build Targets & Multi-platform Bundle
@ -269,8 +284,11 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
At build time, `plugindev build` auto-generates C ABI bridge code (`z_bridge_gen.go` + `z_entry.c`),
shared by both Windows DLL and Linux/macOS .so builds. No manual bridge code needed.
At build time, `plugindev build` auto-generates subprocess runtime code
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
Unix, named kernel objects on Windows). No manual bridge code needed.
### PluginSDK Core API
@ -322,7 +340,7 @@ Tool output → valuable for LLM attention?
└── No → Normal memory, no extra handling
```
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation.
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot be serialized across process boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation.
#### Stage Hooks — Intervene in message processing flow
@ -527,7 +545,7 @@ Lua plugins run inside the kernel process on a gopher-lua interpreter (single Lu
- **Passive callback model**: `main.lua` executes only once at load time. Afterward, tools, stage hooks, output/input channels, and registered APIs are all invoked by the kernel via callbacks into Lua functions. Plugins cannot start background tasks on their own.
- **No concurrency / no long-running services**: Lua has no goroutines, coroutine scheduling, `os`/`io` libraries, or socket listening. The only outbound capability is `sdk.http.get/post` (synchronous). Any blocking loop will stall every call of that plugin while holding the lock.
- **For long-running services (listening on a port, background polling, timers) use a Go plugin** (`.so`/`.dll` built with the toolchain, which may spawn goroutines — see the webui/cli plugins). The Lua equivalent is event-driven: register tools/stage hooks/channels to be called back by the kernel, or interact with external processes via `sdk.http`.
- **For long-running services (listening on a port, background polling, timers) use a Go plugin** (`plugin.bin` built with the toolchain, which may spawn goroutines — see the webui/cli plugins). The Lua equivalent is event-driven: register tools/stage hooks/channels to be called back by the kernel, or interact with external processes via `sdk.http`.
### Plugin Structure
@ -576,7 +594,7 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
### Lua SDK API
The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / toolchain-built `.so`/`.dll`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
The `sdk.*` API of Lua plugins is fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
**Registration**
@ -594,7 +612,7 @@ The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / t
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`.
**Stage writeback (ABI v2)**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with the C ABI v2 external-plugin capability):
**Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability):
```lua
sdk.register_stage("on_input", function(ctx)
@ -622,7 +640,7 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
**Data APIs (aligned with C ABI, all return `(result, err)`)**
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
| Sub-table | Functions |
|-----------|-----------|

View File

@ -257,12 +257,18 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 方式 | 注册机制 | 编译 | 用途 |
|------|----------|------|------|
| 内置插件 | `init()``RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部 `.so` | C ABI 动态加载 | `-buildmode=c-shared` + bridge | qq/browser/files 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()``Registry.Load()` 扫描目录匹配工厂。
外部插件加载:`internal/plugin/dynamic.go` → 复制到 SHA256 临时路径(绕过 `plugin.Open` 路径缓存)→ `Open` + `Lookup("NewPlugin")`
外部插件加载v1.0.0 起):`internal/plugin/dynamic_proc.go``exec.Command(plugin.bin)`
→ 继承共享段 fd → 握手(比对 protocol 版本)→ `plugin.init``plugin.start`
(插件在此期间反向注册工具/阶段/通道)。
**C ABI 通道(`-buildmode=c-shared` + bridge已在 v1.0.0 整体删除**——
旧的 `plugin.Open` 路径缓存绕行、SHA256 临时路径复制等手法随之退场。
Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main.lua`(加载期 `sdk.register_*` 仅暂存 handler`Start()` 时替换为真实 SDK 实现并批量注册。脚本只在加载时读取一次,运行期通过回调执行。
### 内置插件 vs 外部插件
@ -270,20 +276,37 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 维度 | 内置插件 | 外部插件 |
|------|----------|----------|
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `.so`/`.dll``-buildmode=c-shared`C ABI bridge 加载 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap`ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件name, version, entry, platforms 等) |
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + 二进制 |
| SDK 权限 | 完整 PluginSDKSocialAPI 读写、Publish 事件) | 受限 SDKSocialAPI 只读、仅 Subscribe 事件) |
| 生命周期 | 随内核启动/停止,不可单独热重载 | 独立 Start/Stop支持热重载ReloadOne和禁用/启用 |
| 崩溃恢复 | 无独立恢复机制 | 支持 `SetAutoRestart(true)` 崩溃自动重启 |
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件name, version, entry, platforms, capabilities 等) |
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
| SDK 权限 | 完整 PluginSDKSocialAPI 读写、Publish 事件) | 收窄的 `procCore` 能力面 + manifest capabilities 声明 + RPC 边界拒绝 |
| 生命周期 | 随内核启动/停止,不可单独热重载 | 独立进程,换 `plugin.bin` 即生效的真热重载ReloadOne和禁用/启用 |
| 崩溃恢复 | 无独立恢复机制 | 进程级隔离:崩溃不影响内核,内核摘除其注册面后按退避自动重启(`SetAutoRestart(false)` 可关) |
两者的联系:
- 内置插件的工厂函数 `RegisterFactory` 与外部插件的 `NewPluginFactory` 共用同一个 `NativeFactory` 类型签名
- `Registry.Load()` 统一处理两者的加载:先查工厂表(内置),无工厂则尝试动态加载(外部)
- 两者使用相同的 `Plugin` 接口和 `PluginSDK`,工具注册、阶段钩子、输出通道等 API 完全一致
- `Registry.Load()` 统一处理两者的加载:先查工厂表(内置),无工厂则按 manifest 的 `entry` 分派到 proc / lua / skill 通道
- 两者使用相同的 `Plugin` 接口和公开 SDK API,工具注册、阶段钩子、输出通道等完全一致
- 两者共享同一个工具注册表(`StageHost`LLM 调用时无差别
### 子进程插件的三个通信面v1.0.0
| 面 | 机制 | 为何这么选 |
|---|---|---|
| 控制面 | stdio JSON-RPCNDJSON 帧51 个 `core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型lost update 原样复现 |
| 通知面 | 事件环 + 平台通知Linux eventfd / macOS pipe / Windows Event | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
**子进程生命周期管理**
- 每子进程一根专职 `waitLoop``cmd.Wait()` 唯一调用点)——不依赖 stdout EOF
因为插件 fork 的孙子进程browser 拉 chromium、editdoc 拉 python继承同一 stdout
插件本体死后 EOF 永不到来
- 集中台账 `proc.Supervisor`:握手成功即登记,退出即注销;`Host.Close()` 先 StopAll 再拆段
(顺序反了插件还持有映射而段已 unmap下次访问就是 SIGBUS
- 崩溃自愈:摘注册面(工具 + stage handler + IO 通道)→ 移出注册表 → 退避重启
- Linux `Pdeathsig` 兜底 homed 被强杀时子进程不滞留为孤儿
### PluginSDK 四通道
```

View File

@ -50,7 +50,9 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
**插件系统** (`internal/plugin/`)
- 内置插件Go `init()` 自注册,编译进内核
- 外部插件Go `-buildmode=c-shared` 编译为 `.so`,通过 C ABI bridge 动态加载;也支持 Lua 脚本插件
- 外部插件v1.0.0 起):编译为普通 Go 二进制 `plugin.bin`,内核 spawn 为**独立子进程**
经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)通信;也支持 Lua 脚本插件
C ABI 动态库通道 `-buildmode=c-shared` 已在 v1.0.0 整体删除)
- PluginSDK (`internal/sdk/`) 定义四通道RegisterTool / RegisterStage / Subscribe / RegisterOutputChannel
- 阶段钩子 7 个on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output

View File

@ -30,7 +30,7 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 |
|------|---------|--------|
| **动态 .so/.dll 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
@ -115,7 +115,7 @@ myplugin/
└── thirdpart/ — 外部源码存放目录(可选)
```
编译时自动生成 C ABI bridge 文件(`z_bridge_gen.go` + `z_entry.c`),无需手动创建。
编译时自动生成子进程运行时文件(`z_proc_gen.go` ),无需手动创建。
**Lua 插件**
@ -143,8 +143,8 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
执行过程:
1. 读取 `plg.json``targets`/`bundle` 字段确定构建目标bundle 模式优先,见下节)
2. 自动生成 C ABI bridge 代码(`z_bridge_gen.go` + `z_entry.c`Windows 仅 `z_bridge_gen.go`
3. **Go 插件**:执行 `go build -buildmode=c-shared`(生成 `.so` / `.dylib` / `.dll`
2. 自动生成子进程运行时代码(`z_proc_gen.go` + `z_proc_shm_unix.go` + `z_proc_shm_windows.go`
3. **Go 插件**:执行 `go build`(普通可执行文件,`CGO_ENABLED=0`
4. **Lua 插件**:直接打包源码,无需编译(打包内容:`plugin.json` + `main.lua`,以及可选的 `README.md``LICENSE``thirdpart/*.lua`
5. 生成 `plugin.json` 输出清单
6. 打包为 `.hmap` 分发包zip 格式,内含 `plugin.json` + 二进制)
@ -156,13 +156,25 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`|
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
每个目标生成单独的 `.hmap`,二进制文件名由平台决定
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**
| 平台 | 二进制 |
|------|--------|
| Linux | `plugin.so` |
| macOS | `plugin.dylib` |
| Windows | `plugin.dll` |
| Linux / macOS / Windows | `plugin.bin` |
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核挑当前平台
那份重命名为 `plugin.bin`
> ⚠️ **v1.0.0 破坏性变更**:外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
> 会跳过并报可操作错误,不崩溃。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
> 它现在只用于区分 Lua 插件。
> - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。
> - Windows 从「只下发 3 个 stage 字段、无写回」升级到 16 字段全可见 + 写回,
> 与 Unix 共用同一套 RPC 实现。
### 构建目标与多平台打包bundle
@ -269,7 +281,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
编译时 `plugindev build` 根据目标平台自动生成 C ABI bridge 代码(`z_bridge_gen.go` + `z_entry.c`无需手动编写。Windows DLL 和 Linux/macOS .so 共享同一入口
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)
### PluginSDK 核心 API
@ -321,7 +333,7 @@ s.RegisterTool("weather_query", sdk.ToolDef{
└── 否 → 正常记忆,无需额外处理
```
> **注意**`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`Go 桥接层会在计算层调用时逐次回调 Lua。
> **注意**`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨进程序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`Go 桥接层会在计算层调用时逐次回调 Lua。
#### 阶段钩子 — 干预消息处理流
@ -526,7 +538,7 @@ Lua 插件运行在内核进程内的 gopher-lua 解释器中(单 Lua 状态 +
- **被动回调模型**`main.lua` 仅在加载时执行一次,此后插件的工具、阶段钩子、输出/输入通道、注册 API 全部由内核事件驱动回调 Lua 函数;插件不能自己启动后台任务。
- **无并发/无常驻服务能力**Lua 侧没有 goroutine、协程调度、`os`/`io` 库和 socket 监听能力,唯一主动出站通道是 `sdk.http.get/post`(同步请求)。任何阻塞循环都会持锁卡死该插件的所有调用。
- **常驻服务(如监听端口、后台轮询、定时任务)请使用 Go 插件**(工具链编译的 `.so`/`.dll`,可自行启动 goroutine参见 webui/cli 插件。Lua 插件的等价做法是事件驱动:注册工具/阶段钩子/通道由内核回调,或经 `sdk.http` 与外部进程交互。
- **常驻服务(如监听端口、后台轮询、定时任务)请使用 Go 插件**(工具链编译的 `plugin.bin`,可自行启动 goroutine参见 webui/cli 插件。Lua 插件的等价做法是事件驱动:注册工具/阶段钩子/通道由内核回调,或经 `sdk.http` 与外部进程交互。
### 插件结构
@ -575,7 +587,7 @@ lua main.lua
### Lua SDK API
Lua 插件的 `sdk.*` API 与外部插件(C ABI / 工具链编译的 `.so`/`.dll`)能力完全对齐:注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
**注册类**
@ -593,7 +605,7 @@ Lua 插件的 `sdk.*` API 与外部插件C ABI / 工具链编译的 `.so`/`.d
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message``user_id``group_id``phase``llm_text``final_text``no_memory``response`(已响应时)、`tool_calls``tool_results`
**Stage 写回ABI v2**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与 C ABI v2 外部插件能力对齐):
**Stage 写回**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
```lua
sdk.register_stage("on_input", function(ctx)
@ -621,7 +633,7 @@ end)
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
**数据类(与 C ABI 对齐,均返回 `(result, err)`**
**数据类(与子进程外部插件对齐,均返回 `(result, err)`**
| 子表 | 函数 |
|------|------|

View File

@ -1380,7 +1380,8 @@ function executeHomeagentCmd(capability, reqId) {
.split(/[ >\n]/)[0];
switch (name) {
case "camerasue": {
// 摄像头camerasue=抓拍单张camerasue <秒>=录制 N 秒视频,返回 base64
// 摄像头camerasue=抓拍单张camerasue <秒>=录制 N 秒视频
// 平台分支Windows=dshow(设备名自动探测)macOS=avfoundationLinux=v4l2
const argStr = String(capability || "")
.replace(/^camerasue/, "")
.trim();
@ -1390,24 +1391,54 @@ function executeHomeagentCmd(capability, reqId) {
const os = require("os");
const path = require("path");
const fs = require("fs");
// 探测平台可用的 ffmpeg 输入参数(缓存结果避免重复探测)
let camInput = null;
function resolveCameraInput(cb) {
if (camInput) return cb(camInput);
const plat = process.platform;
if (plat === "win32") {
// dshow先枚举设备名取第一个视频设备
cp.execFile(
"ffmpeg",
["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "video= dummy"],
{ timeout: 8000 },
(err, _so, se) => {
const out = String(se || "");
const m = out.match(/"([^"]+)"\s*\((?:video|默认)|"([^"]+)"[\s\S]{0,200}?\(video/)
|| out.match(/"([^"]+)"[^\n]*\(video/i);
const name = m ? (m[1] || m[2]) : null;
if (name) {
camInput = { pre: ["-f", "dshow", "-i", "video=" + name] };
} else {
camInput = { pre: ["-f", "dshow", "-i", "video=USB Camera"] }; // 常见默认名兑底
}
cb(camInput);
},
);
return;
}
if (plat === "darwin") {
camInput = { pre: ["-f", "avfoundation", "-i", "0:0"] }; // 默认摄像头
return cb(camInput);
}
camInput = { pre: ["-f", "v4l2", "-i", "/dev/video0"] }; // Linux
return cb(camInput);
}
resolveCameraInput((cam) => {
if (isVideo) {
// 录像ffmpeg 录 N 秒 mp4 到临时文件
const outFile = path.join(os.tmpdir(), "ha_cam_" + Date.now() + ".mp4");
const args = [
"-f",
"v4l2",
"-i",
"/dev/video0",
...cam.pre,
"-t",
String(durMatch),
"-pix_fmt",
"yuv420p",
"-c:v",
"libx264",
"-f",
"mp4",
outFile,
];
if (process.platform !== "win32") {
args.push("-c:v", "libx264"); // Windows dshow→mp4 由扩展名驱动原生编码器
}
args.push("-f", "mp4", outFile);
cp.execFile(
"ffmpeg",
args,
@ -1446,19 +1477,16 @@ function executeHomeagentCmd(capability, reqId) {
return;
}
// 抓拍单张 jpeg
const args = [
"-f",
"v4l2",
"-i",
"/dev/video0",
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"pipe:1",
];
const args = [
...cam.pre,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"pipe:1",
];
cp.execFile(
"ffmpeg",
args,
@ -1483,6 +1511,7 @@ function executeHomeagentCmd(capability, reqId) {
);
},
);
}); // resolveCameraInput 回调闭合
return;
}
case "screensue": {

View File

@ -44,6 +44,9 @@ const state = {
pendingTools: [],
eventSource: null,
chatFinalIdx: -1,
chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标
chatTotal: 0, // 服务端历史总条数
chatHasMore: false, // 是否还有更早历史可向上加载
sseLastEventID: "", // 最近一次 SSE 事件 id断线重连时随 Last-Event-ID 头回传
lang: localStorage.getItem("ha-lang") || "zh",
connections: [],
@ -966,6 +969,7 @@ function fmtUptime(ms) {
}
var uptimeTick = null;
var _chatSyncTick = null;
function startUptimeTicker() {
if (uptimeTick) clearInterval(uptimeTick);
uptimeTick = setInterval(() => {
@ -978,6 +982,15 @@ function startUptimeTicker() {
if (el2) el2.textContent = "-";
}
}, 1000);
// 消息同步轮询兜底每30秒增量同步 chatHistory补偿 SSE 断连窗口期
// 丢失的事件(尤其是非 GUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。
// syncChatFromHistory 增量同步,不重建已有消息 DOM无闪烁。
if (_chatSyncTick) clearInterval(_chatSyncTick);
_chatSyncTick = setInterval(function () {
if (state.currentConn && state.currentConn.type !== "cli") {
syncChatFromHistory().catch(function () {});
}
}, 30000);
}
// ===== Overview =====
@ -1330,6 +1343,10 @@ function renderChat() {
() => {
state.chatStick =
msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80;
// 触顶(近顶部 60px且服务端还有更早历史 → 向上懒加载下一页
if (msgsEl.scrollTop < 60 && state.chatHasMore) {
loadOlderChat();
}
},
{ passive: true },
);
@ -2341,13 +2358,26 @@ function switchChatPanel(tab, el) {
if (tab === "knowledge") searchKnowledgeChat();
}
// 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。
var CHAT_PAGE_SIZE = 40;
async function loadChatHistory() {
try {
var data = await api("/chat/history");
var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE);
if (data && data.messages) {
state.messages = data.messages;
state.chatOffset = typeof data.offset === "number" ? data.offset : 0;
state.chatTotal =
typeof data.total === "number" ? data.total : data.messages.length;
state.chatHasMore = !!data.has_more;
if (window.homeagent && window.homeagent.log)
window.homeagent.log("history: loaded " + data.messages.length);
window.homeagent.log(
"history: loaded " +
data.messages.length +
"/" +
state.chatTotal +
(state.chatHasMore ? " (has more)" : ""),
);
} else if (window.homeagent && window.homeagent.log) {
window.homeagent.log("history: no messages field");
}
@ -2357,6 +2387,103 @@ async function loadChatHistory() {
}
}
// loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。
// 保持滚动位置补偿,避免视口跳动。
var _loadingOlder = false;
async function loadOlderChat() {
if (_loadingOlder || !state.chatHasMore) return;
_loadingOlder = true;
var msgsEl = document.getElementById("chat-msgs");
var prevH = msgsEl ? msgsEl.scrollHeight : 0;
var prevTop = msgsEl ? msgsEl.scrollTop : 0;
try {
var before = state.chatOffset || 0;
if (before <= 0) {
state.chatHasMore = false;
return;
}
var data = await api(
"/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before,
);
if (data && data.messages && data.messages.length) {
state.messages = data.messages.concat(state.messages);
state.chatOffset =
typeof data.offset === "number" ? data.offset : 0;
state.chatHasMore = !!data.has_more;
state.chatStick = false;
rerenderChatIfActive();
if (msgsEl) {
msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH);
}
if (window.homeagent && window.homeagent.log)
window.homeagent.log(
"history: older " + data.messages.length + " (offset=" + state.chatOffset + ")",
);
} else {
state.chatHasMore = false;
}
} catch (e) {
} finally {
_loadingOlder = false;
}
}
// syncChatFromHistory 增量同步:对比服务端历史,仅追加新消息 DOM 节点,
// 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底(跨渠道消息补偿)。
function syncChatFromHistory() {
return api("/chat/history?limit=" + CHAT_PAGE_SIZE)
.then(function (data) {
if (!data || !data.messages || data.messages.length === 0) return;
var serverMsgs = data.messages;
var localMsgs = state.messages;
// 首次加载(空列表)→ 全量赋值
if (localMsgs.length === 0) {
state.messages = serverMsgs;
state.chatOffset = typeof data.offset === "number" ? data.offset : 0;
state.chatHasMore = !!data.has_more;
rerenderChatIfActive();
return;
}
// 分段拉取只回传最新页,本地可能已向上翻页加载更多,
// 因此不能用长度比较,改用末尾内容比对 + 重叠区对齐。
var lastLocal = localMsgs[localMsgs.length - 1];
var lastServer = serverMsgs[serverMsgs.length - 1];
var localContent = lastLocal.content || lastLocal.Content || "";
var serverContent = lastServer.content || lastServer.Content || "";
// 末尾一致 → 无新增
if (localContent === serverContent) return;
// 寻找重叠点(本地末尾 k 条在服务端页中的位置)
var overlap = -1;
var maxK = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1);
for (var k = maxK; k >= 1; k--) {
var sMsg = serverMsgs[serverMsgs.length - 1 - k];
var lMsg = localMsgs[localMsgs.length - 1 - k];
if (
lMsg &&
sMsg &&
(lMsg.content || "") === (sMsg.content || "") &&
lMsg.role === sMsg.role
) {
overlap = k;
break;
}
}
if (overlap >= 0) {
var newMsgs = serverMsgs.slice(serverMsgs.length - overlap);
if (newMsgs.length === 0) return;
Array.prototype.push.apply(state.messages, newMsgs);
rerenderChatIfActive();
} else {
// 无重叠点(本地领先过多/已失同步)→ 安全退化为全量刷新最新页
state.messages = serverMsgs;
state.chatOffset = typeof data.offset === "number" ? data.offset : 0;
state.chatHasMore = !!data.has_more;
rerenderChatIfActive();
}
})
.catch(function () {});
}
async function loadTerminals() {
try {
var data = await api("/terminals");
@ -5101,6 +5228,10 @@ async function connectFetchSSE(url) {
badge.textContent = state.chatStage || "";
badge.style.display = "none";
}
} else if (type === "sync_required") {
// Server 因 Last-Event-ID 不在 ringdelta ID / 已到 tip无法重放
// 通知前端增量补拉历史——避免空等后续聚合事件导致「消息同步不及时」。
syncChatFromHistory();
}
} catch (err) {}
}
@ -5115,6 +5246,8 @@ async function connectFetchSSE(url) {
break;
}
}
// 断连后先增量同步历史(补偿断连窗口期丢失的事件),再重连
syncChatFromHistory().catch(function () {});
reconnectTimer = setTimeout(() => {
connectSSE();
}, Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000));

View File

@ -358,6 +358,7 @@ func main() {
pluginReg.SetProviderManager(providerMgr)
pluginReg.SetConfigRegistry(cfgReg)
pluginReg.SetPluginDir(cfg.Plugin.Dir)
pluginReg.SetDataDir(*dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
@ -391,14 +392,25 @@ func main() {
- output_* — 输出通道管理(切换/发送消息)
- timer_set — 设置定时提醒
- plgreload — 热重载插件
- spawn_child — 生成子 Agent 执行独立任务
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent避免阻塞当前对话。
- describe_image — 描述用户上传的图片
- transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字
命令与文件操作策略:
- cmd_run 经完整 shellbash执行支持管道、分号、&&、命令替换、heredoc、重定向。
- 多步交互式程序vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端,
terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。
- 写文件优先 files_write原子+留档),生成多行内容时可用 heredoc 或 files_write
不要用 echo 拼接长文本。
- 读用户发来的文件用 files_read向 webui 回传图片/文件用 output_send__webui(type=image/file)。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
回复你的真实想法,用自然语言与用户交流。`
回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。`
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultPrompt)
if sysPrompt == "" {
sysPrompt = defaultPrompt
@ -500,6 +512,14 @@ func main() {
}
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
// 技能索引接线skillmgr 插件实现 SkillIndexProvider 时注入 agent方案B prompt 注入)
if sp := pluginReg.Get("skillmgr"); sp != nil {
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
agent.SetSkillIndexProvider(prov)
log.Printf("[homed] skill index wired from skillmgr plugin")
}
}
// 日志管理:层级压缩 + 保留策略
logManager := logpkg.NewManager(logDir, cfgReg)
go logManager.Start(ctx)

2
cmd/ohos/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# 开发过程截图(体积大、非源码),不入库
screenshots/

19
cmd/ohos/HomeAgent/.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# 构建产物
build/
.hvigor/
.cxx/
# 依赖
oh_modules/
node_modules/
# 本地 SDK / Node 路径,每台机器不同
local.properties
# 签名材料:含 keyPassword / storePassword 明文与本机绝对路径,不入库
build-profile.json5
# hvigorw 在本机是指向 /opt/huawei/command-line-tools/bin/hvigorw 的符号链接,
# 绝对路径因机而异,入库后他人 clone 得到的是坏链接。
# 请改用本机 DevEco command-line-tools 里的 hvigorw见 README
hvigorw

View File

@ -0,0 +1,12 @@
{
"app": {
"bundleName": "com.example.homeagent",
"vendor": "HomeAgent",
"versionCode": 1000000,
"versionName": "1.0.0",
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}

View File

@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "HomeAgent"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,6 @@
{
"layered-image": {
"background": "$media:background",
"foreground": "$media:foreground"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

View File

@ -0,0 +1,57 @@
{
app: {
products: [
{
name: 'default',
signingConfig: 'default',
compileSdkVersion: '26.0.0',
compatibleSdkVersion: '6.1.1(24)',
runtimeOS: 'HarmonyOS',
buildOption: {
strictMode: {
useNormalizedOHMUrl: true,
},
},
},
],
buildModeSet: [
{
name: 'debug',
},
{
name: 'release',
},
],
// 复制为 build-profile.json5 后,把下面四项换成本机 DevEco 生成的调试签名材料
// (默认在 ~/.ohos/config/ 下keyPassword / storePassword 用自己的值。
signingConfigs: [
{
name: 'default',
type: 'HarmonyOS',
material: {
certpath: 'REPLACE_WITH_YOUR_CER_PATH',
keyAlias: 'debugKey',
keyPassword: 'REPLACE_WITH_YOUR_KEY_PASSWORD',
profile: 'REPLACE_WITH_YOUR_P7B_PATH',
signAlg: 'SHA256withECDSA',
storeFile: 'REPLACE_WITH_YOUR_P12_PATH',
storePassword: 'REPLACE_WITH_YOUR_STORE_PASSWORD',
},
},
],
},
modules: [
{
name: 'entry',
srcPath: './entry',
targets: [
{
name: 'default',
applyToProducts: [
'default',
],
},
],
},
],
}

View File

@ -0,0 +1 @@
export { hapTasks } from '@ohos/hvigor-ohos-plugin';

View File

@ -0,0 +1,20 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21"
},
"packages": {
"@ohos/hypium@1.0.21": {
"name": "@ohos/hypium",
"version": "1.0.21",
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
"registryType": "ohpm"
}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "entry",
"version": "1.0.0",
"description": "HomeAgent HarmonyOS client entry module",
"main": "",
"author": "",
"license": "Apache-2.0",
"dependencies": {},
"devDependencies": {
"@ohos/hypium": "1.0.21"
}
}

View File

@ -0,0 +1,217 @@
import { http } from '@kit.NetworkKit';
import { ConnectionConfig } from '../model/Model';
import { DEFAULT_API_TIMEOUT } from './Constants';
export class ApiError extends Error {
status: number;
body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
export interface ApiResponse {
status: number;
body: string;
}
/** 二进制响应:附件预览需要原始字节来解码成 PixelMap */
export interface ApiBinaryResponse {
status: number;
data: ArrayBuffer;
}
export class ApiClient {
private conn: ConnectionConfig | null = null;
setConnection(conn: ConnectionConfig): void {
this.conn = conn;
}
getConnection(): ConnectionConfig | null {
return this.conn;
}
hasConnection(): boolean {
return this.conn !== null && this.conn.url.length > 0;
}
private buildUrl(path: string): string {
if (this.conn === null) {
return path;
}
const base = this.conn.url.replace(/\/+$/, '');
return base + '/api/v1' + path;
}
/**
* 把附件的 url 字段解析成可直接请求的绝对地址。
*
* 后端给的是 `/files/<name>` 或 `/uploads/<name>`(注意:不带 /api/v1 前缀),
* 远程附件则直接是 http(s) 绝对地址,原样返回。
*/
absoluteUrl(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (this.conn === null) {
return url;
}
const base = this.conn.url.replace(/\/+$/, '');
return url.startsWith('/') ? base + url : base + '/' + url;
}
/**
* 读取附件字节。/files/ 与 /uploads/ 走 requireWeb
* 但后端对 API Key 客户端同等放行,所以带上同一套鉴权头即可。
*/
async getBinary(absUrl: string, timeoutMs: number): Promise<ApiBinaryResponse> {
const httpRequest = http.createHttp();
try {
const options: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: this.buildHeaders(),
expectDataType: http.HttpDataType.ARRAY_BUFFER,
readTimeout: timeoutMs,
connectTimeout: Math.min(timeoutMs, 15000),
};
const resp = await httpRequest.request(absUrl, options);
const statusCode = resp.responseCode;
if (statusCode >= 400) {
throw new ApiError('下载失败', statusCode, '');
}
const buf: ArrayBuffer = resp.result as ArrayBuffer;
return { status: statusCode, data: buf };
} finally {
httpRequest.destroy();
}
}
/**
* multipart/form-data 上传。
*
* 后端 POST /api/v1/chat/file 要求 file 字段为文件本体,
* message / device_id / device_name / client_msg_id 为普通文本字段。
* ArkTS 侧用 http 的 multiFormDataList文件走 filePath文本走 data。
*/
async postMultipart(path: string, parts: http.MultiFormData[],
timeoutMs: number): Promise<ApiResponse> {
if (this.conn === null) {
throw new ApiError('未选择连接', -1, '');
}
const url = this.buildUrl(path);
const httpRequest = http.createHttp();
try {
const header: Record<string, string> = this.buildHeaders();
// multipart 的 boundary 由底层生成,必须让出 Content-Type 的控制权
header['Content-Type'] = 'multipart/form-data';
const options: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: header,
expectDataType: http.HttpDataType.STRING,
multiFormDataList: parts,
readTimeout: timeoutMs,
connectTimeout: Math.min(timeoutMs, 15000),
};
const resp = await httpRequest.request(url, options);
const statusCode = resp.responseCode;
const body = typeof resp.result === 'string' ? resp.result : '';
if (statusCode >= 400) {
let errMsg = body;
try {
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
if (parsed['error'] !== undefined) {
errMsg = parsed['error'];
}
} catch (e) {
// keep raw body
}
throw new ApiError(errMsg, statusCode, body);
}
return { status: statusCode, body: body };
} finally {
httpRequest.destroy();
}
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
headers['Content-Type'] = 'application/json';
if (this.conn !== null && this.conn.apiKey.length > 0) {
// 后端 validAPIKey 认 X-API-Key也认 Authorization: Bearer
// query string 形式会被判 401不要用。
headers['X-API-Key'] = this.conn.apiKey;
headers['Authorization'] = 'Bearer ' + this.conn.apiKey;
}
return headers;
}
async request(path: string, method: string, bodyStr: string,
timeoutMs: number): Promise<ApiResponse> {
if (this.conn === null) {
throw new ApiError('未选择连接', -1, '');
}
const url = this.buildUrl(path);
const httpRequest = http.createHttp();
try {
const options: http.HttpRequestOptions = {
method: (method === 'POST' ? http.RequestMethod.POST :
method === 'PUT' ? http.RequestMethod.PUT :
method === 'DELETE' ? http.RequestMethod.DELETE :
http.RequestMethod.GET) as http.RequestMethod,
header: this.buildHeaders(),
expectDataType: http.HttpDataType.STRING,
readTimeout: timeoutMs,
connectTimeout: Math.min(timeoutMs, 15000),
};
if (bodyStr.length > 0) {
options.extraData = bodyStr;
}
const resp = await httpRequest.request(url, options);
const statusCode = resp.responseCode;
const body = typeof resp.result === 'string' ? resp.result : '';
if (statusCode >= 400) {
let errMsg = body;
try {
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
if (parsed['error'] !== undefined) {
errMsg = parsed['error'];
} else if (parsed['message'] !== undefined) {
errMsg = parsed['message'];
}
} catch (e) {
// keep raw body
}
throw new ApiError(errMsg, statusCode, body);
}
return { status: statusCode, body: body };
} finally {
httpRequest.destroy();
}
}
async get(path: string): Promise<ApiResponse> {
return this.request(path, 'GET', '', DEFAULT_API_TIMEOUT);
}
async getWithTimeout(path: string, timeoutMs: number): Promise<ApiResponse> {
return this.request(path, 'GET', '', timeoutMs);
}
async post(path: string, bodyObj: object | null): Promise<ApiResponse> {
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
return this.request(path, 'POST', bodyStr, DEFAULT_API_TIMEOUT);
}
async postWithTimeout(path: string, bodyObj: object | null,
timeoutMs: number): Promise<ApiResponse> {
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
return this.request(path, 'POST', bodyStr, timeoutMs);
}
}
export const apiClient: ApiClient = new ApiClient();

View File

@ -0,0 +1,195 @@
import { image } from '@kit.ImageKit';
import { util } from '@kit.ArkTS';
import { pasteboard } from '@kit.BasicServicesKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { textToSpeech } from '@kit.CoreSpeechKit';
import { componentSnapshot } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
// ===== 能力结果 =====
export interface CapResult {
status: string; // 'ok' | 'error'
output: string;
error: string;
}
function okResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '' };
return r;
}
function errResult(errMsg: string): CapResult {
const r: CapResult = { status: 'error', output: '', error: errMsg };
return r;
}
// ===== screensee截取本应用当前画面前台时为整屏可见内容=====
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
/** 根组件 idIndex 的根 Stack 设置同名 .id()),截屏时按此定位。 */
export function snapshotComponentId(): string {
return SNAPSHOT_COMPONENT_ID;
}
async function captureScreenPixelMap(): Promise<image.PixelMap> {
const pm: image.PixelMap = await componentSnapshot.get(SNAPSHOT_COMPONENT_ID);
return pm;
}
/**
* screensee 实现:截本应用画面,缩放到最大宽度 720px 后压成 jpeg base64 data URL。
* 说明鸿蒙三方应用无法无弹窗截取整个系统屏幕CUSTOM_SCREEN_CAPTURE 为系统权限),
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
*/
export async function capScreensee(): Promise<CapResult> {
try {
const full: image.PixelMap = await captureScreenPixelMap();
const info: image.ImageInfo = await full.getImageInfo();
const maxW: number = 720;
let targetW: number = info.size.width;
let targetH: number = info.size.height;
if (targetW > maxW) {
targetH = Math.floor(targetH * maxW / targetW);
targetW = maxW;
}
let packed: ArrayBuffer;
if (targetW !== info.size.width) {
await full.scale(targetW / info.size.width, targetH / info.size.height);
}
const packer: image.ImagePacker = image.createImagePacker();
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
packed = await packer.packing(full, opt);
packer.release();
full.release();
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('screensee failed: ' + msg);
}
}
// ===== clipboardsee / clipboardsue =====
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
// 说明READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
// 这里直接尝试读取;系统拒绝时回错误信息。
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const has: boolean = await clip.hasData();
if (!has) {
const empty: CapResult = { status: 'ok', output: '', error: '' };
return empty;
}
const data: pasteboard.PasteData = await clip.getData();
const txt: string = data.getPrimaryText();
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
return out;
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
}
}
export async function capClipboardsue(text: string): Promise<CapResult> {
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
await clip.setPasteData(data);
return okResult('written ' + text.length + ' chars');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsue failed: ' + msg);
}
}
// ===== speakeruseTTS 朗读 =====
class TtsSession {
private engine: textToSpeech.TextToSpeechEngine | null = null;
async speak(text: string): Promise<CapResult> {
try {
if (this.engine === null) {
const extra: Record<string, Object> = {
'style': 'interaction-broadcast',
'locate': 'CN',
'name': 'EngineName',
};
const params: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 1,
};
const eng: textToSpeech.TextToSpeechEngine = await textToSpeech.createEngine(params);
this.engine = eng;
}
const sp: textToSpeech.SpeakParams = {
requestId: 'spk-' + Date.now().toString(),
};
this.engine.speak(text, sp);
return okResult('speaking');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('speakeruse failed: ' + msg);
}
}
shutdown(): void {
if (this.engine !== null) {
try {
this.engine.shutdown();
} catch (e) {
// ignore
}
this.engine = null;
}
}
}
const ttsSession: TtsSession = new TtsSession();
export async function capSpeakerUse(text: string): Promise<CapResult> {
return ttsSession.speak(text);
}
// ===== deviceinfo =====
export function capDeviceInfo(): CapResult {
const lines: string[] = [];
lines.push('brand=' + deviceInfo.brand);
lines.push('manufacturer=' + deviceInfo.manufacture);
lines.push('model=' + deviceInfo.productModel);
lines.push('series=' + deviceInfo.productSeries);
lines.push('osFullName=' + deviceInfo.osFullName);
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
lines.push('abiList=' + deviceInfo.abiList);
lines.push('deviceType=' + deviceInfo.deviceType);
return okResult(lines.join('\n'));
}
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
let rest: string = rawArgs.trim();
const tokens: string[] = rest.split(/\s+/);
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
p.duration = parseInt(tokens[0], 10);
rest = tokens.slice(1).join(' ');
} else {
rest = tokens.join(' ');
}
p.content = rest.trim();
return p;
}

View File

@ -0,0 +1,99 @@
import { deviceBridge, CmdReply } from './DeviceBridge';
import {
CapResult,
capScreensee,
capClipboardSee,
capClipboardsue,
capSpeakerUse,
capDeviceInfo,
parseScreensue,
ScreensuePayload,
} from './BridgeCaps';
import { common } from '@kit.AbilityKit';
// screensue 展示回调由 UI 层注册Index 挂全局悬浮层)
export type ScreensueHandler = (payload: ScreensuePayload) => void;
let screensueHandler: ScreensueHandler | null = null;
let appContext: common.UIAbilityContext | null = null;
export function registerScreensueHandler(handler: ScreensueHandler): void {
screensueHandler = handler;
}
export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
appContext = ctx;
}
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
function splitCapability(command: string): string[] {
const cmd: string = command.trim();
const idx: number = cmd.indexOf(' ');
if (idx < 0) {
return [cmd];
}
const out: string[] = [cmd.substring(0, idx), cmd.substring(idx + 1)];
return out;
}
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
const parts: string[] = splitCapability(command);
const name: string = parts[0];
const args: string = parts.length > 1 ? parts[1] : '';
// screensee 截屏回传data URL 走文本结果,服务端兼容)
if (name === 'screensee') {
return capScreensee();
}
if (name === 'screensue') {
const payload: ScreensuePayload = parseScreensue(args);
if (screensueHandler !== null) {
screensueHandler(payload);
return okRes('shown');
}
return errRes('screensue: display layer not ready');
}
if (name === 'clipboardsee') {
if (appContext === null) {
return errRes('clipboardsee: app context missing');
}
return capClipboardSee(appContext);
}
if (name === 'clipboardsue') {
if (args.length === 0) {
return errRes('clipboardsue: empty text');
}
return capClipboardsue(args);
}
if (name === 'speakeruse') {
if (args.length === 0) {
return errRes('speakeruse: empty text');
}
return capSpeakerUse(args);
}
if (name === 'deviceinfo' || name === 'status') {
return capDeviceInfo();
}
if (name === 'camerasue') {
return errRes('camerasue: camera capture not supported on this build');
}
if (name === 'computeruse') {
return errRes('computeruse: not applicable to touch-only device');
}
return errRes('unsupported homeagent capability: ' + name);
}
function okRes(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '' };
return r;
}
function errRes(errMsg: string): CapResult {
const r: CapResult = { status: 'error', output: '', error: errMsg };
return r;
}
/** 安装命令处理器到 bridge 单例。 */
export function installCmdRouter(): void {
deviceBridge.setCmdHandler(executeCommand);
}

View File

@ -0,0 +1,236 @@
import { preferences } from '@kit.ArkData';
import { Context } from '@kit.AbilityKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { ConnectionConfig, AppSettings, emptySettings, defaultConnection, normalizeBaseUrl } from '../model/Model';
const PREF_NAME: string = 'homeagent_prefs';
const KEY_CONNECTIONS: string = 'connections_json';
const KEY_SETTINGS: string = 'settings_json';
const KEY_DEVICE_ID: string = 'device_id';
const KEY_DEVICE_NAME: string = 'device_name';
const KEY_DEVICE_AUTH: string = 'device_authorized';
export class ConnStore {
private prefs: preferences.Preferences | null = null;
private connections: ConnectionConfig[] = [];
private settings: AppSettings = emptySettings();
async init(context: Context): Promise<void> {
this.prefs = await preferences.getPreferences(context, PREF_NAME);
await this.load();
}
private async load(): Promise<void> {
if (this.prefs === null) {
return;
}
const connJson: string = await this.prefs.get(KEY_CONNECTIONS, '') as string;
if (connJson.length > 0) {
try {
this.connections = JSON.parse(connJson) as ConnectionConfig[];
} catch (e) {
this.connections = [];
}
// 存量数据可能是旧版本存进去的裸域名或带尾斜杠的地址,读出来时一并规范化,
// 否则老用户升级后仍然会拼出错误的请求地址。
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
c.url = normalizeBaseUrl(c.url);
}
}
const settingsJson: string = await this.prefs.get(KEY_SETTINGS, '') as string;
if (settingsJson.length > 0) {
try {
this.settings = JSON.parse(settingsJson) as AppSettings;
} catch (e) {
this.settings = emptySettings();
}
}
}
// 不预置任何连接:地址与密钥属于用户私有配置,不能随源码分发。
// 连接列表为空时各页面走"尚未配置后端连接"的统一提示,用户到设置页添加。
getConnections(): ConnectionConfig[] {
return this.connections;
}
getCurrentConnection(): ConnectionConfig | null {
if (this.settings.currentConnId.length === 0) {
if (this.connections.length > 0) {
return this.connections[0];
}
return null;
}
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
if (c.id === this.settings.currentConnId) {
return c;
}
}
if (this.connections.length > 0) {
return this.connections[0];
}
return null;
}
async addConnection(name: string, url: string, apiKey: string): Promise<ConnectionConfig> {
const conn: ConnectionConfig = defaultConnection();
conn.id = Date.now().toString(36) + Math.floor(Math.random() * 10000).toString(36);
conn.name = name;
conn.url = normalizeBaseUrl(url);
conn.apiKey = apiKey;
conn.type = 'webui';
this.connections.push(conn);
if (this.settings.currentConnId.length === 0) {
this.settings.currentConnId = conn.id;
}
await this.save();
return conn;
}
async updateConnection(id: string, name: string, url: string,
apiKey: string): Promise<void> {
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
if (c.id === id) {
c.name = name;
c.url = normalizeBaseUrl(url);
c.apiKey = apiKey;
break;
}
}
await this.save();
}
async deleteConnection(id: string): Promise<void> {
const next: ConnectionConfig[] = [];
for (let i = 0; i < this.connections.length; i++) {
const c: ConnectionConfig = this.connections[i];
if (c.id !== id) {
next.push(c);
}
}
this.connections = next;
if (this.settings.currentConnId === id) {
if (this.connections.length > 0) {
this.settings.currentConnId = this.connections[0].id;
} else {
this.settings.currentConnId = '';
}
}
await this.save();
}
async setCurrent(id: string): Promise<void> {
this.settings.currentConnId = id;
await this.save();
}
getSettings(): AppSettings {
return this.settings;
}
async saveSettings(s: AppSettings): Promise<void> {
this.settings = s;
await this.save();
}
/** Persisted local device id (stable across restarts). */
getDeviceId(): string {
if (this.prefs === null) {
return '';
}
return this.prefs.getSync(KEY_DEVICE_ID, '') as string;
}
saveDeviceId(id: string): void {
if (this.prefs === null) {
return;
}
this.prefs.putSync(KEY_DEVICE_ID, id);
this.prefs.flush();
}
/**
* 取本机设备 ID没有就地生成并持久化。
*
* 聊天发送必须带 device_id后端 handleChat 只有拿到它才会把来源编码成
* webui/<device_id> 并往 stageCtx 注入"当前输入来自设备[...]"
* 否则 source 恒为 "webui"agent 会以为消息是网页端发的。
* 之前只有设备页在用时才生成 ID聊天页拿到空串 → 身份丢失。
*/
ensureDeviceId(): string {
const cur: string = this.getDeviceId();
if (cur.length > 0) {
return cur;
}
const id: string = 'ohos-' + Date.now().toString(36);
this.saveDeviceId(id);
return id;
}
/** 设备显示名:优先用户自定义,否则按机型自动生成("HUAWEI Mate 60 (HarmonyOS)")。 */
getDeviceName(): string {
if (this.prefs !== null) {
const saved: string = this.prefs.getSync(KEY_DEVICE_NAME, '') as string;
if (saved.length > 0) {
return saved;
}
}
return ConnStore.autoDeviceName();
}
saveDeviceName(name: string): void {
if (this.prefs === null) {
return;
}
this.prefs.putSync(KEY_DEVICE_NAME, name);
this.prefs.flush();
}
/** 由 deviceInfo 拼一个人看得懂的机器名;静态方法内不能用 this。 */
private static autoDeviceName(): string {
let name: string = '';
try {
const brand: string = deviceInfo.brand;
const model: string = deviceInfo.productModel;
if (model.length > 0) {
name = brand.length > 0 && model.indexOf(brand) < 0 ? brand + ' ' + model : model;
}
} catch (e) {
name = '';
}
if (name.length === 0) {
name = 'HarmonyOS 设备';
}
return name;
}
/** Local authorization flag reported via hello; server stores nothing. */
getDeviceAuth(): boolean {
if (this.prefs === null) {
return false;
}
return this.prefs.getSync(KEY_DEVICE_AUTH, false) as boolean;
}
saveDeviceAuth(on: boolean): void {
if (this.prefs === null) {
return;
}
this.prefs.putSync(KEY_DEVICE_AUTH, on);
this.prefs.flush();
}
private async save(): Promise<void> {
if (this.prefs === null) {
return;
}
await this.prefs.put(KEY_CONNECTIONS, JSON.stringify(this.connections));
await this.prefs.put(KEY_SETTINGS, JSON.stringify(this.settings));
await this.prefs.flush();
}
}
export const connStore: ConnStore = new ConnStore();

View File

@ -0,0 +1,347 @@
/**
* Design tokens — mirror of gui/renderer/style.css `:root` (dark theme).
* All values copied 1:1 from the web GUI to keep visual identity identical.
*
* Theme system:
* - ThemePalette holds every color token used by the app.
* - DARK_PALETTE mirrors style.css `:root`; LIGHT_PALETTE mirrors
* `[data-theme="light"][data-color="sakura"]`.
* - themeStore keeps a global AppStorage("themeIsDark") flag that every page
* reads through @StorageProp, so the whole UI re-renders on switch.
*/
// ===== network =====
export const DEFAULT_API_TIMEOUT: number = 120000;
export const SSE_RECONNECT_DELAY: number = 5000;
export const DEFAULT_WS_PORT: number = 9890;
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
export const CHAT_PAGE_SIZE: number = 40;
// ===== sakura / frost palette (style.css :root) =====
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
export const COLOR_SAKURA_300: string = '#5B93F8';
export const COLOR_SAKURA_400: string = '#0A59F7';
export const COLOR_SAKURA_500: string = '#0A59F7';
export const COLOR_SAKURA_600: string = '#0A59F7';
export const COLOR_SAKURA_700: string = '#0846C2';
export const COLOR_FROST_300: string = '#4A90D9';
export const COLOR_FROST_400: string = '#3A78B5';
export const COLOR_FROST_500: string = '#2C5E8C';
export const COLOR_SUCCESS: string = '#30B260';
export const COLOR_WARNING: string = '#D99A2B';
export const COLOR_ERROR: string = '#E84026';
export const COLOR_INFO: string = '#3F6EF5';
export const COLOR_CYAN: string = '#2DD4BF';
export const COLOR_VIOLET: string = '#A78BFA';
export const COLOR_EMERALD: string = '#34D399';
export const COLOR_AMBER: string = '#FBBF24';
export const COLOR_BLUE: string = '#60A5FA';
// ===== 宽屏(平板 / 折叠展开 / 分屏)适配 =====
/**
* 宽屏断点:窗口宽度 >= 600vp 视为宽屏。
* 600vp = Navigation 分栏所需的 minNavBarWidth(240) + minContentWidth(360)
* 与系统 NavigationMode.Auto 的切换阈值保持一致,避免自判与系统行为脱节。
*/
export const WIDE_MIN_WIDTH: number = 600;
/** 宽屏下左侧一级界面栏宽度vp左边一级界面右边二级界面 */
export const WIDE_NAV_BAR_WIDTH: number = 420;
/** 宽屏下右侧二级界面的最小宽度vp不足时左栏被压缩 */
export const WIDE_MIN_CONTENT: number = 380;
/** 悬浮导航胶囊的最大宽度vp宽屏下避免被拉成长条 */
export const NAV_PILL_MAX_WIDTH: number = 620;
// ===== 动效时长ms=====
/**
* 统一动效节奏,避免每处各写一个魔数导致快慢不一。
*
* 取值依据:状态切换类交互(按下、展开、选中)控制在 150-250ms
* 超过 300ms 会显得"拖";进出场大位移(导航栏收起、悬浮层)用 300-400ms
* 才不突兀。曲线统一 EaseOut起步快、收尾缓手感上更"跟手"。
*/
/** 按压反馈、图标旋转等即时反馈 */
export const ANIM_FAST: number = 150;
/** 展开/折叠、选中态迁移等常规状态切换 */
export const ANIM_NORMAL: number = 220;
/** 卡片入场、列表项出现 */
export const ANIM_ENTER: number = 280;
/** 导航栏收起、悬浮层进出等大位移 */
export const ANIM_SLOW: number = 400;
/** 按压态缩放:轻微下沉即可,过度缩放显廉价 */
export const PRESS_SCALE: number = 0.97;
/** Every color token the app renders with. */
export interface ThemePalette {
bgPrimary: string;
bgSecondary: string;
bgCard: string;
bgInput: string;
bgHover: string;
glassBgStrong: string;
glassBorder: string;
border: string;
kvBorder: string;
btnGhostBorder: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
textTertiary: string;
accent: string;
accentBg: string;
preBg: string;
preText: string;
msgUserBg: string;
msgUserText: string;
msgAssistantBg: string;
msgAssistantText: string;
msgBubbleBg: string;
msgBubbleText: string;
msgBubbleBorder: string;
msgUserBubbleBg: string;
msgUserBubbleBorder: string;
msgAssistantBubbleBg: string;
msgAssistantBubbleBorder: string;
toastBg: string;
toastText: string;
toastErrorBg: string;
toastErrorText: string;
toastWarnBg: string;
toastWarnText: string;
successSoftBg: string;
errorSoftBg: string;
frostSoftBg: string;
navBarBg: string;
navBarBorder: string;
navBarGradientStart: string;
navBarGradientEnd: string;
shadow: string;
gradA: string; // sakura top-right glow (start color)
gradB: string; // frost mid-left glow
gradC: string; // sakura bottom glow
gradAEnd: string; // transparent end color for radial fade
gradBEnd: string;
gradCEnd: string;
}
/** Dark palette — HarmonyOS system-app style neutral dark. */
export const DARK_PALETTE: ThemePalette = {
bgPrimary: '#000000',
bgSecondary: 'rgba(28, 28, 30, 0.72)',
bgCard: 'rgba(28, 28, 30, 0.6)',
bgInput: 'rgba(44, 44, 46, 0.92)',
bgHover: 'rgba(255, 255, 255, 0.08)',
glassBgStrong: 'rgba(28, 28, 30, 0.82)',
glassBorder: 'rgba(255, 255, 255, 0.1)',
border: 'rgba(255, 255, 255, 0.1)',
kvBorder: 'rgba(255, 255, 255, 0.08)',
btnGhostBorder: 'rgba(255, 255, 255, 0.18)',
textPrimary: '#FFFFFF',
textSecondary: '#D1D1D6',
textMuted: '#98989F',
textTertiary: '#8E8E93',
accent: '#0A59F7',
accentBg: 'rgba(10, 89, 247, 0.18)',
preBg: 'rgba(20, 20, 22, 0.9)',
preText: '#D6E4FF',
msgUserBg: 'rgba(10, 89, 247, 0.2)',
msgUserText: '#9DC0FC',
msgAssistantBg: 'rgba(120, 120, 128, 0.24)',
msgAssistantText: '#E5E5EA',
msgBubbleBg: '#1C1C1E',
msgBubbleText: '#F2F2F7',
msgBubbleBorder: 'rgba(255, 255, 255, 0.12)',
msgUserBubbleBg: 'rgba(10, 89, 247, 0.18)',
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.25)',
msgAssistantBubbleBg: 'rgba(58, 58, 60, 0.72)',
msgAssistantBubbleBorder: 'rgba(255, 255, 255, 0.1)',
toastBg: 'rgba(48, 178, 96, 0.18)',
toastText: '#4CD47A',
toastErrorBg: 'rgba(232, 64, 38, 0.2)',
toastErrorText: '#FF6B4A',
toastWarnBg: 'rgba(255, 159, 10, 0.16)',
toastWarnText: '#FFCC66',
successSoftBg: 'rgba(48, 178, 96, 0.16)',
errorSoftBg: 'rgba(232, 64, 38, 0.18)',
frostSoftBg: 'rgba(74, 144, 217, 0.14)',
navBarBg: 'rgba(24, 24, 26, 0.45)',
navBarBorder: 'rgba(255, 255, 255, 0.14)',
navBarGradientStart: 'rgba(40, 40, 44, 0.35)',
navBarGradientEnd: 'rgba(20, 20, 22, 0.5)',
shadow: 'rgba(0, 0, 0, 0.5)',
gradA: 'rgba(10, 89, 247, 0.1)',
gradB: 'rgba(74, 144, 217, 0.07)',
gradC: 'rgba(94, 92, 230, 0.06)',
gradAEnd: 'rgba(10, 89, 247, 0)',
gradBEnd: 'rgba(74, 144, 217, 0)',
gradCEnd: 'rgba(94, 92, 230, 0)',
};
/** Light palette — HarmonyOS system-app style neutral light. */
export const LIGHT_PALETTE: ThemePalette = {
bgPrimary: '#F1F3F5',
bgSecondary: 'rgba(255, 255, 255, 0.85)',
bgCard: 'rgba(255, 255, 255, 0.95)',
bgInput: 'rgba(118, 118, 128, 0.28)',
bgHover: 'rgba(0, 0, 0, 0.05)',
glassBgStrong: 'rgba(255, 255, 255, 0.92)',
glassBorder: 'rgba(60, 60, 67, 0.12)',
border: 'rgba(60, 60, 67, 0.12)',
kvBorder: 'rgba(60, 60, 67, 0.1)',
btnGhostBorder: 'rgba(60, 60, 67, 0.2)',
textPrimary: '#191919',
textSecondary: '#494949',
textMuted: '#777779',
textTertiary: '#8A8A8E',
accent: '#0A59F7',
accentBg: 'rgba(10, 89, 247, 0.1)',
preBg: 'rgba(118, 118, 128, 0.1)',
preText: '#3C3C43',
msgUserBg: 'rgba(10, 89, 247, 0.12)',
msgUserText: '#0A59F7',
msgAssistantBg: '#FFFFFF',
msgAssistantText: '#333333',
msgBubbleBg: '#FFFFFF',
msgBubbleText: '#191919',
msgBubbleBorder: 'rgba(60, 60, 67, 0.12)',
msgUserBubbleBg: 'rgba(10, 89, 247, 0.1)',
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.18)',
msgAssistantBubbleBg: 'rgba(255, 255, 255, 0.88)',
msgAssistantBubbleBorder: 'rgba(60, 60, 67, 0.1)',
toastBg: 'rgba(48, 178, 96, 0.14)',
toastText: '#157347',
toastErrorBg: 'rgba(232, 64, 38, 0.12)',
toastErrorText: '#C0361F',
toastWarnBg: 'rgba(255, 159, 10, 0.14)',
toastWarnText: '#8F5A00',
successSoftBg: 'rgba(48, 178, 96, 0.12)',
errorSoftBg: 'rgba(232, 64, 38, 0.1)',
frostSoftBg: 'rgba(74, 144, 217, 0.12)',
navBarBg: 'rgba(250, 250, 252, 0.55)',
navBarBorder: 'rgba(60, 60, 67, 0.15)',
navBarGradientStart: 'rgba(255, 255, 255, 0.35)',
navBarGradientEnd: 'rgba(240, 240, 245, 0.5)',
shadow: 'rgba(0, 0, 0, 0.1)',
gradA: 'rgba(10, 89, 247, 0.06)',
gradB: 'rgba(74, 144, 217, 0.05)',
gradC: 'rgba(94, 92, 230, 0.04)',
gradAEnd: 'rgba(10, 89, 247, 0)',
gradBEnd: 'rgba(74, 144, 217, 0)',
gradCEnd: 'rgba(94, 92, 230, 0)',
};
/**
* Legacy single-theme constants kept for incremental migration.
* New code should use tp() / ThemePalette instead.
*/
export const COLOR_BG_PRIMARY: string = '#000000';
export const COLOR_BG_SECONDARY: string = 'rgba(17, 24, 44, 0.72)';
export const COLOR_BG_CARD: string = 'rgba(17, 24, 44, 0.6)';
export const COLOR_BG_INPUT: string = 'rgba(13, 18, 34, 0.75)';
export const COLOR_BG_HOVER: string = 'rgba(255, 255, 255, 0.06)';
export const COLOR_GLASS_BG: string = 'rgba(13, 18, 34, 0.6)';
export const COLOR_GLASS_BG_STRONG: string = 'rgba(13, 18, 32, 0.82)';
export const COLOR_GLASS_BORDER: string = 'rgba(255, 255, 255, 0.08)';
export const COLOR_GLASS_HOVER: string = 'rgba(255, 255, 255, 0.05)';
export const COLOR_TEXT_PRIMARY: string = '#FFFFFF';
export const COLOR_TEXT_SECONDARY: string = 'rgba(255, 255, 255, 0.6)';
export const COLOR_TEXT_MUTED: string = 'rgba(255, 255, 255, 0.4)';
export const COLOR_TEXT_TERTIARY: string = 'rgba(255, 255, 255, 0.45)';
export const COLOR_ACCENT: string = '#0A59F7';
export const COLOR_ACCENT_BG: string = 'rgba(10, 89, 247, 0.14)';
export const COLOR_BORDER: string = 'rgba(255, 255, 255, 0.09)';
export const COLOR_KV_BORDER: string = 'rgba(255, 255, 255, 0.07)';
export const COLOR_BTN_GHOST_BORDER: string = 'rgba(255, 255, 255, 0.14)';
export const COLOR_SAVE_BTN_BORDER: string = '#D99A2B';
export const COLOR_TOAST_BG: string = 'rgba(23, 169, 100, 0.16)';
export const COLOR_TOAST_TEXT: string = '#6EE7A8';
export const COLOR_TOAST_ERROR_BG: string = 'rgba(232, 64, 38, 0.18)';
export const COLOR_TOAST_ERROR_TEXT: string = '#F0865B';
export const COLOR_TOAST_WARN_BG: string = 'rgba(217, 154, 43, 0.16)';
export const COLOR_TOAST_WARN_TEXT: string = '#FCD9A0';
// ===== radius (style.css --radius-*) =====
export const RADIUS_SM: number = 6;
export const RADIUS_MD: number = 10;
export const RADIUS_LG: number = 14;
export const RADIUS_PILL: number = 999;
// ===== misc states =====
export const COLOR_CONN_OFFLINE: string = '#77809A';
export const COLOR_DOT_GRAY: string = '#475569';
// =====================================================================
// Global reactive theme store.
//
// AppStorage keys:
// "themeIsDark" boolean — current resolved dark/light state
// "themeMode" string — 'system' | 'dark' | 'light'
//
// Pages read via @StorageProp("themeIsDark") and pick colors from
// tp() so the whole tree re-renders when the mode flips.
// EntryAbility seeds both on launch and updates "themeIsDark" on
// onConfigurationUpdate (system dark-mode change).
// =====================================================================
const KEY_THEME_IS_DARK: string = 'themeIsDark';
const KEY_SYSTEM_IS_DARK: string = 'systemIsDark';
const KEY_THEME_MODE: string = 'themeMode';
export function seedTheme(isDark: boolean): void {
if (!AppStorage.has(KEY_THEME_IS_DARK)) {
AppStorage.setOrCreate(KEY_THEME_IS_DARK, isDark);
} else {
AppStorage.set(KEY_THEME_IS_DARK, isDark);
}
}
/**
* Record the OS dark/light state. UI never binds to this directly, but the
* 'system' mode resolves against it, so EntryAbility updates it on config
* change and then flips themeIsDark when mode === 'system'.
*/
export function seedSystemIsDark(isDark: boolean): void {
if (!AppStorage.has(KEY_SYSTEM_IS_DARK)) {
AppStorage.setOrCreate(KEY_SYSTEM_IS_DARK, isDark);
} else {
AppStorage.set(KEY_SYSTEM_IS_DARK, isDark);
}
}
/**
* Resolve a stored theme mode ('system'|'dark'|'light') against the current
* system color mode. 'system' falls back to the systemIsDark flag.
*/
export function resolveIsDark(mode: string, sysDark: boolean): boolean {
if (mode === 'dark') {
return true;
}
if (mode === 'light') {
return false;
}
return sysDark;
}
/**
* Apply a stored theme mode: persist the mode token and immediately flip the
* reactive themeIsDark flag so the whole UI re-renders.
*/
export function applyThemeMode(mode: string): void {
const sysDark: boolean = AppStorage.get<boolean>(KEY_SYSTEM_IS_DARK) ?? true;
AppStorage.set(KEY_THEME_MODE, mode);
seedTheme(resolveIsDark(mode, sysDark));
}
/** Current resolved palette for @Builder / build() usage. */
export function tp(): ThemePalette {
const dark: boolean = AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
return dark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** Raw read for non-UI code. */
export function themeIsDark(): boolean {
return AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
}

View File

@ -0,0 +1,394 @@
import { webSocket } from '@kit.NetworkKit';
import { DeviceInfo } from '../model/Model';
import { CapResult } from './BridgeCaps';
// ===== 协议消息(与 remotedevice 插件对齐)=====
interface HelloDeviceInfo {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
}
interface HelloDevice {
device_id: string;
name: string;
kind: string;
authorized: boolean;
caps: string[];
info: HelloDeviceInfo;
}
interface HelloMessage {
op: string;
device: HelloDevice;
}
interface BindMessage {
op: string;
device_id: string;
token: string;
}
interface CmdMessage {
op: string;
req_id: string;
command: string;
cmd_type: string;
}
export interface CmdReply {
op: string; // 'cmd_result'
req_id: string;
status: string;
output: string;
error: string;
}
interface DataStartMessage {
op: string;
req_id: string;
kind: string;
mime: string;
total: number;
chunk_size: number;
}
interface DataEndMessage {
op: string;
req_id: string;
status: string;
total?: number;
error?: string;
}
const CHUNK_SIZE: number = 8192;
// ===== 命令处理器回调 =====
// 返回 CapResult二进制大结果通过 dataHandler 分块回传。
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
export class DeviceBridgeClient {
private ws: webSocket.WebSocket = webSocket.createWebSocket();
private url: string = '';
private token: string = '';
private deviceId: string = '';
private name: string = 'HomeAgent OHOS';
private kind: string = 'phone';
private caps: string[] = [];
private hostname: string = 'ohos';
private connected: boolean = false;
private everConnected: boolean = false;
private manualClose: boolean = false;
private reconnectTimer: number = -1;
private cmdHandler: BridgeCmdHandler | null = null;
private onStateChange: ((open: boolean) => void) | null = null;
isConnected(): boolean {
return this.connected;
}
getDeviceId(): string {
return this.deviceId;
}
setCmdHandler(handler: BridgeCmdHandler): void {
this.cmdHandler = handler;
}
setStateListener(listener: (open: boolean) => void): void {
this.onStateChange = listener;
}
async connect(url: string, token: string, deviceId: string,
caps: string[], hostname: string,
authorized: boolean, name: string): Promise<void> {
this.url = url;
this.token = token;
this.deviceId = deviceId;
this.caps = caps;
this.hostname = hostname;
this.name = name;
this.manualClose = false;
await this.openAndRegister(authorized);
}
private async openAndRegister(authorized: boolean): Promise<void> {
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
try {
this.ws.off('open');
this.ws.off('message');
this.ws.off('close');
this.ws.off('error');
} catch (e) {
// ignore
}
this.ws = webSocket.createWebSocket();
this.bindWsEvents(authorized);
// 鉴权必须走请求头,不能拼 ?token=
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer
// 查询参数一律视为未授权 → 握手被 401 顶掉,
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token
// 如果我们再带 ?token=<webui apiKey>remotedevice 的 ServeWS
// 会拿它和 ws_token 比对并 401。留空反而放行。
const opts: webSocket.WebSocketRequestOptions = {
header: this.authHeader(),
};
try {
await this.ws.connect(this.url, opts);
} catch (e) {
this.connected = false;
this.scheduleReconnect();
}
}
/** 握手请求头X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
private authHeader(): Record<string, string> {
const h: Record<string, string> = {};
if (this.token.length > 0) {
h['X-API-Key'] = this.token;
h['Authorization'] = 'Bearer ' + this.token;
}
return h;
}
private bindWsEvents(authorized: boolean): void {
this.ws.on('open', (err: Error, value: Object) => {
this.connected = true;
this.everConnected = true;
this.cancelReconnect();
this.sendHello(authorized);
this.sendBind();
if (this.onStateChange !== null) {
this.onStateChange(true);
}
});
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
if (typeof value === 'string') {
this.handleTextFrame(value);
}
});
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
});
this.ws.on('error', (err: Error) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
});
}
private scheduleReconnect(): void {
if (this.manualClose || this.reconnectTimer >= 0) {
return;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = -1;
if (this.manualClose || this.url.length === 0) {
return;
}
this.openAndRegister(this.lastAuthorized);
}, 5000);
}
private lastAuthorized: boolean = false;
private cancelReconnect(): void {
if (this.reconnectTimer >= 0) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = -1;
}
}
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
updateAuthorized(authorized: boolean): void {
this.lastAuthorized = authorized;
if (this.connected) {
this.sendHello(authorized);
}
}
disconnect(): void {
this.manualClose = true;
this.cancelReconnect();
this.connected = false;
try {
this.ws.off('open');
this.ws.off('message');
this.ws.off('close');
this.ws.off('error');
this.ws.close().catch(() => {
// ignore
});
} catch (e) {
// ignore
}
if (this.onStateChange !== null) {
this.onStateChange(false);
}
}
private sendHello(authorized: boolean): void {
this.lastAuthorized = authorized;
const info: HelloDeviceInfo = {
hostname: this.hostname,
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: '1.1.0',
cpus: 0,
};
const device: HelloDevice = {
device_id: this.deviceId,
name: this.name,
kind: this.kind,
authorized: authorized,
caps: this.caps,
info: info,
};
const hello: HelloMessage = { op: 'hello', device: device };
this.send(JSON.stringify(hello));
}
private sendBind(): void {
const bind: BindMessage = {
op: 'bind',
device_id: this.deviceId,
token: this.token,
};
this.send(JSON.stringify(bind));
}
// ===== 命令处理 =====
private handleTextFrame(text: string): void {
let obj: Record<string, Object>;
try {
obj = JSON.parse(text) as Record<string, Object>;
} catch (e) {
return;
}
const op: string = obj['op'] as string ?? '';
if (op === 'cmd') {
const reqId: string = obj['req_id'] as string ?? '';
const command: string = obj['command'] as string ?? '';
if (reqId.length === 0 || command.length === 0) {
return;
}
if (!this.lastAuthorized) {
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
return;
}
this.dispatchCommand(reqId, command);
} else if (op === 'hello_ack' || op === 'bind_ack') {
if (this.onAck !== null) {
this.onAck(op);
}
}
}
onAck: ((op: string) => void) | null = null;
private dispatchCommand(reqId: string, command: string): void {
if (this.cmdHandler === null) {
this.sendResult(reqId, 'error', '', 'no capability handler registered');
return;
}
const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => {
this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => {
const msg: string = e instanceof Error ? e.message : String(e);
this.sendResult(reqId, 'error', '', msg);
});
}
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
const result: CmdReply = {
op: 'cmd_result',
req_id: reqId,
status: status,
output: output,
error: errMsg,
};
this.send(JSON.stringify(result));
}
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
const startMsg: DataStartMessage = {
op: 'cmd_data_start',
req_id: reqId,
kind: kind,
mime: mime,
total: bytes.byteLength,
chunk_size: CHUNK_SIZE,
};
this.send(JSON.stringify(startMsg));
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
const view: Uint8Array = bytes.slice(off, end);
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
try {
this.ws.send(ab).catch(() => {
// ignore per-chunk failure; end frame reports error below
});
} catch (e) {
break;
}
}
const endMsg: DataEndMessage = {
op: 'cmd_data_end',
req_id: reqId,
status: 'ok',
};
this.send(JSON.stringify(endMsg));
}
sendEvent(eventType: string, detail: string): void {
const payload: Record<string, string> = { 'detail': detail };
const msg: Record<string, Object> = {
'op': 'event',
'device_id': this.deviceId,
'type': eventType,
'payload': payload,
};
this.send(JSON.stringify(msg));
}
sendStatus(status: string): void {
const msg: Record<string, Object> = {
'op': 'status',
'device_id': this.deviceId,
'status': status,
};
this.send(JSON.stringify(msg));
}
send(text: string): void {
if (!this.connected) {
return;
}
this.ws.send(text).catch(() => {
// ignore
});
}
}
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
return [];
}

View File

@ -0,0 +1,55 @@
/**
* 导航栏显隐控制器(跨页面共享单例)。
* 规则:任何页面滚动中隐藏底部导航,滚动停止(松手/fling 结束)后重新显示。
*/
type NavListener = (visible: boolean) => void;
export class NavBarController {
private static instance: NavBarController | null = null;
private visible: boolean = true;
private listeners: NavListener[] = [];
static shared(): NavBarController {
if (NavBarController.instance === null) {
NavBarController.instance = new NavBarController();
}
return NavBarController.instance;
}
isVisible(): boolean {
return this.visible;
}
setVisible(v: boolean): void {
if (this.visible !== v) {
this.visible = v;
AppStorage.setOrCreate<boolean>('navVisible', v);
this.notify();
}
}
addListener(l: NavListener): void {
this.listeners.push(l);
}
private notify(): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i](this.visible);
}
}
}
export const navBar: NavBarController = NavBarController.shared();
/**
* 页面 Scroll.onDidScroll 的统一处理:
* Scroll/Fling 状态(手指拖动或惯性滚动)隐藏导航栏,
* Idle松手或惯性结束重新显示。
*/
export function handleNavOnScroll(state: ScrollState): void {
if (state === ScrollState.Idle) {
navBar.setVisible(true);
} else {
navBar.setVisible(false);
}
}

View File

@ -0,0 +1,51 @@
/**
* 各主 Tab 页面的二级导航栈登记处。
*
* 为什么需要它:四个一级页面各自持有一个 Navigation宽屏要"左一级右二级"
* 所以栈必须是页面局部的,不能提到 Index 里去)。但四个 Navigation 同时
* 挂在 Swiper 里都是活的,系统返回事件落到哪一个并不确定 —— 表现就是
* "有的页面返回手势能用、有的不能"。
*
* 解决办法:页面把自己的栈按 Tab 序号登记进来,@Entry 页在 onBackPress 里
* 按当前 Tab 精确地 pop 对应的栈。这样返回手势/三键返回/无障碍返回
* 在每个页面上的行为都是确定的。
*/
interface StackEntry {
stack: NavPathStack;
/** pop 之后页面要同步自己的选中态宽屏高亮、activeXxx 等) */
onPopped: () => void;
}
const registry: Map<number, StackEntry> = new Map<number, StackEntry>();
/** 页面 aboutToAppear 时登记;同一 Tab 重复登记以最后一次为准。 */
export function registerNavStack(tab: number, stack: NavPathStack, onPopped: () => void): void {
registry.set(tab, { stack: stack, onPopped: onPopped });
}
export function unregisterNavStack(tab: number): void {
registry.delete(tab);
}
/**
* 返回键/返回手势的统一处理。
* 返回 true 表示已消费弹出了一层二级页面false 表示交回系统(退出应用)。
*
* 宽屏 Split 模式下右栏常驻,返回不该把它清空,所以那时直接不消费。
*/
export function handleBackPress(tab: number, isWide: boolean): boolean {
if (isWide) {
return false;
}
const e: StackEntry | undefined = registry.get(tab);
if (e === undefined) {
return false;
}
if (e.stack.size() <= 0) {
return false;
}
e.stack.pop();
e.onPopped();
return true;
}

View File

@ -0,0 +1,258 @@
import { http } from '@kit.NetworkKit';
import { ConnectionConfig } from '../model/Model';
export interface SseEvent {
event: string;
data: string;
id: string;
}
export type SseHandler = (ev: SseEvent) => void;
export type SseCloseHandler = () => void;
export type SseOpenHandler = () => void;
function decodeUtf8(bytes: Uint8Array): string {
let result: string = '';
let i: number = 0;
while (i < bytes.length) {
const b: number = bytes[i];
if (b < 0x80) {
result += String.fromCharCode(b);
i++;
} else if (b < 0xC0) {
i++;
} else if (b < 0xE0) {
if (i + 1 < bytes.length) {
result += String.fromCharCode(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
i += 2;
} else {
i++;
}
} else if (b < 0xF0) {
if (i + 2 < bytes.length) {
result += String.fromCharCode(
((b & 0x0F) << 12) | ((bytes[i + 1] & 0x3F) << 6) | (bytes[i + 2] & 0x3F),
);
i += 3;
} else {
i++;
}
} else {
if (i + 3 < bytes.length) {
const cp: number =
((b & 0x07) << 18) |
((bytes[i + 1] & 0x3F) << 12) |
((bytes[i + 2] & 0x3F) << 6) |
(bytes[i + 3] & 0x3F);
const adjusted: number = cp - 0x10000;
result += String.fromCharCode(0xD800 + (adjusted >> 10));
result += String.fromCharCode(0xDC00 + (adjusted & 0x3FF));
i += 4;
} else {
i++;
}
}
}
return result;
}
export class SseClient {
private httpRequest: http.HttpRequest | null = null;
private buffer: string = '';
private lastEventId: string = '';
private closed: boolean = false;
private opened: boolean = false;
private onEvent: SseHandler | null = null;
private onClose: SseCloseHandler | null = null;
private onOpen: SseOpenHandler | null = null;
setLastEventId(id: string): void {
this.lastEventId = id;
}
getLastEventId(): string {
return this.lastEventId;
}
async connect(conn: ConnectionConfig, path: string,
onEvent: SseHandler, onClose: SseCloseHandler,
onOpen: SseOpenHandler | null = null): Promise<void> {
this.onEvent = onEvent;
this.onClose = onClose;
this.onOpen = onOpen;
this.closed = false;
this.opened = false;
this.buffer = '';
this.curEvent = '';
this.curData = '';
this.curId = '';
const base: string = conn.url.replace(/\/+$/, '');
const url: string = base + '/api/v1' + path;
const headers: Record<string, string> = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-store',
};
if (conn.apiKey.length > 0) {
// 后端 validAPIKey 两种都认;有些反代只放行 Authorization两个都带更稳
headers['X-API-Key'] = conn.apiKey;
headers['Authorization'] = 'Bearer ' + conn.apiKey;
}
if (this.lastEventId.length > 0) {
headers['Last-Event-ID'] = this.lastEventId;
}
const req: http.HttpRequest = http.createHttp();
this.httpRequest = req;
req.on('headersReceive', (header: Object) => {
const ct: string = this.getHeaderValue(header, 'content-type');
if (ct.indexOf('text/event-stream') >= 0) {
this.markOpened();
} else {
this.finish();
}
});
req.on('dataReceive', (chunk: ArrayBuffer) => {
if (this.closed) {
return;
}
this.markOpened();
const bytes: Uint8Array = new Uint8Array(chunk);
const text: string = decodeUtf8(bytes);
this.buffer += text;
this.processBuffer();
});
req.on('dataEnd', () => {
this.finish();
});
const options: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: headers,
expectDataType: http.HttpDataType.ARRAY_BUFFER,
usingCache: false,
readTimeout: 3600000,
connectTimeout: 15000,
};
try {
await req.requestInStream(url, options);
} catch (e) {
this.finish();
}
}
private getHeaderValue(header: Object, name: string): string {
try {
const rec: Record<string, string> = header as Record<string, string>;
const lower: string = name.toLowerCase();
const keys: string[] = Object.keys(rec);
for (let i = 0; i < keys.length; i++) {
if (keys[i].toLowerCase() === lower) {
const val: string | undefined = rec[keys[i]];
return val !== undefined ? val : '';
}
}
} catch (e) {
// ignore
}
return '';
}
/**
* 帧解析状态必须【跨 chunk 保持】。
*
* 之前把 eventType/data/id 作为 processBuffer 的局部变量,
* 而 TCP 分片完全可能切在帧内部的换行处(服务端 16ms 批量 flush 时
* 一次写入几十帧,尾部被切开是常态):
* chunk1 = "...event: content_delta\n"
* chunk2 = "data: {...}\n\n"
* 于是 chunk1 解析出的 event 被丢掉chunk2 只剩 data 而没有事件名,
* 整帧被静默丢弃 —— 表现就是"工具调用和思考不显示、也不是流式"。
*/
private curEvent: string = '';
private curData: string = '';
private curId: string = '';
private processBuffer(): void {
const lines: string[] = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (let i = 0; i < lines.length; i++) {
// 兼容 CRLF\r 会污染事件名与 JSON 尾部
let line: string = lines[i];
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
line = line.substring(0, line.length - 1);
}
if (line.startsWith(':')) {
// 注释行(心跳),忽略
continue;
}
if (line.startsWith('id:')) {
this.curId = line.substring(3).trim();
if (this.curId.length > 0) {
this.lastEventId = this.curId;
}
} else if (line.startsWith('event:')) {
this.curEvent = line.substring(6).trim();
} else if (line.startsWith('data:')) {
// SSE 规范data: 后的单个空格属于分隔符,其余原样保留;
// 多行 data 用换行拼接。
let chunk: string = line.substring(5);
if (chunk.startsWith(' ')) {
chunk = chunk.substring(1);
}
this.curData = this.curData.length > 0 ? this.curData + '\n' + chunk : chunk;
} else if (line === '') {
if (this.curEvent.length > 0 && this.curData.length > 0) {
const ev: SseEvent = { event: this.curEvent, data: this.curData, id: this.curId };
if (this.onEvent !== null) {
this.onEvent(ev);
}
}
this.curEvent = '';
this.curData = '';
this.curId = '';
}
}
}
close(): void {
this.closed = true;
this.opened = false;
if (this.httpRequest !== null) {
try {
this.httpRequest.off('dataReceive');
this.httpRequest.off('dataEnd');
this.httpRequest.off('headersReceive');
this.httpRequest.destroy();
} catch (e) {
// ignore
}
this.httpRequest = null;
}
}
private markOpened(): void {
if (this.opened || this.closed) {
return;
}
this.opened = true;
if (this.onOpen !== null) {
this.onOpen();
}
}
private finish(): void {
if (this.closed) {
return;
}
this.close();
if (this.onClose !== null) {
this.onClose();
}
}
}

View File

@ -0,0 +1,289 @@
import { apiClient } from './ApiClient';
import { userMessage, noConnectionMessage } from './UserError';
/**
* 运行状态数据源(单例)。
*
* 状态页已并入设置页:设置一级页顶部嵌一张摘要卡,明细走二级页。
* 摘要卡与明细页是两个独立组件,但必须显示同一份数据、只请求一次,
* 所以把请求与解析收拢到这里,标量通过 AppStorage 广播给两边。
*
* 字段口径与 WebGUI 的 概览/内核 两页一致:
* - GET /status → status / version / startedAt / agents
* - GET /kernel → agent_id / plugins / tools / llm / memory / documents /
* text_memory / knowledge / runtime
* 后端不提供 token 用量、配额、错误列表,所以这里也没有。
*/
/** 一行明细 */
export interface StatField {
label: string;
value: string;
}
/** 一组明细卡 */
export interface StatGroup {
title: string;
fields: StatField[];
}
// ===== AppStorage 键:摘要卡与明细页共用 =====
export const K_UP: string = 'statUp';
export const K_VERSION: string = 'statVersion';
export const K_STARTED: string = 'statStartedAt';
export const K_AGENTS: string = 'statAgents';
export const K_PLUGINS: string = 'statPlugins';
export const K_TOOLS: string = 'statTools';
export const K_ERR: string = 'statErr';
export const K_LOADING: string = 'statLoading';
export const K_REV: string = 'statRev';
class StatusStore {
/** 明细分组:只有明细页读它,不进 AppStorage数组同步语义太脆 */
private groups: StatGroup[] = [];
init(): void {
AppStorage.setOrCreate<boolean>(K_UP, false);
AppStorage.setOrCreate<string>(K_VERSION, '-');
AppStorage.setOrCreate<string>(K_STARTED, '');
AppStorage.setOrCreate<number>(K_AGENTS, 0);
AppStorage.setOrCreate<number>(K_PLUGINS, 0);
AppStorage.setOrCreate<number>(K_TOOLS, 0);
AppStorage.setOrCreate<string>(K_ERR, '');
AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.setOrCreate<number>(K_REV, 0);
}
getGroups(): StatGroup[] {
return this.groups;
}
isUp(): boolean {
return AppStorage.get<boolean>(K_UP) ?? false;
}
async refresh(): Promise<void> {
if (!apiClient.hasConnection()) {
this.fail(noConnectionMessage());
return;
}
AppStorage.setOrCreate<boolean>(K_LOADING, true);
AppStorage.setOrCreate<string>(K_ERR, '');
try {
const resp = await apiClient.getWithTimeout('/status', 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const version: string = obj['version'] as string ?? '未知';
const status: string = obj['status'] as string ?? 'unknown';
const uptimeStr: string = obj['uptime'] as string ?? '';
const agents: number = obj['agents'] as number ?? 0;
const startedAt: string = obj['startedAt'] as string ?? '';
AppStorage.setOrCreate<boolean>(K_UP, true);
AppStorage.setOrCreate<string>(K_VERSION, version);
AppStorage.setOrCreate<number>(K_AGENTS, agents);
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
const systemFields: StatField[] = [
{ label: '版本', value: version },
{ label: '运行状态', value: statusText(status) },
{ label: '运行时长', value: uptimeStr.length > 0 ? uptimeStr : '-' },
];
if (startedAt.length > 0) {
systemFields.push({ label: '启动时间', value: formatTime(startedAt) });
}
const groups: StatGroup[] = [
{ title: '系统概览', fields: systemFields },
];
await this.collectKernel(groups);
this.groups = groups;
this.bump();
} catch (e) {
this.fail(userMessage('status.refresh', e));
}
AppStorage.setOrCreate<boolean>(K_LOADING, false);
}
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
private async collectKernel(groups: StatGroup[]): Promise<void> {
try {
const kResp = await apiClient.getWithTimeout('/kernel', 8000);
const k: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const agentId: string = k['agent_id'] as string ?? 'main';
const startTime: string = k['start_time'] as string ?? '';
const pluginsArr: Object[] | undefined = k['plugins'] as Object[];
const toolsArr: Object[] | undefined = k['tools'] as Object[];
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
const kernelFields: StatField[] = [
{ label: 'Agent ID', value: agentId },
{ label: '已加载插件', value: pluginCount.toString() },
{ label: '工具数', value: toolCount.toString() },
];
if (startTime.length > 0) {
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
}
groups.push({ title: '内核', fields: kernelFields });
// LLMprovider / 可用源 / 是否可用
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
if (llm !== undefined && llm !== null) {
const provider: string = llm['provider'] as string ?? '';
const sources: number = llm['sources'] as number ?? 0;
const available: boolean = llm['available'] as boolean ?? false;
groups.push({
title: '模型',
fields: [
{ label: 'Provider', value: provider.length > 0 ? provider : '未配置' },
{ label: '可用源', value: sources.toString() },
{ label: '状态', value: available ? '运行中' : '不可用' },
],
});
}
// 记忆:图 / 文档 / 文本 / 知识库
const memFields: StatField[] = [];
const mem: Record<string, Object> | undefined = k['memory'] as Record<string, Object>;
if (mem !== undefined && mem !== null) {
const ok: boolean = mem['available'] as boolean ?? false;
const ent: number = mem['entity_count'] as number ?? 0;
const rel: number = mem['relation_count'] as number ?? 0;
memFields.push({
label: '图记忆',
value: ok ? ent.toString() + ' 实体 · ' + rel.toString() + ' 关系' : '未初始化',
});
}
const docs: Record<string, Object> | undefined = k['documents'] as Record<string, Object>;
if (docs !== undefined && docs !== null) {
const ok: boolean = docs['available'] as boolean ?? false;
const n: number = docs['doc_count'] as number ?? 0;
memFields.push({ label: '文档记忆', value: ok ? n.toString() + ' 文档' : '未初始化' });
}
const tm: Record<string, Object> | undefined = k['text_memory'] as Record<string, Object>;
if (tm !== undefined && tm !== null) {
const ok: boolean = tm['available'] as boolean ?? false;
const n: number = tm['file_count'] as number ?? 0;
memFields.push({ label: '文本记忆', value: ok ? n.toString() + ' 文件' : '未初始化' });
}
const kb: Record<string, Object> | undefined = k['knowledge'] as Record<string, Object>;
if (kb !== undefined && kb !== null) {
const ok: boolean = kb['available'] as boolean ?? false;
const n: number = kb['item_count'] as number ?? 0;
memFields.push({ label: '知识库', value: ok ? n.toString() + ' 项' : '未初始化' });
}
if (memFields.length > 0) {
groups.push({ title: '记忆', fields: memFields });
}
// 运行时goroutine / 内存 / Go 版本
const rt: Record<string, Object> | undefined = k['runtime'] as Record<string, Object>;
if (rt !== undefined && rt !== null) {
const g: number = rt['goroutines'] as number ?? 0;
const mb: number = rt['memory_mb'] as number ?? 0;
const gov: string = rt['go_version'] as string ?? '';
groups.push({
title: '运行时',
fields: [
{ label: 'Goroutines', value: g.toString() },
{ label: '内存占用', value: mb.toString() + ' MB' },
{ label: 'Go 版本', value: gov.length > 0 ? gov : '-' },
],
});
}
// 工具清单:名称 → 归属插件
if (toolsArr !== undefined && toolsArr.length > 0) {
const toolFields: StatField[] = [];
for (let i = 0; i < toolsArr.length; i++) {
const t: Record<string, Object> = toolsArr[i] as Record<string, Object>;
const name: string = t['name'] as string ?? '';
const plugin: string = t['plugin'] as string ?? '';
if (name.length > 0) {
toolFields.push({ label: name, value: plugin });
}
}
if (toolFields.length > 0) {
groups.push({ title: '可用工具', fields: toolFields });
}
}
} catch (e) {
// /kernel 不可用时只保留 /status 的概览分组
}
}
private fail(msg: string): void {
AppStorage.setOrCreate<string>(K_ERR, msg);
AppStorage.setOrCreate<boolean>(K_UP, false);
AppStorage.setOrCreate<boolean>(K_LOADING, false);
this.groups = [];
this.bump();
}
/** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */
private bump(): void {
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
AppStorage.setOrCreate<number>(K_REV, cur + 1);
}
}
export const statusStore: StatusStore = new StatusStore();
export function statusText(s: string): string {
if (s === 'running') {
return '运行中';
}
if (s === 'stopped') {
return '已停止';
}
if (s === 'starting') {
return '启动中';
}
return s;
}
export function formatTime(iso: string): string {
const t: number = new Date(iso).getTime();
if (isNaN(t) || t <= 0) {
return iso;
}
const d = new Date(t);
return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
/**
* 紧凑运行时长:环心里只有 96vp 单行,必须用 12h48m 这种记法,
* 「10时 49分 16秒」会折行。
*/
export function compactDuration(startedAt: string): string {
if (startedAt.length === 0) {
return '-';
}
const t: number = new Date(startedAt).getTime();
const now: number = Date.now();
if (isNaN(t) || t <= 0 || now <= t) {
return '-';
}
const sec: number = Math.floor((now - t) / 1000);
const days: number = Math.floor(sec / 86400);
const hours: number = Math.floor((sec % 86400) / 3600);
const mins: number = Math.floor((sec % 3600) / 60);
if (days > 0) {
return days.toString() + 'd ' + hours.toString() + 'h';
}
if (hours > 0) {
return hours.toString() + 'h ' + mins.toString() + 'm';
}
return mins.toString() + 'm ' + (sec % 60).toString() + 's';
}
function pad2(n: number): string {
return n < 10 ? '0' + n.toString() : n.toString();
}

View File

@ -0,0 +1,178 @@
import { hilog } from '@kit.PerformanceAnalysisKit';
import { ApiError } from './ApiClient';
/**
* 面向用户的错误文案统一出口。
*
* 问题背景:之前各页面直接把 `e.message` 拼进 UI于是屏幕上出现
* "获取状态失败: Failed to connect to the server."、原始 JSON 报错体、
* 甚至后端堆栈。这类文本对用户没有意义,还会泄露内网地址与实现细节。
*
* 约定:
* - UI 只显示 userMessage() 返回的短句(人话、可行动、不含技术细节);
* - 技术细节(原始 message / HTTP 状态码 / 响应体)只写进 hilog
* 通过 `hdc shell hilog | grep HomeAgent` 排查,不进 UI。
*/
const DOMAIN: number = 0xA0A0;
const TAG: string = 'HomeAgent';
/** 网络类错误的统一提示不暴露主机名、端口、curl 错误码。 */
const MSG_UNREACHABLE: string = '连接不上后端服务,请检查网络与服务地址';
const MSG_TIMEOUT: string = '后端响应超时,请稍后重试';
const MSG_AUTH: string = 'API Key 无效或已过期,请在设置里更新';
const MSG_FORBIDDEN: string = '没有访问权限,请检查 API Key 的权限范围';
const MSG_NOT_FOUND: string = '后端没有这个接口,可能版本不匹配';
const MSG_SERVER: string = '后端服务内部出错,请查看服务端日志';
const MSG_BAD_DATA: string = '后端返回的数据无法解析';
const MSG_TLS: string = '证书校验失败,请检查 HTTPS 配置';
const MSG_GENERIC: string = '操作失败,请稍后重试';
const MSG_NO_CONN: string = '尚未配置后端连接,请先在设置里添加';
/** 一眼判定是否"网络层根本没连上",用于页面显示离线态而不是报错态。 */
export function isOffline(e: Object): boolean {
const code: number = businessCode(e);
if (code === 2300006 || code === 2300007 || code === 2300005 ||
code === 2300052 || code === 2300056 || code === 2300028) {
return true;
}
if (e instanceof ApiError) {
return e.status < 0;
}
return false;
}
/**
* 是否为超时。聊天场景里超时不算失败——请求已经到后端,
* 只是回复还没生成完UI 要显示"等待回复"而不是报错。
*/
export function isTimeout(e: Object): boolean {
const code: number = businessCode(e);
if (code === 2300028) {
return true;
}
if (e instanceof ApiError && (e.status === 408 || e.status === 504)) {
return true;
}
const raw: string = rawMessage(e);
return raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0;
}
/**
* 把任意异常翻译成一句用户能看懂、且不含技术细节的话。
* 同时把原始信息写入 hilogscene 用于定位是哪个调用点)。
*/
export function userMessage(scene: string, e: Object): string {
logDetail(scene, e);
if (e instanceof ApiError) {
const st: number = e.status;
if (st === 401) {
return MSG_AUTH;
}
if (st === 403) {
return MSG_FORBIDDEN;
}
if (st === 404) {
return MSG_NOT_FOUND;
}
if (st === 408 || st === 504) {
return MSG_TIMEOUT;
}
if (st >= 500) {
return MSG_SERVER;
}
if (st < 0) {
return MSG_NO_CONN;
}
if (st >= 400) {
// 4xx 里后端通常给了业务原因,但不保证是人话,也可能带内部路径。
// 只在明显短且不含技术噪音时透传,否则退回通用文案。
return safeBackendReason(e.message);
}
}
const code: number = businessCode(e);
if (code === 2300028) {
return MSG_TIMEOUT;
}
if (code === 2300005 || code === 2300006 || code === 2300007 ||
code === 2300052 || code === 2300055 || code === 2300056) {
return MSG_UNREACHABLE;
}
if (code === 2300058 || code === 2300059 || code === 2300060 || code === 2300077) {
return MSG_TLS;
}
if (code === 2300001 || code === 2300003) {
return '服务地址格式不正确,请在设置里检查';
}
if (code === 2300009 || code === 2300094) {
return MSG_AUTH;
}
if (code === 2300997 || code === 2300998) {
return '系统禁止访问该地址,请改用 HTTPS 或放开域名白名单';
}
if (e instanceof SyntaxError) {
// JSON.parse 失败
return MSG_BAD_DATA;
}
const raw: string = rawMessage(e);
if (raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0) {
return MSG_TIMEOUT;
}
return MSG_GENERIC;
}
/** 未配置连接时的统一文案,页面不要各写一份。 */
export function noConnectionMessage(): string {
return MSG_NO_CONN;
}
/**
* 4xx 的后端说明只在"看起来是给人看的"时才透传:
* 短、无换行、不含路径/括号异常/HTML/JSON 花括号。
*/
function safeBackendReason(msg: string): string {
const s: string = msg.trim();
if (s.length === 0 || s.length > 60) {
return MSG_GENERIC;
}
if (s.indexOf('\n') >= 0 || s.indexOf('{') >= 0 || s.indexOf('<') >= 0 ||
s.indexOf('/') >= 0 || s.indexOf('0x') >= 0 || s.indexOf('Exception') >= 0 ||
s.indexOf('Error:') >= 0 || s.indexOf('panic') >= 0) {
return MSG_GENERIC;
}
return s;
}
/** 取 BusinessError.codeArkTS 不允许 in / 索引访问,用可选字段读取)。 */
function businessCode(e: Object): number {
const be = e as BusinessErrorLike;
const c: number | undefined = be.code;
return c !== undefined ? c : 0;
}
function rawMessage(e: Object): string {
if (e instanceof Error) {
return e.message;
}
return String(e);
}
function logDetail(scene: string, e: Object): void {
const code: number = businessCode(e);
let detail: string = rawMessage(e);
if (e instanceof ApiError) {
detail = 'HTTP ' + e.status.toString() + ' ' + detail + ' body=' + e.body;
}
hilog.error(DOMAIN, TAG, '%{public}s failed: code=%{public}d detail=%{private}s',
scene, code, detail);
}
/** BusinessError 的最小结构(避免为了读 code 而 import 整个 kit。 */
interface BusinessErrorLike {
code?: number;
message?: string;
}

View File

@ -0,0 +1,463 @@
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { apiClient } from '../common/ApiClient';
import { userMessage } from '../common/UserError';
import { ChatAttachment } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { PlainCard } from './SubPage';
/**
* 附件解析与展示。
*
* 后端 Attachment 只有四个字段type / url / size / name
* internal/plugins/webui/handler.go没有 mime、没有像素尺寸、没有本地路径。
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
*
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
* 这两条路由在后端是 requireWeb但对 API Key 客户端同等放行,
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
*/
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const o: Record<string, Object> = raw as Record<string, Object>;
const url: string = o['url'] as string ?? '';
if (url.length === 0) {
return undefined;
}
const t: string = o['type'] as string ?? 'file';
const a: ChatAttachment = {
type: t === 'image' ? 'image' : 'file',
url: url,
size: o['size'] as number ?? 0,
name: o['name'] as string ?? fileNameOf(url),
};
return a;
}
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
export function attachmentFromChannelOutput(
outputType: string, url: string, size: number): ChatAttachment | undefined {
if (url.length === 0) {
return undefined;
}
if (outputType !== 'image' && outputType !== 'file') {
return undefined;
}
const a: ChatAttachment = {
type: outputType,
url: url,
size: size,
name: fileNameOf(url),
};
return a;
}
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
export function fileNameOf(url: string): string {
let s: string = url;
const q: number = s.indexOf('?');
if (q >= 0) {
s = s.substring(0, q);
}
const i: number = s.lastIndexOf('/');
const name: string = i >= 0 ? s.substring(i + 1) : s;
return name.length > 0 ? name : '附件';
}
/** 人类可读字节数,口径对齐后端 formatBytesKB 以上保留一位小数)。 */
export function formatBytes(n: number): string {
if (n <= 0) {
return '';
}
if (n < 1024) {
return n.toString() + ' B';
}
const kb: number = n / 1024;
if (kb < 1024) {
return oneDecimal(kb) + ' KB';
}
const mb: number = kb / 1024;
if (mb < 1024) {
return oneDecimal(mb) + ' MB';
}
return oneDecimal(mb / 1024) + ' GB';
}
function oneDecimal(v: number): string {
return (Math.round(v * 10) / 10).toString();
}
/** 由文件名后缀猜测类型标签。后端不返回 mime只能这样标注。 */
export function extLabel(name: string): string {
const i: number = name.lastIndexOf('.');
if (i < 0 || i === name.length - 1) {
return '未知类型';
}
return name.substring(i + 1).toUpperCase();
}
/**
* 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。
* 点击进入附件详情二级页面WebGUI 是新开标签页,移动端改为二级页)。
*/
@Component
export struct AttachmentCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop att: ChatAttachment;
@Prop mine: boolean = false;
onTap?: () => void;
@State private pixel: image.PixelMap | undefined = undefined;
@State private failed: boolean = false;
aboutToAppear(): void {
if (this.att.type === 'image') {
this.loadThumb();
}
}
private async loadThumb(): Promise<void> {
const pm: image.PixelMap | undefined = await loadPixelMap(this.att.url);
if (pm === undefined) {
this.failed = true;
return;
}
this.pixel = pm;
}
build() {
// 整卡按压反馈统一收进 MotionBase父组件scale 回弹 + 透明度压暗。
// fillWidth: false —— 附件卡在聊天气泡内按内容自适应宽度,不能撑满整行。
MotionBase({ pressEnabled: true, pressOpacity: 0.88, fillWidth: false }) {
Column({ space: 6 }) {
Text(this.mine ? '你发送的' : '小宅发送的')
.fontSize(11)
.fontColor(this.palette().textMuted)
if (this.att.type === 'image') {
if (this.pixel !== undefined) {
Image(this.pixel)
.width('100%')
.constraintSize({ maxHeight: 220 })
.objectFit(ImageFit.Cover)
.borderRadius(RADIUS_MD)
.draggable(false)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.scale({ x: 0.98, y: 0.98 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} else if (this.failed) {
Row({ space: 6 }) {
Image($r('app.media.ic_error'))
.width(14)
.height(14)
.fillColor(COLOR_ERROR)
.draggable(false)
Text('图片加载失败')
.fontSize(12)
.fontColor(COLOR_ERROR)
}
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else {
Row() {
LoadingProgress()
.width(20)
.height(20)
.color(this.palette().accent)
}
.width(120)
.height(80)
.justifyContent(FlexAlign.Center)
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
} else {
Row({ space: 8 }) {
Image($r('app.media.ic_file'))
.width(18)
.height(18)
.fillColor(this.palette().accent)
.draggable(false)
Column({ space: 2 }) {
Text(this.att.name)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.att.size > 0) {
Text(formatBytes(this.att.size))
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.ic_chevron_right'))
.width(14)
.height(14)
.fillColor(this.palette().textMuted)
.draggable(false)
}
.constraintSize({ minWidth: 180 })
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.mine ? this.palette().accentBg : this.palette().bgHover)
.alignItems(VerticalAlign.Center)
}
}
.alignItems(HorizontalAlign.Start)
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 附件详情二级页面内容:大图预览 / 文件信息 + 保存到本地。
*
* 后端不提供尺寸与 mime图片尺寸由本地解码得到类型标签由后缀推断
* 界面上如实标注它们的来源,不谎称是服务端元数据。
*/
@Component
export struct AttachmentDetailContent {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop att: ChatAttachment;
@State private pixel: image.PixelMap | undefined = undefined;
@State private pxWidth: number = 0;
@State private pxHeight: number = 0;
@State private loading: boolean = true;
@State private err: string = '';
@State private savedPath: string = '';
aboutToAppear(): void {
if (this.att.type === 'image') {
this.loadFull();
} else {
this.loading = false;
}
}
private async loadFull(): Promise<void> {
this.loading = true;
this.err = '';
try {
const abs: string = apiClient.absoluteUrl(this.att.url);
const resp = await apiClient.getBinary(abs, 20000);
const src: image.ImageSource = image.createImageSource(resp.data);
const info: image.ImageInfo = await src.getImageInfo();
this.pxWidth = info.size.width;
this.pxHeight = info.size.height;
this.pixel = await src.createPixelMap();
await src.release();
} catch (e) {
this.err = userMessage('attachment.load', e);
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.loading = false;
});
}
/** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */
private async saveToSandbox(): Promise<void> {
try {
const abs: string = apiClient.absoluteUrl(this.att.url);
const resp = await apiClient.getBinary(abs, 30000);
const ctx = getContext(this) as common.UIAbilityContext;
const dest: string = ctx.filesDir + '/' + sanitize(this.att.name);
const f = fileIo.openSync(dest,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.writeSync(f.fd, resp.data);
fileIo.closeSync(f);
this.savedPath = dest;
} catch (e) {
this.err = userMessage('attachment.save', e);
}
}
build() {
Column() {
if (this.err.length > 0) {
Row({ space: 8 }) {
Image($r('app.media.ic_error'))
.width(16)
.height(16)
.fillColor(COLOR_ERROR)
.draggable(false)
Text(this.err)
.fontSize(13)
.fontColor(COLOR_ERROR)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(VerticalAlign.Top)
.margin({ bottom: 14 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
// 预览:图片铺满宽度可缩放查看;文件给一个大图标占位
Column() {
if (this.att.type === 'image') {
if (this.loading) {
Row() {
LoadingProgress()
.width(28)
.height(28)
.color(this.palette().accent)
}
.width('100%')
.height(200)
.justifyContent(FlexAlign.Center)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else if (this.pixel !== undefined) {
Image(this.pixel)
.width('100%')
.constraintSize({ maxHeight: 420 })
.objectFit(ImageFit.Contain)
.borderRadius(RADIUS_MD)
.draggable(false)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
} else {
Column({ space: 10 }) {
Image($r('app.media.ic_file'))
.width(46)
.height(46)
.fillColor(this.palette().accent)
.draggable(false)
Text(extLabel(this.att.name))
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
.width('100%')
.padding({ top: 26, bottom: 26 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
.width('100%')
.padding(12)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.margin({ bottom: 14 })
PlainCard({ caption: '信息' }) {
this.kv('文件名', this.att.name)
this.kv('类型', this.att.type === 'image' ? '图片' : '文件')
this.kv('格式', extLabel(this.att.name))
this.kv('大小', this.att.size > 0 ? formatBytes(this.att.size) : '未知(远程链接)')
if (this.pxWidth > 0 && this.pxHeight > 0) {
// 后端不返回像素尺寸,这一行来自本地解码
this.kv('像素', this.pxWidth.toString() + ' × ' + this.pxHeight.toString())
}
this.kv('来源', this.att.url.startsWith('http') ? '远程链接' : '服务端中转')
}
PlainCard({ caption: '操作' }) {
Button('保存到应用目录')
.width('100%')
.height(38)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.onClick(() => {
this.saveToSandbox();
})
if (this.savedPath.length > 0) {
Text('已保存:' + this.savedPath)
.fontSize(11)
.fontColor(this.palette().textMuted)
.width('100%')
.margin({ top: 8 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 6 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
kv(label: string, value: string) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 210 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/** 下载并解码成 PixelMap任何一步失败都返回 undefined调用方显示占位。 */
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
try {
// 本地待上传的图片:直接读沙箱文件,不走网络
if (url.startsWith('file://')) {
const path: string = url.substring(7);
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const localSrc: image.ImageSource = image.createImageSource(f.fd);
const localPm: image.PixelMap = await localSrc.createPixelMap();
await localSrc.release();
fileIo.closeSync(f);
return localPm;
}
const abs: string = apiClient.absoluteUrl(url);
const resp = await apiClient.getBinary(abs, 15000);
const src: image.ImageSource = image.createImageSource(resp.data);
const pm: image.PixelMap = await src.createPixelMap();
await src.release();
return pm;
} catch (e) {
return undefined;
}
}
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
function sanitize(name: string): string {
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
if (s.length === 0) {
s = 'attachment';
}
return s;
}

View File

@ -0,0 +1,127 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 背景层:底色 + 三层径向渐变光斑(右上蓝 / 左中青 / 底部深),
* 对应 WebGUI 的 grad-a / grad-b / grad-c。
*
* 独立成文件的原因Navigation 的 NavDestination 在栈模式下会整屏盖住
* 一级内容,必须自带同款背景(否则透出下层列表形成重影)。
* 如果这个组件留在 Index.ets 里,二级页面 import 它会与
* Index -> SettingsPage -> Index 形成循环依赖。
*/
@Component
export struct GradientBackground {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('bgImage') @Watch('onBgChanged') private bgImage: string = '';
@StorageProp('bgOpacity') private bgOpacity: number = 0.25;
/**
* 背景图解码后的位图。
*
* 为什么不把路径字符串直接交给 Image
* 把沙箱路径交给 Image 要同时满足"带 file:// 协议头"和"带可识别的图片后缀"
* 两个隐含前提,任何一条不满足都只是静默不显示 —— 前两轮"背景不生效"就是
* 卡在这里,而且没有任何可观测的失败点。
* 这里改成自己用 ImageSource 解码(与聊天图片附件同一条已验证通路),
* 成功与失败都能落 hilogImage 只负责画一张现成的 PixelMap。
*/
@State private bgPixel: image.PixelMap | undefined = undefined;
aboutToAppear(): void {
this.decodeBg();
}
private onBgChanged(): void {
this.decodeBg();
}
/** 解码沙箱/网络背景图为 PixelMap失败时清空只留渐变底。 */
private async decodeBg(): Promise<void> {
const src: string = this.bgImage;
if (src.length === 0) {
this.bgPixel = undefined;
return;
}
// 只处理本地沙箱文件http(s) 背景图不是本应用的场景(设置页只给图库选择)
const path: string = src.startsWith('file://') ? src.substring(7) : src;
if (!path.startsWith('/')) {
hilog.error(0x0000, 'HomeAgent', 'bg: unsupported source');
this.bgPixel = undefined;
return;
}
try {
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const srcObj: image.ImageSource = image.createImageSource(f.fd);
const pm: image.PixelMap = await srcObj.createPixelMap();
await srcObj.release();
fileIo.closeSync(f);
this.bgPixel = pm;
hilog.info(0x0000, 'HomeAgent', 'bg decoded ok');
} catch (e) {
// 失败必须留痕:否则表现就是"设置了但没生效",无从定位
const err = e as BusinessError;
hilog.error(0x0000, 'HomeAgent', 'bg decode failed code=%{public}d', err.code as number);
this.bgPixel = undefined;
}
}
build() {
Stack() {
// 底色
Column()
.width('100%')
.height('100%')
.backgroundColor(this.palette().bgPrimary)
// grad-a: 右上角蓝色光斑
Column()
.width('100%')
.height('100%')
.radialGradient({
center: ['88%', '-4%'],
radius: 520,
colors: [[this.palette().gradA, 0.0], [this.palette().gradAEnd, 1.0]],
})
// grad-b: 左中部霜冻青光斑
Column()
.width('100%')
.height('100%')
.radialGradient({
center: ['-6%', '38%'],
radius: 470,
colors: [[this.palette().gradB, 0.0], [this.palette().gradBEnd, 1.0]],
})
// grad-c: 底部深色光斑
Column()
.width('100%')
.height('100%')
.radialGradient({
center: ['50%', '110%'],
radius: 560,
colors: [[this.palette().gradC, 0.0], [this.palette().gradCEnd, 1.0]],
})
// 自定义背景图(可选):叠在渐变光斑之上、页面内容之下
if (this.bgPixel !== undefined) {
Image(this.bgPixel)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.opacity(this.bgOpacity)
.draggable(false)
}
}
.width('100%')
.height('100%')
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,155 @@
import { MarkdownStream, StreamingMarkdown } from '@ycj3/streaming-markdown';
import type { StreamingMarkdownConfig } from '@ycj3/streaming-markdown';
import { StaticMarkdownView } from './StaticMarkdown';
import { DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
/**
* MarkdownView — unified markdown renderer for chat messages.
*
* - isStreaming=true → uses @ycj3/streaming-markdown live StreamingMarkdown
* (incremental, animated — ideal for SSE output).
* - isStreaming=false → uses StaticMarkdownView (parses once, no timers,
* instant render — ideal for history / finalized messages).
*/
@Component
export struct MarkdownView {
@Prop content: string = '';
@Prop isStreaming: boolean = false;
@Prop isDark: boolean = true;
private stream: MarkdownStream = new MarkdownStream({ mode: 'word', interval: 18 });
private seeded: boolean = false;
private pushedLen: number = 0;
aboutToAppear(): void {
if (this.isStreaming) {
this.seedStream();
this.seeded = true;
}
}
// Triggered by @Prop changes (streaming updates)
private onContentChange(): void {
if (this.isStreaming && this.seeded) {
const c: string = this.content;
const prev: number = this.pushedLen;
if (c.length > prev) {
const delta: string = c.substring(prev);
this.stream.append(delta);
this.pushedLen = c.length;
return;
}
// non-prefix change → reseed
this.seedStream();
}
}
private seedStream(): void {
this.stream.reset();
this.pushedLen = 0;
const c: string = this.content;
if (c.length > 0) {
this.stream.append(c);
this.pushedLen = c.length;
}
// Streaming message stays open; finish happens when isStreaming flips false.
}
/**
* When a streaming message finalizes, mark the stream complete.
*/
private onStreamingEnd(): void {
if (this.seeded) {
this.stream.finish();
}
}
private getConfig(): StreamingMarkdownConfig {
const p = this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
const config: StreamingMarkdownConfig = {
heading: {
sizes: [28, 24, 20, 18, 16, 14],
color: p.textPrimary,
topSpacing: 8,
bottomSpacing: 6,
},
paragraph: {
fontSize: 15,
lineHeight: 24,
color: p.msgBubbleText,
bottomSpacing: 6,
},
list: {
fontSize: 15,
lineHeight: 24,
itemBottomSpacing: 3,
color: p.msgBubbleText,
},
blockquote: {
textColor: p.textTertiary,
bgColor: p.bgHover,
borderColor: p.kvBorder,
bottomSpacing: 6,
},
codeBlock: {
borderColor: p.kvBorder,
radius: 10,
topSpacing: 8,
bottomSpacing: 8,
},
table: {
headerBgColor: p.bgHover,
borderColor: p.kvBorder,
stripeBgColor: p.bgHover,
cellFontSize: 13,
topSpacing: 6,
bottomSpacing: 6,
},
horizontalRule: {
color: p.kvBorder,
topSpacing: 8,
bottomSpacing: 8,
},
inline: {
linkColor: p.accent,
codeTextColor: p.preText,
codeBgColor: p.preBg,
mathTextColor: p.accent,
mathBgColor: p.bgHover,
monoFontFamily: 'monospace',
},
layout: {
contentPadding: { left: 0, right: 0, top: 0, bottom: 0 },
},
};
return config;
}
build() {
// 布局说明(已由 uitest dumpLayout 实测确认):
// 父气泡 BubbleBody 为内容自适应宽度constraintSize maxWidth 78%)且带 12vp 左右 padding。
// 在这种"内容自适应 + padding"的父节点下,后代节点的 width('100%') 会被解析成父气泡的
// 外框宽度而不是内容框宽度,于是 markdown 内容整体右溢出 12vp 并被气泡 clip 裁掉。
// 解决办法:用 Row + layoutWeight(1) 代替百分比宽度。layoutWeight 走的是"剩余约束分配"
// 而不是百分比解析,能拿到正确的内容框宽度,再把这个确定宽度传给 StaticMarkdown
// 其内部各层的 width('100%') 就有了正确的解析基准。
Row() {
Column() {
if (this.isStreaming) {
StreamingMarkdown({
stream: this.stream,
config: this.getConfig(),
})
} else {
StaticMarkdownView({
content: this.content,
isDark: this.isDark,
})
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Top)
}
}

View File

@ -0,0 +1,87 @@
/**
* MotionBase —— 全局通用动效的"父组件"。
*
* 目标:把各个组件重复实现的按压反馈收拢到这唯一一个父组件里,
* 全局统一手感(时长 token + EaseOut 曲线 + 形变幅度)。
* 公共组件NavRow / StatusSummaryCard / AttachmentCard / 插件卡片 …)
* 在自己的 build 根节点接入 MotionBase通用按压动效即随父组件生效
* 子组件无需再各自维护 @State pressed + scale + animation + onTouch 四件套。
*
* 特有动画(多态扩展):留在各组件内部。例如聊天输入框向上弹出
* ChatPage 的 inputMultiLine 上移 + Curve.Friction 展开)、加号菜单浮起、
* 折叠面板展开、toast 进出场——这些是"某个组件专属"的动效,
* 不应塞进父组件,由各组件按需声明。
*
* ArkUI V1 的 @Component struct 不能继承另一位 struct 的 build
* 这里用「组合」表达继承:调用组件把内容经唯一内容插槽传给 MotionBase
* MotionBase 在包装节点上统一挂通用动效修饰器。
*
* 通用动效契约:
* - 按压反馈pressEnabled 时整容器 scale → pressScale快速回弹。
* V1 自带按压态在玻璃/透明底上几乎不可见,这里统一补足;
* scale 只作用于容器自身,不侵入子节点外观。
* - onPress 只是按压瞬时的轻量钩子(如提前高亮),
* 真正的导航/提交语义仍由调用组件的 onClick点击释放负责
* 二者不耦合,避免"按下即触发"的手感偏差。
*
* 用法:
* MotionBase({ pressEnabled: true }) {
* // 实际内容;外层 onClick 由调用组件自己绑在内容上
* }
*
* 注意:包装节点默认 width('100%')。像聊天气泡里的附件卡那样需要
* 「内容自适应宽度」的组件传 fillWidth: false否则会被撑满整行。
*/
import { ANIM_FAST, PRESS_SCALE } from '../common/Constants';
@Component
export struct MotionBase {
/** 内容插槽:调用组件把实际内容传进来 */
@BuilderParam content: () => void;
/** 是否启用按压反馈(默认关,纯展示容器传 false 零开销) */
@Prop pressEnabled: boolean = false;
/** 按下的瞬间回调(可选):只做轻量即时反馈,不要在这里做导航/提交 */
onPress?: () => void;
/** 按压缩放幅度,默认全局 PRESS_SCALE */
@Prop pressScale: number = PRESS_SCALE;
/** 按压时整体透明度1 = 不启用透明度反馈) */
@Prop pressOpacity: number = 1;
/** 包装节点是否撑满父宽false = 由内容决定宽度(气泡内的卡片必须传 false */
@Prop fillWidth: boolean = true;
/** 在 Row/Column 中参与剩余空间分配的权重0 = 不参与(等分排列的按钮传 1 */
@Prop flexWeight: number = 0;
/** 按压态:只对容器自身 scale 生效 */
@State private pressed: boolean = false;
build() {
Column() {
this.content()
}
.width(this.fillWidth && this.flexWeight <= 0 ? '100%' : undefined)
.layoutWeight(this.flexWeight)
.alignItems(HorizontalAlign.Start)
// ===== 通用动效(父组件统一声明,子组件继承) =====
// 按压回弹pressed 驱动 scale 增量,.animation 在下方覆盖该增量属性
.scale({
x: this.pressed ? this.pressScale : 1,
y: this.pressed ? this.pressScale : 1,
})
.opacity(this.pressed ? this.pressOpacity : 1)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onTouch((e: TouchEvent) => {
if (!this.pressEnabled) {
return;
}
if (e.type === TouchType.Down) {
this.pressed = true;
const cb = this.onPress;
if (cb !== undefined) {
cb();
}
} else if (e.type === TouchType.Up || e.type === TouchType.Cancel) {
this.pressed = false;
}
})
}
}

View File

@ -0,0 +1,215 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { ANIM_FAST, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from './MotionBase';
/**
* 顶栏高度vp。页面 padding-top 应略大于此值,避免内容被标题压住,
* 同时不要留过多空白。
*/
export const TOP_BAR_HEIGHT: number = 68;
/**
* 页面顶栏遮罩:仅标题文字,无按钮无状态。
* 浮在滚动区之上(页面用 PageTopBarLayer 置顶),背景为 不透明 -> 透明 的线性渐变,
* 滚动内容从其下方穿过时逐渐淡出,形成"逐渐加深"的柔化边界,而不是硬截断。
*
* 关键:自定义组件被外部施加 .position() 时ArkUI 会生成一个 __Common__ 包裹节点,
* 该节点默认铺满父约束并使用默认命中测试 —— 会吞掉整页触摸事件。
* 因此必须在【调用点】同时显式给出尺寸与 hitTestBehavior(None)
* 统一封装在 PageTopBarLayer 里,页面不要再手写 .position()。
*/
@Component
export struct PageTopBar {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop title: string = '';
build() {
// 外层撑满整页并顶部对齐,替代调用点的 .position()
// 自定义组件一旦在调用点被施加 .position()ArkUI 会生成铺满父约束的
// __Common__ 包裹节点,该节点使用默认命中测试,会吞掉整页的滚动与点击。
// 这里改为自身撑满 + 全链路 HitTestMode.None触摸完全穿透到下层滚动区。
Column() {
Column() {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.margin({ left: 16, top: 10 })
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height(TOP_BAR_HEIGHT)
.alignItems(HorizontalAlign.Start)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
[this.opaqueBg(), 0.0],
[this.opaqueBg(), 0.45],
[this.transparentBg(), 1.0],
],
})
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Start)
.hitTestBehavior(HitTestMode.None)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private opaqueBg(): string {
const bg: string = this.palette().bgPrimary;
return '#FF' + bg.substring(1);
}
private transparentBg(): string {
const bg: string = this.palette().bgPrimary;
return '#00' + bg.substring(1);
}
}
/**
* 独立气态玻璃节点:单个悬浮组件,与底部导航同款玻璃(半透明底 + 高光渐变)。
* 尺寸自适应内容(宽度随内容,高度/圆角参数化),视觉上独立、不与其他组件共框。
* 用于承载状态胶囊、图标按钮等单个悬浮元素。
*/
@Component
export struct GlassShell {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop nodeHeight: number = 32;
@Prop nodeRadius: number = 16;
@BuilderParam content: () => void;
build() {
Row() {
this.content()
}
.height(this.nodeHeight)
.padding({ left: 8, right: 8 })
.alignItems(VerticalAlign.Center)
.backgroundColor(this.palette().navBarBg)
.borderRadius(this.nodeRadius)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 16,
color: this.palette().shadow,
offsetY: 5,
})
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 悬浮区圆形图标按钮的直径vp。徽标不使用它——徽标是竖向窄条。
*/
export const FLOAT_NODE_SIZE: number = 42;
/**
* 悬浮图标按钮:与底部导航同一套玻璃语言的圆形按钮。
* 统一 42vp 直径、同款玻璃底/描边/投影,图标 18vp。
*/
@Component
export struct FloatIconButton {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource | undefined = undefined;
@Prop accent: boolean = false;
onTap?: () => void;
build() {
// 按压缩放交给 MotionBase父组件统一手感
// fillWidth: false —— 悬浮区靠右对齐,包装节点必须按内容 42vp 收窄。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button() {
Image(this.icon)
.width(18)
.height(18)
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
.width(FLOAT_NODE_SIZE)
.height(FLOAT_NODE_SIZE)
.type(ButtonType.Circle)
.backgroundColor(this.palette().navBarBg)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 24,
color: this.palette().shadow,
offsetY: 8,
})
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 悬浮区的"一行":宽度自适应内容,内部组件从右向左排列(右边缘与导航栏平齐)。
* 放在 NavFloatOverlay 内部使用;声明顺序越靠前越贴近导航栏,后声明的行叠在其上方。
*/
@Component
export struct NavFloatRow {
@BuilderParam content: () => void;
build() {
Row({ space: 8 }) {
this.content()
}
.alignItems(VerticalAlign.Center)
}
}
/**
* 底部悬浮容器:透明、无任何背景/边框(视觉上不包裹任何东西,组件彼此独立),
* 仅负责 右侧对齐(与导航栏同宽 88%)、自下而上分行堆叠 与 随滚动渐隐/滑出动画。
*
* 布局约定:直接子节点是"一行"(通常是自适应宽度的 Row
* 行按【从上到下】声明:先声明的行在上方,最后声明的行贴住导航栏,
* 即"一行放不下时向上面再开一行"(把新行写在前面)。
*
* 关键:这里必须用 Column 而不是 Flex —— Flex 在 ArkUI 中默认铺满父约束,
* 会形成一个覆盖整页的默认命中测试节点,吞掉页面滚动与点击;
* Column 高度自适应内容。再加 HitTestMode.Transparent
* 让每行右侧之外的空白区域触摸穿透到下层滚动区。
*/
@Component
export struct NavFloatOverlay {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
@Prop tab: number = 0;
@Prop alignEnd: boolean = true;
@BuilderParam content: () => void;
build() {
Column({ space: 8 }) {
this.content()
}
.width('88%')
.alignItems(this.alignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
.hitTestBehavior(HitTestMode.Transparent)
.margin({ bottom: 94 })
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
}
}

View File

@ -0,0 +1,389 @@
import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants';
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
/**
* 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。
*
* 抽出来的原因:配置项不应该全部堆在「设置 → 后端配置」一个平铺列表里。
* - core.* 各分类归到「核心」下一级;
* - plugin.<name>.* 属于插件本身,放进该插件的详情页 —— 就是这个组件的用处。
*
* 后端接口:
* - GET /settings?prefix=<prefix> → { settings: {k:v}, meta: {k:{...}} }
* - PUT /settings → { key, value }value 一律字符串)
*/
interface SettingMetaRaw {
key: string;
type: string;
displayName: string;
description: string;
category: string;
options: string[];
}
interface EditorEntry {
key: string;
displayName: string;
description: string;
type: string;
options: string[];
value: string;
dirty: boolean;
}
interface SaveBody {
key: string;
value: string;
}
@Component
export struct SettingsEditor {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** key 前缀,例如 'plugin.ai_image.' 或 'core.llm.' */
@Prop @Watch('onPrefixChanged') prefix: string = '';
/** 空列表时的提示语 */
@Prop emptyHint: string = '暂无可配置项';
@State private entries: EditorEntry[] = [];
@State private loading: boolean = false;
@State private err: string = '';
@State private toast: string = '';
@State private toastErr: boolean = false;
private original: Record<string, string> = {};
aboutToAppear(): void {
this.load();
}
/** 宽屏下同一个组件实例会被复用(切换插件只改 prefix必须重新拉取 */
private onPrefixChanged(): void {
this.entries = [];
this.load();
}
/**
* 查询用前缀。
*
* 后端 GET /settings?prefix= 对 plugin.* 走的是另一条分支:
* 它把 prefix 当作 "plugin." + 插件名 来切表,再自己拼 prefix + "." + key。
* 所以传 'plugin.browser.'(带尾点)会被解析成插件名 "browser.",查不到表,
* settings 返回空 —— 这就是插件详情页配置卡片空白的原因。
* 查询必须去掉尾点,返回的 key 仍是 'plugin.browser.timeout' 这种全名,
* 所以本地过滤/短标签依旧用带尾点的 this.prefix。
*/
private queryPrefix(): string {
const p: string = this.prefix;
return p.endsWith('.') ? p.substring(0, p.length - 1) : p;
}
private async load(): Promise<void> {
if (!apiClient.hasConnection()) {
this.err = noConnectionMessage();
return;
}
if (this.prefix.length === 0) {
this.entries = [];
return;
}
this.loading = true;
this.err = '';
try {
const resp = await apiClient.getWithTimeout('/settings?prefix=' + this.queryPrefix(), 15000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const metaStore: Record<string, SettingMetaRaw> = {};
const rawMeta: Object | undefined = obj['meta'];
if (rawMeta !== undefined && rawMeta !== null) {
const mObj: Record<string, Object> = rawMeta as Record<string, Object>;
for (const mk of Object.keys(mObj)) {
const item: Record<string, Object> = mObj[mk] as Record<string, Object>;
const opts: string[] = [];
const optsRaw: Object | undefined = item['options'];
if (optsRaw !== undefined && optsRaw !== null) {
const oa: Object[] = optsRaw as Object[];
for (let i = 0; i < oa.length; i++) {
const s: string = oa[i] as string ?? '';
if (s.length > 0) {
opts.push(s);
}
}
}
const entryMeta: SettingMetaRaw = {
key: item['key'] as string ?? mk,
type: item['type'] as string ?? 'string',
displayName: item['display_name'] as string ?? '',
description: item['description'] as string ?? '',
category: item['category'] as string ?? '',
options: opts,
};
// 两个索引都建:
// - mk 是 map 的键,插件分支里后端会把它拼成 'plugin.<name>.' + 完整 key
// 于是变成 'plugin.ai_image.plugin.ai_image.api_key' 这种双前缀,对不上;
// - entryMeta.key 是定义自带的真实全名('plugin.ai_image.api_key'),才是能匹配的那个。
// 只用 mk 索引就会让插件配置全部退化成"无显示名、类型按 string"。
metaStore[mk] = entryMeta;
if (entryMeta.key.length > 0) {
metaStore[entryMeta.key] = entryMeta;
}
}
}
const list: EditorEntry[] = [];
this.original = {};
const rawVals: Object | undefined = obj['settings'];
if (rawVals !== undefined && rawVals !== null) {
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
const keys: string[] = Object.keys(vObj).filter((k: string): boolean => {
return k.startsWith(this.prefix);
});
keys.sort((a: string, b: string): number => a.localeCompare(b));
for (let i = 0; i < keys.length; i++) {
const k: string = keys[i];
const raw: Object = vObj[k];
let sv: string;
if (typeof raw === 'string') {
sv = raw as string;
} else if (typeof raw === 'boolean' || typeof raw === 'number') {
sv = String(raw);
} else {
sv = JSON.stringify(raw);
}
this.original[k] = sv;
const meta: SettingMetaRaw | undefined = metaStore[k];
list.push({
key: k,
displayName: meta !== undefined && meta.displayName.length > 0
? meta.displayName : shortLabel(k, this.prefix),
description: meta !== undefined ? meta.description : '',
type: meta !== undefined ? meta.type : 'string',
options: meta !== undefined ? meta.options : [],
value: sv,
dirty: false,
});
}
}
this.entries = list;
} catch (e) {
this.err = userMessage('settings.load', e);
}
this.loading = false;
}
private markEntry(key: string, value: string, dirty: boolean): void {
const next: EditorEntry[] = [];
for (let i = 0; i < this.entries.length; i++) {
const e: EditorEntry = this.entries[i];
if (e.key === key) {
next.push({
key: e.key,
displayName: e.displayName,
description: e.description,
type: e.type,
options: e.options,
value: value,
dirty: dirty,
});
} else {
next.push(e);
}
}
this.entries = next;
}
private onEdit(key: string, value: string): void {
const orig: string = this.original[key] ?? '';
this.markEntry(key, value, value !== orig);
}
private latestValue(key: string): string {
for (let i = 0; i < this.entries.length; i++) {
if (this.entries[i].key === key) {
return this.entries[i].value;
}
}
return '';
}
private async save(entry: EditorEntry, newValue: string): Promise<void> {
let payload: string = newValue.trim();
if (entry.type === 'bool') {
payload = newValue === 'true' ? 'true' : 'false';
}
try {
const body: SaveBody = { key: entry.key, value: payload };
await apiClient.request('/settings', 'PUT', JSON.stringify(body), 10000);
this.original[entry.key] = payload;
this.markEntry(entry.key, payload, false);
this.showToast('已保存', false);
} catch (e) {
this.showToast(userMessage('settings.save', e), true);
}
}
private showToast(msg: string, isErr: boolean): void {
this.toast = msg;
this.toastErr = isErr;
setTimeout(() => {
this.toast = '';
}, 2000);
}
build() {
Column() {
if (this.loading && this.entries.length === 0) {
Row() {
LoadingProgress()
.width(22)
.height(22)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 14, bottom: 14 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.err.length > 0) {
Text(this.err)
.fontSize(12)
.fontColor(COLOR_ERROR)
.width('100%')
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
Text(this.emptyHint)
.fontSize(12)
.fontColor(this.palette().textMuted)
.width('100%')
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.entries, (entry: EditorEntry) => {
this.EntryRow(entry)
}, (entry: EditorEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
if (this.toast.length > 0) {
Text(this.toast)
.fontSize(11)
.fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent)
.margin({ top: 4 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
EntryRow(entry: EditorEntry) {
Column() {
Text(entry.displayName)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(entry.key)
.fontSize(10)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (entry.description.length > 0) {
Text(entry.description)
.fontSize(11)
.fontColor(this.palette().textSecondary)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
}
if (entry.type === 'bool') {
Row() {
Text(entry.value === 'true' ? '已开启' : '已关闭')
.fontSize(12)
.fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Blank()
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.save(entry, on ? 'true' : 'false');
})
}
.width('100%')
.margin({ top: 6 })
} else if (entry.type === 'select' && entry.options.length > 0) {
Flex({
direction: FlexDirection.Row,
justifyContent: FlexAlign.Start,
alignItems: ItemAlign.Center,
wrap: FlexWrap.Wrap,
}) {
ForEach(entry.options, (opt: string) => {
Button(opt)
.height(26)
.fontSize(11)
.margin({ right: 6, bottom: 6 })
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.onClick(() => {
this.save(entry, opt);
})
}, (opt: string) => opt)
}
.width('100%')
.margin({ top: 6 })
} else {
Row() {
TextInput({ text: entry.value })
.height(36)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.type(entry.type === 'password' ? InputType.Password : InputType.Normal)
.layoutWeight(1)
.onChange((v: string) => {
this.onEdit(entry.key, v);
})
Button('保存')
.height(30)
.fontSize(12)
.backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.margin({ left: 8 })
.onClick(() => {
this.save(entry, this.latestValue(entry.key));
})
}
.width('100%')
.margin({ top: 6 })
}
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
.alignItems(HorizontalAlign.Start)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/** 去掉前缀后的短标签plugin.ai_image.model → model */
function shortLabel(key: string, prefix: string): string {
const s: string = key.startsWith(prefix) ? key.substring(prefix.length) : key;
return s.length > 0 ? s : key;
}

View File

@ -0,0 +1,604 @@
/**
* Static Markdown → ArkUI renderer for COMPLETE (non-streaming) chat messages.
*
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
* Uses Span children inside Text for inline bold/italic/code/link formatting.
*
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
*/
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants';
// ── Types ──────────────────────────────────────────────────────────────────────
export interface MdBlock {
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
level?: number;
items?: string[];
text?: string;
lang?: string;
codeLines?: string[];
headers?: string[];
rows?: string[][];
}
export interface MdSpan {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
link?: boolean;
linkUrl?: string;
}
// ── Inline parser ──────────────────────────────────────────────────────────────
export function parseInline(text: string): MdSpan[] {
const spans: MdSpan[] = [];
let i: number = 0;
while (i < text.length) {
// Inline code (backtick)
if (text[i] === '`') {
const end: number = text.indexOf('`', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), code: true });
i = end + 1;
continue;
}
}
// Bold: **text**
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
const end: number = text.indexOf('**', i + 2);
if (end > i + 1) {
spans.push({ text: text.substring(i + 2, end), bold: true });
i = end + 2;
continue;
}
}
// Italic: *text* (single asterisk)
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
const end: number = text.indexOf('*', i + 1);
if (end > i) {
spans.push({ text: text.substring(i + 1, end), italic: true });
i = end + 1;
continue;
}
}
// Link: [text](url)
if (text[i] === '[') {
const cb: number = text.indexOf(']', i + 1);
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
const cp: number = text.indexOf(')', cb + 2);
if (cp > cb + 1) {
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
i = cp + 1;
continue;
}
}
}
// Plain run
let j: number = i + 1;
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
j++;
}
spans.push({ text: text.substring(i, j) });
i = j;
}
return spans;
}
// ── Block parser helpers ───────────────────────────────────────────────────────
function isHr(line: string): boolean {
if (line.length < 3) {
return false;
}
const ch: string = line[0];
if (ch !== '-' && ch !== '*' && ch !== '_') {
return false;
}
for (let k = 0; k < line.length; k++) {
if (line[k] !== ch) {
return false;
}
}
return true;
}
function isOlStart(line: string): boolean {
if (line.length < 3) {
return false;
}
let k: number = 0;
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
k++;
}
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
}
function isTableSep(line: string): boolean {
if (!line.includes('-')) {
return false;
}
for (let k = 0; k < line.length; k++) {
const c: string = line[k];
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
return false;
}
}
return true;
}
// ── Block parser ───────────────────────────────────────────────────────────────
export function parseBlocks(content: string): MdBlock[] {
if (content.length === 0) {
return [];
}
const lines: string[] = content.split('\n');
const blocks: MdBlock[] = [];
let i: number = 0;
while (i < lines.length) {
const line: string = lines[i];
// Empty line
if (line.trim().length === 0) {
i++;
continue;
}
// Code fence
if (line.startsWith('```')) {
const langEnd: number = line.indexOf('`', 3);
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) {
i++;
}
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
continue;
}
// Heading
if (line.startsWith('#')) {
let level: number = 0;
while (level < line.length && line[level] === '#') {
level++;
}
if (level <= 6 && level < line.length && line[level] === ' ') {
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
i++;
continue;
}
}
// Horizontal rule
if (isHr(line.trim())) {
blocks.push({ type: 'hr' });
i++;
continue;
}
// Unordered list
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
const items: string[] = [];
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
items.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'list', items: items });
continue;
}
// Ordered list
if (isOlStart(line)) {
const items: string[] = [];
while (i < lines.length && isOlStart(lines[i])) {
const dotIdx: number = lines[i].indexOf('. ');
items.push(lines[i].substring(dotIdx + 2));
i++;
}
blocks.push({ type: 'ol', items: items });
continue;
}
// Blockquote
if (line.startsWith('> ')) {
const qLines: string[] = [];
while (i < lines.length && lines[i].startsWith('> ')) {
qLines.push(lines[i].substring(2));
i++;
}
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
continue;
}
// Table
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
const tLines: string[] = [];
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
tLines.push(lines[i]);
i++;
}
if (tLines.length >= 2) {
const parseRow = (row: string): string[] => {
const cells: string[] = [];
const parts: string[] = row.split('|');
for (let p = 0; p < parts.length; p++) {
const c: string = parts[p].trim();
if (c.length > 0) {
cells.push(c);
}
}
return cells;
};
const headers: string[] = parseRow(tLines[0]);
const rows: string[][] = [];
for (let k = 1; k < tLines.length; k++) {
if (!isTableSep(tLines[k].trim())) {
rows.push(parseRow(tLines[k]));
}
}
if (headers.length > 0) {
blocks.push({ type: 'table', headers: headers, rows: rows });
}
}
continue;
}
// Paragraph: collect consecutive non-special lines
{
const paraLines: string[] = [];
while (i < lines.length) {
const ln: string = lines[i];
if (ln.trim().length === 0) {
break;
}
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
break;
}
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
break;
}
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
break;
}
paraLines.push(ln);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: 'para', text: paraLines.join('\n') });
}
}
}
return blocks;
}
// ── Component ──────────────────────────────────────────────────────────────────
@Component
export struct StaticMarkdownView {
@Prop content: string = '';
@Prop isDark: boolean = true;
private blocks: MdBlock[] = [];
aboutToAppear(): void {
this.blocks = parseBlocks(this.content);
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private headingSize(level: number): number {
if (level === 1) {
return 22;
}
if (level === 2) {
return 19;
}
if (level === 3) {
return 17;
}
if (level === 4) {
return 15.5;
}
return 14;
}
// ── Block builders ─────────────────────────────────────────────────────────
@Builder
ParaBlock(text: string) {
Column({ space: 1 }) {
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
// Render inline spans inside this line
Text() {
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(15)
.fontColor(sp.link === true ? this.palette().accent : sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
.borderRadius(3)
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
.decoration(sp.link === true ? { type: TextDecorationType.Underline } : undefined)
}, (sp: MdSpan, si: number) => idx.toString() + '_' + si.toString())
}
.fontSize(15)
.lineHeight(24)
.fontColor(this.palette().msgBubbleText)
.width('100%')
.wordBreak(WordBreak.BREAK_ALL)
.textAlign(TextAlign.Start)
}, (ln: string, idx: number) => 'p' + idx.toString())
}
.width('100%')
.margin({ top: 2, bottom: 4 })
}
@Builder
HeadingBlock(block: MdBlock) {
Text(block.text ?? '')
.fontSize(this.headingSize(block.level ?? 1))
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.lineHeight(this.headingSize(block.level ?? 1) + 8)
.width('100%')
.margin({ top: 6, bottom: 4 })
}
@Builder
CodeBlock(block: MdBlock) {
Column() {
Row() {
Text(block.lang !== undefined && block.lang.length > 0 ? block.lang : 'code')
.fontSize(10)
.fontColor(this.palette().textMuted)
.fontFamily('monospace')
Blank()
}
.width('100%')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.palette().bgHover)
// Code body
Column() {
ForEach(block.codeLines ?? [], (ln: string, idx: number) => {
Text(ln.length > 0 ? ln : ' ')
.fontSize(12.5)
.lineHeight(19)
.fontFamily('monospace')
.fontColor(this.palette().preText)
.width('100%')
.textAlign(TextAlign.Start)
.wordBreak(WordBreak.BREAK_ALL)
}, (ln: string, idx: number) => 'c' + idx.toString())
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
}
.width('100%')
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().preBg)
.border({ width: 1, color: this.palette().kvBorder })
.clip(true)
.margin({ top: 4, bottom: 6 })
}
@Builder
ListBlock(block: MdBlock) {
Column({ space: 2 }) {
ForEach(block.items ?? [], (item: string, idx: number) => {
Row({ space: 6 }) {
Text('•')
.fontSize(15)
.fontColor(this.palette().accent)
.fontWeight(FontWeight.Bold)
.margin({ top: 1 })
Text() {
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(15)
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
.borderRadius(3)
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
}, (sp: MdSpan, si: number) => 'li' + idx.toString() + '_' + si.toString())
}
.fontSize(15)
.lineHeight(23)
.fontColor(this.palette().msgBubbleText)
.layoutWeight(1)
.wordBreak(WordBreak.BREAK_ALL)
.width('100%')
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (item: string, idx: number) => idx.toString())
}
.width('100%')
.margin({ top: 2, bottom: 4 })
}
@Builder
OlBlock(block: MdBlock) {
Column({ space: 2 }) {
ForEach(block.items ?? [], (item: string, idx: number) => {
Row({ space: 6 }) {
Text((idx + 1).toString() + '.')
.fontSize(15)
.fontColor(this.palette().accent)
.fontWeight(FontWeight.Medium)
.margin({ top: 1 })
Text() {
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(15)
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
.borderRadius(3)
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
}, (sp: MdSpan, si: number) => 'oli' + idx.toString() + '_' + si.toString())
}
.fontSize(15)
.lineHeight(23)
.fontColor(this.palette().msgBubbleText)
.layoutWeight(1)
.wordBreak(WordBreak.BREAK_ALL)
.width('100%')
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (item: string, idx: number) => idx.toString())
}
.width('100%')
.margin({ top: 2, bottom: 4 })
}
@Builder
BlockquoteBlock(text: string) {
Column() {
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
Text() {
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(14)
.fontColor(this.palette().textTertiary)
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
}, (sp: MdSpan, si: number) => 'bq' + idx.toString() + '_' + si.toString())
}
.fontSize(14)
.lineHeight(22)
.width('100%')
.wordBreak(WordBreak.BREAK_ALL)
}, (ln: string, idx: number) => 'bq' + idx.toString())
}
.width('100%')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.margin({ top: 3, bottom: 5 })
.borderRadius({ topLeft: 0, topRight: 6, bottomRight: 6, bottomLeft: 0 })
.backgroundColor(this.palette().bgHover)
.border({
width: { left: 3, top: 0, right: 0, bottom: 0 },
color: { left: this.palette().accent, top: Color.Transparent, right: Color.Transparent, bottom: Color.Transparent },
})
}
@Builder
HrBlock() {
Row()
.width('100%')
.height(1)
.backgroundColor(this.palette().kvBorder)
.margin({ top: 6, bottom: 6 })
}
@Builder
TableBlock(block: MdBlock) {
Column() {
// Header
Row() {
ForEach(block.headers ?? [], (h: string, hi: number) => {
Text() {
ForEach(parseInline(h), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(12.5)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
}, (sp: MdSpan, si: number) => 'th' + hi.toString() + '_' + si.toString())
}
.fontSize(12.5)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.padding({ left: 6, right: 6, top: 5, bottom: 5 })
}, (h: string, hi: number) => 'th' + hi.toString())
}
.width('100%')
.backgroundColor(this.palette().bgHover)
// Body
ForEach(block.rows ?? [], (row: string[], ri: number) => {
Row() {
ForEach(row, (cell: string, ci: number) => {
Text() {
ForEach(parseInline(cell), (sp: MdSpan, si: number) => {
Span(sp.text)
.fontSize(12.5)
.fontColor(this.palette().msgBubbleText)
.fontFamily(sp.code === true ? 'monospace' : '-')
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
}, (sp: MdSpan, si: number) => 'td' + ri.toString() + '_' + ci.toString() + '_' + si.toString())
}
.fontSize(12.5)
.fontColor(this.palette().msgBubbleText)
.layoutWeight(1)
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
.wordBreak(WordBreak.BREAK_ALL)
.width('100%')
}, (cell: string, ci: number) => 'td' + ri.toString() + '_' + ci.toString())
}
.width('100%')
}, (row: string[], ri: number) => 'tr' + ri.toString())
}
.width('100%')
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().kvBorder })
.clip(true)
.margin({ top: 4, bottom: 6 })
}
// ── Helpers ────────────────────────────────────────────────────────────────
private splitNewlines(text: string): string[] {
if (text.length === 0) {
return [];
}
return text.split('\n');
}
// ── Build ──────────────────────────────────────────────────────────────────
build() {
Column() {
ForEach(this.blocks, (b: MdBlock, idx: number) => {
if (b.type === 'code') {
this.CodeBlock(b)
} else if (b.type === 'heading') {
this.HeadingBlock(b)
} else if (b.type === 'list') {
this.ListBlock(b)
} else if (b.type === 'ol') {
this.OlBlock(b)
} else if (b.type === 'blockquote') {
this.BlockquoteBlock(b.text ?? '')
} else if (b.type === 'hr') {
this.HrBlock()
} else if (b.type === 'table') {
this.TableBlock(b)
} else {
this.ParaBlock(b.text ?? '')
}
}, (b: MdBlock, idx: number) => idx.toString() + b.type + ((b.text ?? '').substring(0, 12)))
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}

View File

@ -0,0 +1,323 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import {
statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
} from '../common/StatusStore';
/**
* 运行状态摘要卡嵌在设置一级页顶部原「状态」Tab 已删除)。
*
* 只放一眼可读的东西:环形仪表 + 运行时长 + 三项 KPI + 版本,
* 明细(内核 / 模型 / 记忆 / 运行时 / 工具清单)走二级页面。
* 点击整卡进入明细,所以自身不放任何按钮,避免嵌套点击。
*/
@Component
export struct StatusSummaryCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_UP) private up: boolean = false;
@StorageProp(K_VERSION) private version: string = '-';
@StorageProp(K_STARTED) private startedAt: string = '';
@StorageProp(K_AGENTS) private agents: number = 0;
@StorageProp(K_PLUGINS) private plugins: number = 0;
@StorageProp(K_TOOLS) private tools: number = 0;
@StorageProp(K_ERR) private err: string = '';
@StorageProp(K_LOADING) private loading: boolean = false;
/** 秒级刷新的运行时长文本:环心不能用 startedAt 直接算,否则不会自动跳秒 */
@State private uptime: string = '-';
private timerId: number = -1;
onTap?: () => void;
aboutToAppear(): void {
this.uptime = compactDuration(this.startedAt);
this.timerId = setInterval(() => {
this.uptime = compactDuration(this.startedAt);
}, 1000);
}
aboutToDisappear(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
build() {
// 整卡按压反馈统一收进 MotionBase父组件这里是进明细页的唯一入口
// 父组件负责 scale 回弹;导航语义仍由本卡自己的 onClick点击释放负责
// 避免"按下即跳转"的误触手感。
MotionBase({ pressEnabled: true }) {
Column() {
if (this.err.length > 0) {
// 连接失败:只显示一句人话,技术细节在 hilog 里
Row() {
Image($r('app.media.ic_error'))
.width(16)
.height(16)
.fillColor(COLOR_ERROR)
.draggable(false)
Text(this.err)
.fontSize(13)
.fontColor(COLOR_ERROR)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} else if (this.loading && !this.up) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 20, bottom: 20 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else {
// 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。
// 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量,
// 用百分比环表示只会让人以为有什么在加载;架构是单 agent 单 session
// "Agent 数" 恒为 1也没有信息量一并去掉。
Row({ space: 12 }) {
Stack({ alignContent: Alignment.Center }) {
Image(this.up ? $r('app.media.ic_pulse') : $r('app.media.ic_offline'))
.width(24)
.height(24)
.fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false)
}
.width(48)
.height(48)
.borderRadius(RADIUS_SM)
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Column({ space: 3 }) {
Text(this.up ? '运行中' : '离线')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务')
.fontSize(12)
.fontColor(this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
Row({ space: 10 }) {
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
}
.width('100%')
.margin({ top: 14 })
// 版本 + 「查看明细」提示,与 KPI 之间用分割线断开
Row() {
Text('版本')
.fontSize(12)
.fontColor(this.palette().textMuted)
Text(this.version)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 10 })
Text('明细')
.fontSize(12)
.fontColor(this.palette().accent)
Image($r('app.media.ic_chevron_right'))
.width(14)
.height(14)
.fillColor(this.palette().accent)
.draggable(false)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 14 })
.padding({ top: 11 })
.border({ width: { top: 1 }, color: this.palette().kvBorder })
}
}
.width('100%')
.padding(18)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 18 })
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
@Builder
kpiTile(icon: Resource, label: string, value: number, color: string) {
Row({ space: 8 }) {
Image(icon)
.width(16)
.height(16)
.fillColor(color)
.draggable(false)
Column({ space: 1 }) {
Text(value > 0 ? value.toString() : '-')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
Text(label)
.fontSize(11)
.fontColor(this.palette().textMuted)
}
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.alignItems(VerticalAlign.Center)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 运行状态明细:设置页「运行状态」二级页面的内容。
*
* 服务负载三条容量条 + 分组明细卡(系统概览 / 内核 / 模型 / 记忆 / 运行时 / 工具)。
* 分组数组不放 AppStorage数组同步语义不可靠改为订阅版本号 K_REV
* 版本变化时从 statusStore 重取一次快照。
*/
@Component
export struct StatusDetailContent {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp(K_UP) private up: boolean = false;
@StorageProp(K_AGENTS) private agents: number = 0;
@StorageProp(K_PLUGINS) private plugins: number = 0;
@StorageProp(K_TOOLS) private tools: number = 0;
@StorageProp(K_ERR) private err: string = '';
@StorageProp(K_LOADING) private loading: boolean = false;
@StorageProp(K_REV) @Watch('onRevChanged') private rev: number = 0;
@State groups: StatGroup[] = [];
aboutToAppear(): void {
this.groups = statusStore.getGroups();
}
private onRevChanged(): void {
this.groups = statusStore.getGroups();
}
build() {
Column() {
if (this.err.length > 0) {
Row() {
Image($r('app.media.ic_error'))
.width(16)
.height(16)
.fillColor(COLOR_ERROR)
.draggable(false)
Text(this.err)
.fontSize(13)
.fontColor(COLOR_ERROR)
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.padding(16)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(VerticalAlign.Top)
.margin({ bottom: 14 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
if (this.loading && this.groups.length === 0) {
Row() {
LoadingProgress()
.width(28)
.height(28)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 40, bottom: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。
// 后端并不存在"插件上限/工具上限"这种容量概念,分母是两者取大值,
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
ForEach(this.groups, (g: StatGroup) => {
Column() {
Text(g.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
.margin({ bottom: 6 })
ForEach(g.fields, (f: StatField, idx: number) => {
Row() {
Text(f.label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(f.value.length > 0 ? f.value : '-')
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 200 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.border({
width: { bottom: idx < g.fields.length - 1 ? 1 : 0 },
color: this.palette().kvBorder,
})
}, (f: StatField) => g.title + f.label)
}
.width('100%')
.padding(18)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 14 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (g: StatGroup) => g.title)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,360 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG,
ANIM_NORMAL } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { handleNavOnScroll } from '../common/NavBarController';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar';
import { GradientBackground } from './GradientBackground';
/**
* 二级页面开关标志AppStorage
* Index 读取它来锁住 Swiper 左右滑动——否则在二级页面里横滑会误切换主 Tab。
* 由 Navigation.onNavBarStateChange 驱动,与真实导航栈严格同步。
*/
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
export function markSubPageOpen(open: boolean): void {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
}
/** pushPathByName 的参数载体ArkTS 不允许把 string 断言成 object */
export interface SubPageParam {
id: string;
}
export function subPageParam(id: string): SubPageParam {
const p: SubPageParam = { id: id };
return p;
}
/**
* 二级页面内容层:放在 NavDestination 里使用。
*
* 为什么二级页面走 Navigation / NavDestination 而不是自己用 @State 切换:
* - NavDestination 由系统维护导航栈,侧滑返回、三键返回、
* 以及无障碍返回都会自动 pop不需要自己拦 onBackPress
* - 转场动画由系统提供,与其他系统应用一致。
*
* 视觉约定与一级页面完全一致:
* - 自带同款渐变背景NavDestination 会整屏盖住一级内容,
* 不铺背景会透出下层列表形成重影);
* - 顶栏只有标题,上边缘由不透明渐变到透明;
* - 返回按钮不放顶栏,而是作为独立悬浮组件放在底部悬浮区(拇指可达)。
*
* 注意:@BuilderParam 只能有一个trailing lambda 限制),
* 所以悬浮区的刷新按钮用 showRefresh + onRefresh 两个普通属性表达,
* 而不是第二个 @BuilderParam。
*/
@Component
export struct SubPageLayer {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 宽屏分栏时本层就是右侧栏,一级界面一直在左边可见,返回键无意义 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@Prop title: string = '';
/** 所属主 Tab 序号,供底部悬浮区的显隐动画使用 */
@Prop tab: number = 0;
/** 悬浮区是否附带刷新按钮 */
@Prop showRefresh: boolean = false;
onBack?: () => void;
onRefresh?: () => void;
@BuilderParam content: () => void;
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 二级页面自带背景Stack 模式下整屏覆盖,必须自己铺底;
// 宽屏分栏时右栏也需要同款背景,与左栏视觉连续。
GradientBackground()
Scroll() {
Column() {
this.content()
}
.width('100%')
// 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间
.padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
PageTopBar({ title: this.title })
// 底部悬浮区:返回键(+ 可选刷新键)。
// 宽屏分栏时左栏常驻可见,一级页面自己的悬浮刷新键也还在屏幕上,
// 这里再挂一个就成了"两个重加载按钮"——所以宽屏下本层不出刷新键,
// 由一级页面那一个统一负责(它的回调会连带刷新右栏内容)。
NavFloatOverlay({ tab: this.tab }) {
NavFloatRow() {
if (!this.isWide) {
// 自定义组件不能直接挂 .transition()(会生成 __Common__ 包装节点),
// 所以各自套一层无 padding 的 Column由它承载出入场动画
// 让悬浮键与 400ms 平滑滑出的悬浮层不再"一帧硬切"。
Column() {
SubPageBackButton({
onTap: () => {
const cb = this.onBack;
if (cb !== undefined) {
cb();
}
},
})
}
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
if (this.showRefresh) {
Column() {
FloatIconButton({
icon: $r('app.media.ic_refresh'),
onTap: () => {
const cb = this.onRefresh;
if (cb !== undefined) {
cb();
}
},
})
}
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
}
}
}
.width('100%')
.height('100%')
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 一级页面里的入口行:图标 + 标题 + 副标题 + 右侧摘要值 + 右尖角。
* 点击进入二级页面。
*/
@Component
export struct NavRow {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource | undefined = undefined;
@Prop title: string = '';
@Prop subtitle: string = '';
@Prop value: string = '';
@Prop tint: string = '';
@Prop showDivider: boolean = true;
/** 宽屏分栏时右栏正显示本行对应的二级页面 —— 高亮当前项 */
@Prop selected: boolean = false;
onTap?: () => void;
build() {
// 通用按压动画收进 MotionBase父组件scale 回弹统一。
// 选中态底色仍在这里管:它是 NavRow 特有外观,不归父组件。
MotionBase({ pressEnabled: true }) {
Row() {
if (this.icon !== undefined) {
Row() {
Image(this.icon)
.width(17)
.height(17)
.fillColor(this.iconColor())
.draggable(false)
}
.width(32)
.height(32)
.borderRadius(10)
.backgroundColor(this.iconBg())
.justifyContent(FlexAlign.Center)
.margin({ right: 12 })
}
Column({ space: 2 }) {
Text(this.title)
.fontSize(15)
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary)
// 父行 MotionBase 的 .animation() 到不了子节点,选中态字色自己缓动
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.subtitle.length > 0) {
Text(this.subtitle)
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (this.value.length > 0) {
Text(this.value)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 130 })
.margin({ right: 8 })
}
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
// 选中态底色:写在 .border 之前,避免分割线宽度被纳入动画。
// MotionBase 的 .animation() 只作用在包装 Column 上,到不了这里,
// 选中高亮的底色迁移要本行自己声明。
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.border({
width: { bottom: this.showDivider ? 1 : 0 },
color: this.palette().kvBorder,
})
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
private iconColor(): string {
return this.tint.length > 0 ? this.tint : this.palette().accent;
}
private iconBg(): string {
return this.tint.length > 0 ? this.palette().bgHover : this.palette().accentBg;
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 分组卡:一个标题 + 一组 NavRow圆角裁剪让行分割线不越出卡片。
*/
@Component
export struct NavGroup {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop caption: string = '';
@BuilderParam content: () => void;
build() {
Column() {
if (this.caption.length > 0) {
Text(this.caption)
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ left: 4, bottom: 8 })
}
Column() {
this.content()
}
.width('100%')
.borderRadius(RADIUS_LG)
.clip(true)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 18 })
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 通用内容卡(二级页面里承载表单/明细的容器)。
*/
@Component
export struct PlainCard {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop caption: string = '';
@BuilderParam content: () => void;
build() {
Column() {
if (this.caption.length > 0) {
Text(this.caption)
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ left: 4, bottom: 8 })
}
Column() {
this.content()
}
.width('100%')
.padding(16)
.borderRadius(RADIUS_LG)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 18 })
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 二级页面的返回按钮:与悬浮图标按钮同款玻璃,放在底部悬浮区最左侧。
* 系统侧滑返回同样可用,这个按钮只是给单手拇指多一条路径。
*/
@Component
export struct SubPageBackButton {
@StorageProp('themeIsDark') private isDark: boolean = true;
onTap?: () => void;
build() {
// 玻璃底自带的按压变暗在 45% 透明度上几乎看不见,缩放反馈由 MotionBase 统一提供。
// fillWidth: false —— 悬浮区按内容宽度排列,不能撑满整行。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button() {
Row({ space: 4 }) {
Image($r('app.media.ic_arrow_back'))
.width(17)
.height(17)
.fillColor(this.palette().textSecondary)
.draggable(false)
}
}
.width(42)
.height(42)
.type(ButtonType.Circle)
.backgroundColor(this.palette().navBarBg)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -0,0 +1,138 @@
import { UIAbility, AbilityConstant, Configuration, ConfigurationConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
function storedThemeMode(): string {
try {
const s = connStore.getSettings();
if (typeof s.theme === 'string' && s.theme.length > 0) {
return s.theme;
}
} catch (e) {
// store not ready yet
}
return 'system';
}
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
console.info('[HomeAgent] ability onCreate');
const sysDark: boolean =
this.context.config?.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
// Seed the OS color state first, then resolve the persisted mode.
// connStore may not be initialized here (mode falls back to 'system');
// onWindowStageCreate re-applies the real stored mode after init.
seedSystemIsDark(sysDark);
seedTheme(resolveIsDark(storedThemeMode(), sysDark));
}
onDestroy(): void {
console.info('[HomeAgent] ability onDestroy');
}
/** System dark/light switch — re-resolve when mode is 'system'. */
onConfigurationUpdate(newConfig: Configuration): void {
try {
const sysDark: boolean = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
seedSystemIsDark(sysDark);
const mode: string = storedThemeMode();
if (mode === 'system') {
AppStorage.set('themeIsDark', resolveIsDark('system', sysDark));
this.applySystemBar();
}
} catch (e) {
// ignore
}
}
private applySystemBar(): void {
// 状态栏与窗口底色跟随主题,消除浅色模式下的暗色割裂
const dark: boolean = themeIsDark();
const bg: string = dark ? '#000000' : '#F1F3F5';
const fg: string = dark ? '#FFFFFF' : '#191919';
try {
window.getLastWindow(this.context).then((win: window.Window) => {
win.setWindowSystemBarProperties({
statusBarColor: bg,
statusBarContentColor: fg,
navigationBarColor: bg,
});
win.setWindowBackgroundColor(bg);
}).catch(() => {
// ignore
});
} catch (e) {
// ignore
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
const startUI = (): void => {
windowStage.loadContent('pages/Index', () => {
console.info('[HomeAgent] main page loaded');
});
};
// 沉浸式窗口 + 主题化系统栏,消除真机顶部/底部白边
windowStage.getMainWindow((err: BusinessError, win: window.Window) => {
if (err.code !== 0) {
connStore.init(this.context).then(async () => {
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
this.reapplyStoredTheme();
startUI();
}).catch(() => {
startUI();
});
return;
}
try {
// 全屏沉浸:内容延伸到状态栏和导航栏区域
win.setWindowLayoutFullScreen(true);
} catch (e) {
console.warn('[HomeAgent] setWindowLayoutFullScreen failed: ' + (e as Error).message);
}
connStore.init(this.context).then(async () => {
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
console.info('[HomeAgent] connStore initialized');
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
this.reapplyStoredTheme();
this.applySystemBar();
startUI();
}).catch((e: Error) => {
console.error('[HomeAgent] connStore init failed: ' + e.message);
startUI();
});
});
}
/** After connStore.init, re-resolve themeIsDark from the persisted mode. */
private reapplyStoredTheme(): void {
try {
applyThemeMode(storedThemeMode());
} catch (e) {
// ignore
}
}
onWindowStageDestroy(): void {
console.info('[HomeAgent] ability onWindowStageDestroy');
}
onForeground(): void {
console.info('[HomeAgent] ability onForeground');
}
onBackground(): void {
console.info('[HomeAgent] ability onBackground');
}
}

View File

@ -0,0 +1,208 @@
export interface ConnectionConfig {
id: string;
name: string;
url: string;
apiKey: string;
type: string; // 'webui' | 'cli'
}
export interface ToolCallInfo {
name: string;
args: string;
result?: string;
plugin?: string;
status?: string; // 'ok' | 'error' | 'denied' | 'running'
open?: boolean;
}
export interface ChatMessage {
id: number;
role: string; // 'user' | 'assistant'
content: string;
reasoningContent?: string;
reasoningOpen?: boolean;
isStreaming?: boolean;
isFinal?: boolean;
toolCalls?: ToolCallInfo[];
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
source?: string;
/** 图片/文件附件(后端 ChatMsg.attachment */
attachment?: ChatAttachment;
}
export interface HistoryMessage {
role: string;
content: string;
source?: string;
timestamp?: string;
attachment?: ChatAttachment;
}
export interface AgentStatus {
version?: string;
uptime_ms?: number;
agent?: string;
provider?: string;
memory_events?: number;
}
export interface DeviceInfo {
deviceId: string;
name: string;
kind: string;
online: boolean;
authorized: boolean;
caps: string[];
hostname?: string;
platform?: string;
}
export interface PluginInfo {
name: string;
nameZh?: string;
nameEn?: string;
version?: string;
description?: string;
author?: string;
deprecated?: boolean;
builtin: boolean;
loaded: boolean;
disabled: boolean;
tools?: string[];
}
/**
* One row in the merged plugin list:
* kernel.plugins installed(/plugins) disabled(/plugins/disabled)
* 与 WebGUI renderPlugins 的合并口径一致。
*/
export interface PluginRow {
name: string;
/** /kernel.plugins 里出现且 loaded=true 才算已加载 */
loaded: boolean;
/** 出现在 /plugins/disabled 列表里 */
disabled: boolean;
/** 已安装的外部插件(/plugins 返回) */
external: boolean;
version?: string;
description?: string;
tools?: string[];
}
/**
* GET /plugins/{name} 返回的插件清单明细。
* 字段与后端 pluginmgr 的 pluginInfo 一一对应;
* WebGUI 只把它 JSON.stringify 到 <pre> 里,这里改为结构化展示。
*/
export interface PluginDetail {
name: string;
version: string;
description: string;
author: string;
license: string;
homepage: string;
repository: string;
entry: string;
minVersion: string;
tags: string[];
deprecated: boolean;
files: string[];
}
export function emptyPluginDetail(): PluginDetail {
const d: PluginDetail = {
name: '',
version: '',
description: '',
author: '',
license: '',
homepage: '',
repository: '',
entry: '',
minVersion: '',
tags: [],
deprecated: false,
files: [],
};
return d;
}
/**
* 聊天消息附件。字段与后端 webui 的 Attachment 严格一致,只有四个:
* type / url / size / name —— 后端不提供 mime、尺寸、本地路径。
*/
export interface ChatAttachment {
/** 'image' | 'file' */
type: string;
/** /files/<name>、/uploads/<name> 或远程 http(s) 地址 */
url: string;
/** 字节数;远程 URL 为 0 */
size: number;
/** 展示用文件名 */
name: string;
}
export interface AppSettings {
lang: string; // 'zh' | 'en'
theme: string; // 'dark' | 'light' | 'system'
currentConnId: string;
/** 自定义背景图路径(应用沙箱内文件路径),空串表示未设置 */
bgImage: string;
/** 背景图不透明度 0..1 */
bgOpacity: number;
}
export function emptySettings(): AppSettings {
const s: AppSettings = {
lang: 'zh',
theme: 'dark',
currentConnId: '',
bgImage: '',
bgOpacity: 0.25,
};
return s;
}
/**
* 规范化用户填写的后端地址。
*
* 用户习惯直接敲域名homeagent.example.xyz或粘贴带路径的地址。
* 三件事必须在存库前做掉,否则请求会以各种方式失败:
*
* 1) 补协议,且默认 https。
* 很多反代把 http 整站 302 到别的主机(例如门户站 www.xxx
* 重定向后的主机没有 API客户端只会拿到 404 页面而不是 401/200
* 表现为"连接直接失败"且看不出原因。默认 https 可以绕过整类问题。
* 只有明确写了 http:// 的内网地址才走明文。
* 2) 去掉结尾斜杠,避免拼出 //api/v1。
* 3) 去掉用户误粘的 /api/v1 后缀,避免拼成 /api/v1/api/v1。
*/
export function normalizeBaseUrl(raw: string): string {
let u: string = raw.trim();
if (u.length === 0) {
return '';
}
const lower: string = u.toLowerCase();
if (!lower.startsWith('http://') && !lower.startsWith('https://')) {
u = 'https://' + u;
}
u = u.replace(/\/+$/, '');
u = u.replace(/\/api\/v1$/, '');
return u;
}
/**
* 新建连接用的空白模板。
* 这里不能写死任何地址或密钥:源码是公开的,硬编码等于把内网地址和访问凭据一起发布。
* 地址与 API Key 由用户在「设置 - 连接」里填写,未配置时各页面统一提示(见 UserError.MSG_NO_CONN
*/
export function defaultConnection(): ConnectionConfig {
const c: ConnectionConfig = {
id: '',
name: 'HomeAgent',
url: '',
apiKey: '',
type: 'webui',
};
return c;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,692 @@
import { deviceBridge } from '../common/DeviceBridge';
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { DeviceInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { common } from '@kit.AbilityKit';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
const LOCAL_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
];
/** 二级页面标识 */
const SUB_NONE: string = '';
const SUB_LOCAL: string = 'local';
const SUB_CAPS: string = 'caps';
const SUB_GATEWAY: string = 'gateway';
const SUB_LIST: string = 'list';
@Component
export struct DevicePage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */
@State activeSub: string = SUB_NONE;
@State bridgeConnected: boolean = false;
@State bridgeUrl: string = '';
@State bridgeToken: string = '';
@State deviceId: string = '';
@State authorized: boolean = false;
@State devices: DeviceInfo[] = [];
@State loadingDevices: boolean = false;
@State lastError: string = '';
@State toastMsg: string = '';
@State toastIsError: boolean = false;
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
private autoConnectTried: boolean = false;
aboutToAppear(): void {
this.deviceId = deviceBridge.getDeviceId();
if (this.deviceId.length === 0) {
this.deviceId = connStore.getDeviceId();
}
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
}
this.authorized = connStore.getDeviceAuth();
// Gateway URL derives from current connection
const cur = connStore.getCurrentConnection();
if (cur !== null) {
this.bridgeUrl = this.gatewayUrlOf(cur.url);
this.bridgeToken = cur.apiKey;
}
installCmdRouter();
try {
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
} catch (e) {
// ignore context errors
}
deviceBridge.setStateListener((open: boolean) => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.bridgeConnected = open;
});
if (open) {
this.lastError = '';
this.showToast('设备网关已连接', false);
this.refreshDevices();
}
});
this.refreshDevices();
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(2, this.navStack, () => {
this.activeSub = SUB_NONE;
});
}
aboutToDisappear(): void {
unregisterNavStack(2);
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
*
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
* 直接把 http:// 传进 connect() 会在 native 层报
* "protocol failed" + "ParseUrl failed"NETSTACK websocket_exec.cpp
* 表现为设备通道永远连不上。所以这里必须做协议替换。
*/
private gatewayUrlOf(base: string): string {
let trimmed: string = base.trim();
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
let scheme: string = 'ws://';
let rest: string = trimmed;
if (trimmed.startsWith('https://')) {
scheme = 'wss://';
rest = trimmed.substring('https://'.length);
} else if (trimmed.startsWith('http://')) {
scheme = 'ws://';
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
scheme = 'ws://';
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/**
* 打开二级页面。
*
* 窄屏Stack 模式push 一层,整屏覆盖,系统侧滑返回可退。
* 宽屏Split 模式):左栏一级列表常驻,右栏只应有一页,
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
*/
private openSub(id: string): void {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = id;
});
if (this.isWide && this.navStack.size() > 0) {
this.navStack.replacePathByName(id, subPageParam(id), false);
} else {
this.navStack.pushPathByName(id, subPageParam(id), true);
}
}
/** 二级页面返回键宽屏下左栏常驻可见SubPageLayer 已隐藏返回键 */
private closeSub(): void {
this.navStack.pop();
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = SUB_NONE;
});
}
/** Toggle local authorization flag; hello 同步到网关。 */
private toggleAuthorized(on: boolean): void {
this.authorized = on;
try {
connStore.saveDeviceAuth(on);
} catch (e) {
// ignore persist failure
}
deviceBridge.updateAuthorized(on);
if (!this.bridgeConnected) {
this.connectBridge();
}
this.showToast(on ? '已授权agent 可下发能力命令' : '已取消授权', false);
}
// ===================== gateway connection =====================
private async connectBridge(): Promise<void> {
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
this.lastError = noConnectionMessage();
return;
}
this.lastError = '';
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
}
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
const name: string = 'HomeAgent OHOS';
await deviceBridge.connect(
this.bridgeUrl, this.bridgeToken, this.deviceId,
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
}
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
private maybeAutoConnect(): void {
if (this.autoConnectTried || this.bridgeConnected) {
return;
}
this.autoConnectTried = true;
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
this.connectBridge();
}
}
private showToast(msg: string, isError: boolean): void {
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = '';
});
}, 2500);
}
/** 拉取网关在线设备列表(经 webui 反代)。 */
private async refreshDevices(): Promise<void> {
this.loadingDevices = true;
try {
// apiClient 已自动前置 /api/v1这里只写其后的部分
// 否则会拼成 /api/v1/api/v1/device/online 并 404。
const resp = await apiClient.get('/device/online');
if (resp.status >= 200 && resp.status < 300) {
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const devs: Object = parsed['devices'];
const list: DeviceInfo[] = [];
if (devs !== undefined && devs !== null) {
const arr: Object[] = devs as Object[];
for (let i = 0; i < arr.length; i++) {
const d: Record<string, Object> = arr[i] as Record<string, Object>;
const capsArr: Object = d['caps'];
const caps: string[] = [];
if (capsArr !== undefined && capsArr !== null) {
const cArr: Object[] = capsArr as Object[];
for (let j = 0; j < cArr.length; j++) {
caps.push(cArr[j] as string);
}
}
const info: DeviceInfo = {
deviceId: d['device_id'] as string ?? '',
name: d['name'] as string ?? '',
kind: d['kind'] as string ?? '',
online: true,
authorized: d['authorized'] as boolean ?? false,
caps: caps,
};
list.push(info);
}
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.devices = list;
});
} else {
this.devices = [];
}
} catch (e) {
// 网络失败保持静默,不打扰用户
}
this.loadingDevices = false;
}
build() {
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回都会自动 pop
Navigation(this.navStack) {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
this.RootEntries()
}
.width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
.onAppear(() => {
this.maybeAutoConnect();
})
}
.width('100%')
.height('100%')
PageTopBar({ title: '设备' })
// 一级悬浮区:只留刷新(网关状态徽标按用户要求去掉,
// 状态已经在页面内的网关卡片里如实展示)
NavFloatOverlay({ tab: 2 }) {
NavFloatRow() {
FloatIconButton({
icon: $r('app.media.ic_refresh'),
onTap: () => {
this.refreshDevices();
},
})
}
}
this.Toast()
}
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
}
.navDestination(this.SubDestination)
// 宽屏(>=600vp用 SplitnavBar一级列表 + 底部悬浮导航栏)常驻左栏,
// NavDestination二级页面渲染在右栏两栏同时可见。
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
.navBarPosition(NavBarPosition.Start)
.navBarWidth(WIDE_NAV_BAR_WIDTH)
.minContentWidth(WIDE_MIN_CONTENT)
.hideTitleBar(true)
.hideToolBar(true)
.hideBackButton(true)
.width('100%')
.height('100%')
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
.onNavBarStateChange((isVisible: boolean) => {
markSubPageOpen(this.isWide || !isVisible);
})
// 模式初始化与切换Split 右栏不能空白,自动填入默认二级页面;
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的二级页面。
.onNavigationModeChange((mode: NavigationMode) => {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
if (this.navStack.size() === 0) {
this.openSub(SUB_LOCAL);
}
} else {
this.navStack.clear(false);
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = SUB_NONE;
});
markSubPageOpen(false);
}
})
}
/** 二级页面路由表 */
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_LOCAL) {
SubPageLayer({
title: '本机设备',
tab: 2,
onBack: () => {
this.closeSub();
},
}) {
this.LocalDeviceContent()
}
} else if (name === SUB_CAPS) {
SubPageLayer({
title: '设备能力',
tab: 2,
onBack: () => {
this.closeSub();
},
}) {
this.CapsContent()
}
} else if (name === SUB_GATEWAY) {
SubPageLayer({
title: '设备通道',
tab: 2,
onBack: () => {
this.closeSub();
},
}) {
this.GatewayContent()
}
} else if (name === SUB_LIST) {
SubPageLayer({
title: '接入的设备',
tab: 2,
onBack: () => {
this.closeSub();
},
showRefresh: true,
onRefresh: () => {
this.refreshDevices();
},
}) {
this.OnlineDevicesContent()
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
@Builder
Toast() {
if (this.toastMsg.length > 0) {
Row() {
Text(this.toastMsg)
.fontSize(13)
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
// ===================== 一级入口列表 =====================
@Builder
RootEntries() {
NavGroup({ caption: '本机' }) {
NavRow({
icon: $r('app.media.ic_phone'),
title: '本机设备',
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
value: this.bridgeConnected ? '在线' : '离线',
selected: this.isWide && this.activeSub === SUB_LOCAL,
onTap: () => {
this.openSub(SUB_LOCAL);
},
})
NavRow({
icon: $r('app.media.ic_bolt'),
title: '设备能力',
subtitle: this.capsCount().toString() + ' 项能力',
value: '',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_CAPS,
onTap: () => {
this.openSub(SUB_CAPS);
},
})
}
NavGroup({ caption: '通道' }) {
NavRow({
icon: $r('app.media.ic_gateway'),
title: '设备通道',
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
value: this.bridgeConnected ? '已连接' : '未连接',
selected: this.isWide && this.activeSub === SUB_GATEWAY,
onTap: () => {
this.openSub(SUB_GATEWAY);
},
})
NavRow({
icon: $r('app.media.ic_devices_multi'),
title: '接入的设备',
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
value: this.devices.length.toString() + ' 台',
showDivider: false,
selected: this.isWide && this.activeSub === SUB_LIST,
onTap: () => {
this.openSub(SUB_LIST);
},
})
}
}
private capsCount(): number {
return LOCAL_CAPS.length;
}
// ===================== 二级:本机设备 =====================
@Builder
LocalDeviceContent() {
PlainCard({ caption: '基本信息' }) {
this.KvRow('设备 ID', this.deviceId.length > 0 ? this.deviceId : '未注册')
this.KvRow('名称', 'HomeAgent OHOS')
this.KvRow('类型', 'phone')
}
PlainCard({ caption: '权限控制' }) {
Row() {
Column({ space: 2 }) {
Text('允许 agent 控制本机')
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
.selectedColor(this.palette().accent)
.onChange((on: boolean) => {
this.toggleAuthorized(on);
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Circle({ width: 8, height: 8 })
.fill(this.authorized ? '#17A964' : '#E84026')
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.margin({ right: 8 })
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
.fontSize(12)
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.width('100%')
.margin({ top: 12 })
.padding({ left: 4 })
}
}
// ===================== 二级:设备能力 =====================
@Builder
CapsContent() {
PlainCard({ caption: '能力清单' }) {
Text('agent 通过设备桥可调用的本机能力:')
.fontSize(12)
.fontColor(this.palette().textMuted)
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_CAPS, (cap: string) => {
Text(cap)
.fontSize(11)
.fontColor(this.palette().accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(999)
.backgroundColor(this.palette().bgHover)
.border({ width: 1, color: this.palette().glassBorder })
.margin({ right: 6, bottom: 6 })
}, (cap: string) => cap)
}
.width('100%')
}
}
// ===================== 二级:设备通道 =====================
@Builder
GatewayContent() {
PlainCard({ caption: '连接信息' }) {
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
}
if (this.lastError.length > 0) {
Text(this.lastError)
.fontSize(12)
.fontColor('#E84026')
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: -8 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
PlainCard({ caption: '操作' }) {
Row() {
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
// 因为父容器的 .animation() 到不了子节点。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button(this.bridgeConnected ? '断开' : '连接网关')
.height(34)
.fontSize(12)
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.border({
width: this.bridgeConnected ? 1 : 0,
color: this.palette().btnGhostBorder,
})
.onClick(() => {
if (this.bridgeConnected) {
deviceBridge.disconnect();
} else {
this.connectBridge();
}
})
}
Blank()
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('刷新设备')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: this.palette().btnGhostBorder })
.fontColor(this.palette().textSecondary)
.onClick(() => {
this.refreshDevices();
})
}
}
.width('100%')
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态bind 携带 Token 完成身份绑定。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })
}
}
// ===================== 二级:接入的设备 =====================
@Builder
OnlineDevicesContent() {
if (this.devices.length === 0) {
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
.fontSize(12)
.fontColor(this.palette().textMuted)
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
ForEach(this.devices, (dev: DeviceInfo) => {
// PlainCard 是自定义组件transition 不能直接挂在它上面(会生成 __Common__ 包装),
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
Column() {
PlainCard({ caption: '' }) {
Row() {
Circle({ width: 8, height: 8 })
.fill(dev.online ? '#17A964' : '#77809A')
.margin({ right: 10 })
Column() {
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
.fontSize(14)
.fontColor(this.palette().textPrimary)
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
.fontSize(11)
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(dev.caps.length.toString() + ' 能力')
.fontSize(10)
.fontColor(this.palette().textMuted)
}
.width('100%')
}
}
.width('100%')
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
}
// ===================== 通用 KV 行 =====================
@Builder
KvRow(k: string, v: string) {
Row() {
Text(k)
.fontSize(13)
.fontColor(this.palette().textSecondary)
Blank()
Text(v)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
}
}
const RADIUS_MD: number = 10;

View File

@ -0,0 +1,398 @@
import { ChatPage } from './ChatPage';
import { PluginsPage } from './PluginsPage';
import { DevicePage } from './DevicePage';
import { SettingsPage } from './SettingsPage';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { navBar } from '../common/NavBarController';
import { handleBackPress } from '../common/NavStackRegistry';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { GradientBackground } from '../components/GradientBackground';
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
interface TabItem {
icon: Resource;
label: string;
pageId: string;
}
/**
* 底部导航单项:矢量图标 + 文字。
* active 必须 @Prop 才能在父组件 currentTab 变化时刷新高亮。
*/
@Component
export struct RailTab {
icon: Resource = $r('app.media.ic_tab_chat');
@Prop label: string = '';
@Prop active: boolean = false;
@Prop accent: string = '';
@Prop muted: string = '';
build() {
// 底部导航按压反馈:交给 MotionBase 统一flexWeight 由外层调用点的
// .layoutWeight(1) 负责,这里内部只需撑满自己那一格)。
MotionBase({ pressEnabled: true }) {
Column({ space: 3 }) {
Image(this.icon)
.width(22)
.height(22)
.fillColor(this.active ? this.accent : this.muted)
// 选中态的颜色迁移必须写在子节点上:父容器的 .animation()
// 只驱动它自己的属性,不会下传给 Image / Text。
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false)
Text(this.label)
.fontSize(10)
// fontWeight 不可插值,切换时仍是跳变,只对颜色做过渡
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.accent : this.muted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.maxLines(1)
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.width('100%')
.height(48)
}
}
}
@Entry
@Component
struct Index {
@Watch('onTabChanged') @State currentTab: number = 0;
@StorageProp('navVisible') private navVisible: boolean = true;
/** 二级页面打开时锁住 Swiper 横滑,避免误切换主 Tab */
@StorageProp('subPageOpen') private subPageOpen: boolean = false;
/** 宽屏(>=600vp一级界面在左、二级界面在右导航栏跟随左侧一级界面 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@State screensueVisible: boolean = false;
@State screensueText: string = '';
@State screensueCountdown: number = 0;
/** 状态栏避让高度vp从窗口 avoid area 动态获取;默认 44 兜底 */
@State topInset: number = 44;
/** 底部手势条高度vp */
@State bottomGesture: number = 16;
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
private swiper: SwiperController = new SwiperController();
private screensueTimer: number = -1;
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
// 「状态」Tab 已删除:运行状态并入设置页(顶部摘要卡 + 二级明细页),
// 底部导航只保留四项,胶囊内每项更宽、点按更好命中。
private tabs: TabItem[] = [
{ icon: $r('app.media.ic_tab_chat'), label: '聊天', pageId: 'chat' },
{ icon: $r('app.media.ic_tab_plugins'), label: '插件', pageId: 'plugins' },
{ icon: $r('app.media.ic_tab_devices'), label: '设备', pageId: 'devices' },
{ icon: $r('app.media.ic_tab_settings'), label: '设置', pageId: 'settings' },
];
aboutToAppear(): void {
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
}
// 种子化自定义背景图状态到 AppStorageGradientBackground 响应读取
const st = connStore.getSettings();
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
AppStorage.setOrCreate<number>('bgOpacity', st.bgOpacity ?? 0.25);
AppStorage.setOrCreate<boolean>('navVisible', true);
AppStorage.setOrCreate<boolean>('subPageOpen', false);
AppStorage.setOrCreate<boolean>('isWideScreen', false);
// 启动时按当前主题刷新一次系统栏EntryAbility 已设过,这里兜底对齐)
this.syncSystemBar();
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44
this.resolveSafeArea();
// 设备桥:注册命令路由与 screensue 悬浮层回调
installCmdRouter();
registerScreensueHandler((payload: ScreensuePayload) => {
this.showScreensue(payload);
});
}
/** agent 下发的 screensue 内容展示悬浮卡片倒计时自动关闭0=常驻)。 */
private showScreensue(payload: ScreensuePayload): void {
this.screensueText = payload.content;
this.screensueCountdown = payload.duration;
this.screensueVisible = true;
if (this.screensueTimer >= 0) {
clearInterval(this.screensueTimer);
this.screensueTimer = -1;
}
if (payload.duration > 0) {
this.screensueTimer = setInterval(() => {
if (this.screensueCountdown <= 1) {
clearInterval(this.screensueTimer);
this.screensueTimer = -1;
this.screensueVisible = false;
} else {
this.screensueCountdown = this.screensueCountdown - 1;
}
}, 1000);
}
}
private closeScreensue(): void {
if (this.screensueTimer >= 0) {
clearInterval(this.screensueTimer);
this.screensueTimer = -1;
}
this.screensueVisible = false;
}
/** 主题翻转时同步系统栏前景/背景色,避免浅色页面配黑状态栏。 */
private onThemeChanged(): void {
this.syncSystemBar();
}
/** 通过窗口 avoid area 计算状态栏与底部手势区高度vp失败时保留默认值。 */
private resolveSafeArea(): void {
try {
const ctx = getContext(this) as common.UIAbilityContext;
window.getLastWindow(ctx).then((win: window.Window) => {
const prop = win.getWindowProperties();
const isLayoutFull = prop.isLayoutFullScreen === true;
if (!isLayoutFull) {
return; // 非全屏模式下系统自动避让
}
const density = display.getDefaultDisplaySync().densityPixels > 0
? display.getDefaultDisplaySync().densityPixels : 3;
const topArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
const bottomArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
if (topArea.topRect.height > 0) {
this.topInset = Math.ceil(topArea.topRect.height / density);
}
// 底部手势条:有值时取其高度的一半作为悬浮导航的下边距基准,最小 8 最大 24
if (bottomArea.bottomRect.height > 0) {
const h: number = Math.ceil(bottomArea.bottomRect.height / density);
this.bottomGesture = Math.min(24, Math.max(8, h));
} else {
this.bottomGesture = 16;
}
}).catch(() => {
// ignore, keep defaults
});
} catch (e) {
// ignore
}
AppStorage.set<number>('currentTab', 0);
}
private onTabChanged(): void {
AppStorage.set<number>('currentTab', this.currentTab);
}
private syncSystemBar(): void {
const dark: boolean = this.isDark;
const bg: string = dark ? '#000000' : '#F1F3F5';
const fg: string = dark ? '#FFFFFF' : '#191919';
try {
window.getLastWindow(getContext(this) as common.UIAbilityContext).then((win: window.Window) => {
win.setWindowSystemBarProperties({
statusBarColor: bg,
statusBarContentColor: fg,
navigationBarColor: bg,
});
win.setWindowBackgroundColor(bg);
}).catch(() => {
// ignore
});
} catch (e) {
// ignore
}
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/** 宽屏判定:窗口宽度 >= 600vp与 Navigation Auto 模式的分栏阈值一致) */
private updateWideScreen(w: number): void {
const wide: boolean = w >= WIDE_MIN_WIDTH;
if (wide !== this.isWide) {
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
}
}
/**
* 统一的返回处理:把返回事件精确派发给【当前 Tab】的那个 Navigation 栈。
*
* 四个页面各自持有 Navigation宽屏需要"左一级右二级"),它们在 Swiper 里
* 同时存活,系统返回落到哪个栈并不确定 —— 这就是"有的页面无法使用返回手势"
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
*/
onBackPress(): boolean {
return handleBackPress(this.currentTab, this.isWide);
}
build() {
// alignContent Bottom悬浮导航停靠底部而不是被 Stack 居中。
// 导航胶囊用左右 6% padding 保持"宽度占 88% 居中"的原样视觉;
// 宽屏下胶囊自然被限制在左侧一级界面栏内,不会横跨二级界面。
Stack({ alignContent: Alignment.Bottom }) {
// 渐变氛围背景
GradientBackground()
// 页面内容Swiper 支持左右滑动切换;$$ 双向同步索引
Swiper(this.swiper) {
ChatPage()
PluginsPage()
DevicePage()
SettingsPage()
}
.index(this.currentTab)
.indicator(false)
.loop(false)
.duration(300)
.curve(Curve.FastOutSlowIn)
.vertical(false)
.cachedCount(2)
.disableSwipe(this.subPageOpen)
.onChange((idx: number) => {
this.currentTab = idx;
navBar.setVisible(true);
})
.width('100%')
.height('100%')
// top 让出状态栏bottom所有页面统一为0导航栏浮在内容之上
.padding({
top: this.topInset,
bottom: 0
})
// 悬浮气态玻璃底部导航(三层:半透明底 -> 高光渐变 -> 内容)
// 外层 Row 负责水平定位:窄屏居中;宽屏靠左,只落在一级界面栏范围内。
Row() {
Stack() {
// 层1玻璃底 —— 半透明背景(无 backdropBlur 以避免矩形模糊伪影)
Column()
.width('100%')
.height('100%')
.backgroundColor(this.palette().navBarBg)
// 层2玻璃高光渐变模拟光从上方照射的质感
Row()
.width('100%')
.height('100%')
.linearGradient({
direction: GradientDirection.Top,
colors: [
[this.palette().navBarGradientStart, 0.0],
[this.palette().navBarGradientEnd, 1.0],
],
})
// 层3导航项
Row({ space: 2 }) {
ForEach(this.tabs, (tab: TabItem, index: number) => {
RailTab({
icon: tab.icon,
label: tab.label,
active: this.currentTab === index,
accent: this.palette().accent,
muted: this.palette().textMuted,
})
.layoutWeight(1)
.onClick(() => {
this.currentTab = index;
navBar.setVisible(true);
})
}, (item: TabItem, index: number) => item.pageId + index.toString())
}
.width('100%')
.height(62)
.padding({ left: 14, right: 14 })
.alignItems(VerticalAlign.Center)
}
// 宽屏:胶囊宽度锁在左侧一级界面栏内(左右各留 24vp 余量)
.width(this.isWide ? WIDE_NAV_BAR_WIDTH - 48 : '88%')
.height(62)
.borderRadius(28)
.clip(true)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 24,
color: this.palette().shadow,
offsetY: 8,
})
}
.width('100%')
.height(62)
.padding({ left: this.isWide ? 24 : 0 })
.justifyContent(this.isWide ? FlexAlign.Start : FlexAlign.Center)
.margin({ bottom: this.bottomGesture + 6 })
// 所有页面滑动时隐藏导航栏
.translate({ y: !this.navVisible ? 130 : 0 })
.opacity(!this.navVisible ? 0 : 1)
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent)
// screensue 悬浮层agent 推送给用户看的内容(置顶展示)
if (this.screensueVisible) {
Column() {
Row() {
Circle({ width: 8, height: 8 })
.fill(this.palette().accent)
Text('agent 推送')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ left: 8 })
Blank()
if (this.screensueCountdown > 0) {
Text(this.screensueCountdown.toString() + 's')
.fontSize(11)
.fontColor(this.palette().textMuted)
}
Text('关闭')
.fontSize(12)
.fontColor(this.palette().accent)
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
.onClick(() => {
this.closeScreensue();
})
}
.width('100%')
.margin({ bottom: 10 })
Scroll() {
Text(this.screensueText)
.fontSize(15)
.fontColor(this.palette().textPrimary)
.width('100%')
}
.constraintSize({ maxHeight: 320 })
.scrollBar(BarState.Auto)
.align(Alignment.Top)
}
.width('86%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
}
}
.width('100%')
.height('100%')
.id(snapshotComponentId())
// 实时跟随窗口宽度:平板旋转、折叠展开、分屏、自由窗口拖拽都会回调,
// 比启动时查一次 display 更可靠(不会漏掉运行中的尺寸变化)。
.onAreaChange((oldValue: Area, newValue: Area) => {
this.updateWideScreen(newValue.width as number);
})
}
}

View File

@ -0,0 +1,995 @@
import { apiClient } from '../common/ApiClient';
import { userMessage, noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
import { SettingsEditor } from '../components/SettingsEditor';
interface InstallBody {
url: string;
}
/** 二级页面标识:插件详情 */
const SUB_NONE: string = '';
const SUB_DETAIL: string = 'detail';
@Component
export struct PluginsPage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
/** 宽屏:左边插件列表(含底部导航栏),右边插件详情 */
@StorageProp('isWideScreen') private isWide: boolean = false;
@State plugins: PluginRow[] = [];
@State loading: boolean = false;
@State showInstallForm: boolean = false;
@State installUrl: string = '';
@State toastMsg: string = '';
@State toastIsError: boolean = false;
/** 当前查看详情的插件名,用于宽屏下高亮左侧列表项 */
@State activeName: string = '';
/** 详情页数据GET /plugins/{name} */
@State detail: PluginDetail = emptyPluginDetail();
@State detailLoading: boolean = false;
@State detailError: string = '';
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
aboutToAppear(): void {
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(1, this.navStack, () => {
this.activeName = '';
});
this.loadPlugins();
}
aboutToDisappear(): void {
unregisterNavStack(1);
}
private palette(): ThemePalette {
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 数据源对齐 WebGUI renderPlugins
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools按 plugin 归属)
* - GET /plugins → 已安装外部插件元数据version/description 等)
* - GET /plugins/disabled → {disabled:[{name,...}]}
* 三方按名称合并去重排序。
*/
private async loadPlugins(): Promise<void> {
if (!apiClient.hasConnection()) {
return;
}
this.loading = true;
try {
// ---- kernel: loaded plugins + tool ownership ----
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
const kpRaw: Object | undefined = kernelObj['plugins'];
if (kpRaw !== undefined && kpRaw !== null) {
const kpArr: Object[] = kpRaw as Object[];
for (let i = 0; i < kpArr.length; i++) {
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length === 0) {
continue;
}
loadedMap.set(n, item['loaded'] as boolean ?? true);
}
}
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
const tRaw: Object | undefined = kernelObj['tools'];
if (tRaw !== undefined && tRaw !== null) {
const tArr: Object[] = tRaw as Object[];
for (let i = 0; i < tArr.length; i++) {
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
const tn: string = item['name'] as string ?? '';
const owner: string = item['plugin'] as string ?? '';
if (tn.length === 0 || owner.length === 0) {
continue;
}
let list: string[] | undefined = toolsByPlugin.get(owner);
if (list === undefined) {
list = [];
toolsByPlugin.set(owner, list);
}
// 每插件最多展示 8 个工具名,避免卡片过长
if (list.length < 8) {
list.push(tn);
}
}
}
// ---- installed external plugins metadata ----
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
try {
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
const bodyTrim = pResp.body.trim();
let arr: Object[] = [];
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
arr = JSON.parse(pResp.body) as Object[];
} else {
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
const rawList: Object = obj['plugins'] ?? obj['data'];
if (rawList !== undefined && rawList !== null) {
arr = rawList as Object[];
}
}
for (let i = 0; i < arr.length; i++) {
const item: Record<string, Object> = arr[i] as Record<string, Object>;
const n: string = item['name'] as string ?? '';
if (n.length > 0) {
externalMeta.set(n, item);
}
}
} catch (e) {
// 外部列表失败不阻塞内置展示
}
// ---- disabled list ----
const disabledNames: Set<string> = new Set<string>();
try {
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
const dArr: Object | undefined = dObj['disabled'];
if (dArr !== undefined && dArr !== null) {
const items: Object[] = dArr as Object[];
for (let di = 0; di < items.length; di++) {
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
const dn: string = dItem['name'] as string ?? '';
if (dn.length > 0) {
disabledNames.add(dn);
}
}
}
} catch (e) {
// disabled endpoint may not exist; ignore
}
// ---- merge: allNames sorted与 GUI 一致)----
const allNames: Set<string> = new Set<string>();
loadedMap.forEach((v: boolean, k: string) => {
allNames.add(k);
});
externalMeta.forEach((v: Record<string, Object>, k: string) => {
allNames.add(k);
});
disabledNames.forEach((n: string) => {
allNames.add(n);
});
const names: string[] = Array.from(allNames);
names.sort();
const rows: PluginRow[] = [];
for (let i = 0; i < names.length; i++) {
const name: string = names[i];
const meta: Record<string, Object> | undefined = externalMeta.get(name);
const tools: string[] | undefined = toolsByPlugin.get(name);
const row: PluginRow = {
name: name,
loaded: loadedMap.get(name) ?? false,
disabled: disabledNames.has(name),
external: externalMeta.has(name),
version: meta !== undefined ? meta['version'] as string ?? '' : '',
description: meta !== undefined ? meta['description'] as string ?? '' : '',
tools: tools,
};
rows.push(row);
}
// 列表整体重建ForEach key 含 loaded/disabled启停会整行重挂载
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.plugins = rows;
});
} catch (e) {
this.showToast(userMessage('plugins.load', e), true);
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.loading = false;
});
}
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
private statusOf(plugin: PluginRow): string {
if (plugin.loaded && !plugin.disabled) {
return 'loaded'; // 已加载
}
if (plugin.loaded && plugin.disabled) {
return 'pending'; // 运行中(禁用待生效)
}
if (plugin.disabled) {
return 'disabled'; // 已禁用
}
return 'notloaded'; // 未加载
}
private async togglePlugin(plugin: PluginRow): Promise<void> {
const name: string = plugin.name;
const action: string = plugin.disabled ? 'enable' : 'disable';
try {
await apiClient.post('/plugins/' + name + '/' + action, null);
this.showToast('已' + (plugin.disabled ? '启用' : '禁用') + '插件: ' + name, false);
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.toggle', e), true);
}
}
private async removePlugin(plugin: PluginRow): Promise<void> {
const name: string = plugin.name;
try {
await apiClient.request('/plugins/' + name, 'DELETE', '', 8000);
this.showToast('已卸载插件: ' + name, false);
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.remove', e), true);
}
}
private async installPlugin(): Promise<void> {
const url: string = this.installUrl.trim();
if (url.length === 0) {
return;
}
try {
const bodyObj: InstallBody = { url: url };
// 后端没有 /plugins/install 这个路由:安装就是 POST /pluginsbody 带 url。
await apiClient.post('/plugins', bodyObj);
this.showToast('安装请求已发送', false);
this.installUrl = '';
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.showInstallForm = false;
});
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.install', e), true);
}
}
// ===================== 二级页面:插件详情 =====================
/**
* 打开插件详情。
*
* 窄屏Stackpush 一层整屏覆盖,系统侧滑返回可退。
* 宽屏Split左栏列表常驻右栏换页而不叠栈。
*/
private openDetail(name: string): void {
this.activeName = name;
this.loadDetail(name);
if (this.isWide && this.navStack.size() > 0) {
this.navStack.replacePathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), false);
} else {
this.navStack.pushPathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), true);
}
}
private closeDetail(): void {
this.navStack.pop();
this.activeName = '';
}
/**
* GET /plugins/{name} —— 后端返回插件清单字段。
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
*/
private async loadDetail(name: string): Promise<void> {
this.detailLoading = true;
this.detailError = '';
try {
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const d: PluginDetail = emptyPluginDetail();
d.name = o['name'] as string ?? name;
d.version = o['version'] as string ?? '';
d.description = o['description'] as string ?? '';
d.author = o['author'] as string ?? '';
d.license = o['license'] as string ?? '';
d.homepage = o['homepage'] as string ?? '';
d.repository = o['repository'] as string ?? '';
d.entry = o['entry'] as string ?? '';
d.minVersion = o['min_version'] as string ?? '';
d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = this.strArray(o['tags']);
d.files = this.strArray(o['files']);
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载V1 给它们默认透明度过渡
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d;
});
} catch (e) {
// 内置插件没有清单,属于预期情况,不当成错误刷红
const row: PluginRow | null = this.findRow(name);
const d: PluginDetail = emptyPluginDetail();
d.name = name;
if (row !== null) {
d.version = row.version ?? '';
d.description = row.description ?? '';
}
const errText: string = (row !== null && row.external) ? userMessage('plugins.detail', e) : '';
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d;
this.detailError = errText;
});
}
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detailLoading = false;
});
}
private strArray(raw: Object | undefined): string[] {
const out: string[] = [];
if (raw === undefined || raw === null) {
return out;
}
const arr: Object[] = raw as Object[];
for (let i = 0; i < arr.length; i++) {
const s: string = arr[i] as string ?? '';
if (s.length > 0) {
out.push(s);
}
}
return out;
}
private findRow(name: string): PluginRow | null {
for (let i = 0; i < this.plugins.length; i++) {
if (this.plugins[i].name === name) {
return this.plugins[i];
}
}
return null;
}
/** 详情页正在展示的那一行(供二级页面里的操作按钮使用) */
private activeRow(): PluginRow | null {
return this.findRow(this.activeName);
}
private async reloadPlugins(): Promise<void> {
try {
await apiClient.post('/plugins/reload', null);
this.showToast('插件已重载', false);
this.loadPlugins();
} catch (e) {
this.showToast(userMessage('plugins.reload', e), true);
}
}
private showToast(msg: string, isError: boolean): void {
// 颜色标志必须在 animateTo 闭包之外先落地,否则第一帧配色是上一次的
this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = '';
});
}, 2500);
}
build() {
// 宽屏(>=600vp走 SplitnavBar左栏 = 插件列表 + 底部导航栏)常驻,
// NavDestination右栏 = 插件详情)与之并列;窄屏则是整屏覆盖的二级页面。
Navigation(this.navStack) {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
Scroll() {
Column() {
this.ListStates()
this.PluginList()
}
.width('100%')
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.align(Alignment.Top)
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
}
.width('100%')
.height('100%')
// 顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None触摸完全穿透到滚动区
PageTopBar({ title: '插件' })
// 悬浮操作区:安装(悬浮主按钮)+ 重载。
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
NavFloatOverlay({ tab: 1 }) {
if (this.showInstallForm) {
this.InstallForm()
}
NavFloatRow() {
FloatIconButton({
icon: $r('app.media.ic_reload'),
onTap: () => {
this.reloadPlugins();
},
})
Button() {
Image($r('app.media.ic_plus'))
.width(20)
.height(20)
.fillColor(Color.White)
.rotate({ angle: this.showInstallForm ? 45 : 0 })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
}
.width(50)
.height(50)
.type(ButtonType.Circle)
.backgroundColor(this.palette().accent)
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
.onClick(() => {
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.showInstallForm = !this.showInstallForm;
});
})
}
}
this.Toast()
}
.width('100%')
.height('100%')
.backgroundColor(Color.Transparent)
}
.navDestination(this.SubDestination)
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
.navBarPosition(NavBarPosition.Start)
.navBarWidth(WIDE_NAV_BAR_WIDTH)
.minContentWidth(WIDE_MIN_CONTENT)
.hideTitleBar(true)
.hideToolBar(true)
.hideBackButton(true)
.width('100%')
.height('100%')
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
.onNavBarStateChange((isVisible: boolean) => {
markSubPageOpen(this.isWide || !isVisible);
})
// 模式初始化与切换Split 右栏不能空白,自动选中第一个插件;
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的详情页。
.onNavigationModeChange((mode: NavigationMode) => {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
if (this.navStack.size() === 0 && this.plugins.length > 0) {
this.openDetail(this.plugins[0].name);
}
} else {
this.navStack.clear(false);
this.activeName = '';
markSubPageOpen(false);
}
})
}
/** 二级页面路由表:插件详情 */
@Builder
SubDestination(name: string, param: object) {
NavDestination() {
if (name === SUB_DETAIL) {
SubPageLayer({
title: this.activeName.length > 0 ? this.activeName : '插件详情',
tab: 1,
onBack: () => {
this.closeDetail();
},
showRefresh: true,
onRefresh: () => {
this.loadDetail(this.activeName);
},
}) {
this.DetailContent()
}
}
}
.hideTitleBar(true)
.backgroundColor(Color.Transparent)
}
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
@Builder
InstallForm() {
Row() {
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
.layoutWeight(1)
.height(36)
.fontSize(14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textMuted)
.backgroundColor(this.palette().bgInput)
.borderRadius(RADIUS_SM)
.border({ width: 1, color: this.palette().border })
.onChange((v: string) => {
this.installUrl = v;
})
Button('安装')
.height(36)
.fontSize(12)
.backgroundColor(this.palette().accent)
.fontColor('#FFFFFF')
.margin({ left: 6 })
.onClick(() => {
this.installPlugin();
})
}
.width('100%')
.padding(10)
.margin({ bottom: 10 })
.backgroundColor(this.palette().navBarBg)
.borderRadius(RADIUS_MD)
.border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}
/** 加载中 / 未配置 / 空列表三种占位态 */
@Builder
ListStates() {
if (this.loading && this.plugins.length === 0) {
LoadingProgress()
.width(32)
.height(32)
.color(this.palette().accent)
.margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!apiClient.hasConnection()) {
Text(noConnectionMessage())
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
Text('暂无已加载插件')
.fontSize(13)
.fontColor(this.palette().textMuted)
.padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
/**
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
*/
@Builder
PluginList() {
ForEach(this.plugins, (plugin: PluginRow) => {
// 外层 Column 只为承载 transition.transition() 不能直接挂在自定义组件
// 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
// 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
Column() {
MotionBase({ pressEnabled: true }) {
Row() {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(plugin.name)
.fontSize(15)
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
if (plugin.version !== undefined && plugin.version.length > 0) {
Text('v' + plugin.version)
.fontSize(10)
.fontColor(this.palette().textMuted)
}
}
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 })
.fill(this.statusDotColor(plugin))
Text(this.rowSubtitle(plugin))
.fontSize(11)
.fontColor(this.palette().textMuted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Image($r('app.media.ic_chevron_right'))
.width(15)
.height(15)
.fillColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.draggable(false)
}
.width('100%')
.padding(14)
.borderRadius(RADIUS_LG)
.backgroundColor(this.activeName === plugin.name
? this.palette().accentBg : this.palette().bgCard)
.border({
width: 1,
color: this.activeName === plugin.name
? this.palette().accent : this.palette().glassBorder,
})
// 选中态的底色/描边渐变MotionBase 的 .animation() 到不了这里
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.openDetail(plugin.name);
})
}
}
.width('100%')
.margin({ bottom: 10 })
// ForEach key 含 loaded/disabled启停会整行重挂载靠 transition 变成交叉淡入
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
}
/**
* 二级页面:插件详情。
* WebGUI 这里只有一个 JSON.stringify 的 <pre>
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
*/
@Builder
DetailContent() {
if (this.detailLoading) {
Row() {
LoadingProgress()
.width(26)
.height(26)
.color(this.palette().accent)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
if (this.detailError.length > 0) {
Text(this.detailError)
.fontSize(12)
.fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
// 概览卡:名称、版本、状态徽标、描述
PlainCard({ caption: '概览' }) {
Row({ space: 8 }) {
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.detail.version.length > 0) {
Text('v' + this.detail.version)
.fontSize(12)
.fontColor(this.palette().textSecondary)
}
}
.width('100%')
.margin({ bottom: 10 })
this.DetailBadges()
if (this.detail.description.length > 0) {
Text(this.detail.description)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.width('100%')
.margin({ top: 10 })
}
}
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
if (this.hasManifest()) {
PlainCard({ caption: '清单' }) {
this.KvRow('作者', this.detail.author)
this.KvRow('许可证', this.detail.license)
this.KvRow('主页', this.detail.homepage)
this.KvRow('仓库', this.detail.repository)
this.KvRow('入口', this.detail.entry)
this.KvRow('最低内核版本', this.detail.minVersion)
}
}
if (this.detail.tags.length > 0) {
PlainCard({ caption: '标签' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (t: string) => t)
}
}
}
this.DetailTools()
// 插件配置plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
if (this.activeName.length > 0) {
PlainCard({ caption: '插件配置' }) {
SettingsEditor({
prefix: 'plugin.' + this.activeName + '.',
emptyHint: '该插件没有暴露可配置项',
})
}
}
if (this.detail.files.length > 0) {
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
ForEach(this.detail.files, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.palette().textSecondary)
.width('100%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 4 })
}, (f: string) => f)
}
}
this.DetailActions()
}
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
@Builder
DetailBadges() {
Row({ space: 6 }) {
Circle({ width: 7, height: 7 })
.fill(this.activeStatusColor())
Text(this.detailStatusLine())
.fontSize(12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
private detailStatusLine(): string {
const parts: string[] = [];
parts.push(this.activeStatusText());
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
if (this.detail.deprecated) {
parts.push('已废弃');
}
const n: number = this.activeTools().length;
if (n > 0) {
parts.push(n.toString() + ' 个工具');
}
return parts.join(' · ');
}
/** 工具清单来自一级列表已合并的 kernel.tools按 plugin 归属) */
@Builder
DetailTools() {
if (this.activeTools().length > 0) {
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.activeTools(), (tool: string) => {
Text(tool)
.fontSize(11)
.fontColor('#4A90D9')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().frostSoftBg)
.margin({ right: 5, bottom: 5 })
}, (tool: string) => tool)
}
}
}
}
@Builder
DetailActions() {
if (this.activeName.length > 0) {
PlainCard({ caption: '操作' }) {
Row() {
Button(this.activeIsDisabled() ? '启用' : '禁用')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({
width: 1,
color: this.activeIsDisabled()
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
})
.fontColor(this.activeIsDisabled()
? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.togglePlugin(r);
}
})
Blank()
if (!this.activeIsBuiltin()) {
Button('卸载')
.height(34)
.fontSize(12)
.backgroundColor(Color.Transparent)
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
.fontColor(COLOR_ERROR)
.onClick(() => {
const r: PluginRow | null = this.activeRow();
if (r !== null) {
this.removePlugin(r);
}
})
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
}
}
.width('100%')
}
}
}
// ---- 详情页取值助手ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
private hasManifest(): boolean {
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
}
private activeIsBuiltin(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? !r.external : false;
}
private activeIsDisabled(): boolean {
const r: PluginRow | null = this.activeRow();
return r !== null ? r.disabled : false;
}
private activeTools(): string[] {
const r: PluginRow | null = this.activeRow();
if (r === null) {
return [];
}
return r.tools ?? [];
}
private activeStatusText(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeText(r) : '未加载';
}
private activeStatusColor(): string {
const r: PluginRow | null = this.activeRow();
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
}
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
@Builder
KvRow(label: string, value: string) {
if (value.length > 0) {
Row() {
Text(label)
.fontSize(13)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
Text(value)
.fontSize(13)
.fontColor(this.palette().textPrimary)
.textAlign(TextAlign.End)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 220 })
.margin({ left: 16 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
}
@Builder
Toast() {
if (this.toastMsg.length > 0) {
Row() {
Text(this.toastMsg)
.fontSize(13)
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.borderRadius(RADIUS_MD)
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
.border({
width: 1,
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ right: 20 })
.margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
}
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
private statusDotColor(plugin: PluginRow): string {
return this.statusBadgeColor(plugin);
}
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
private rowSubtitle(plugin: PluginRow): string {
const parts: string[] = [];
parts.push(this.statusBadgeText(plugin));
parts.push(plugin.external ? '外部' : '内置');
if (plugin.tools !== undefined && plugin.tools.length > 0) {
parts.push(plugin.tools.length.toString() + ' 工具');
}
return parts.join(' · ');
}
private statusBadgeText(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '已加载';
}
if (s === 'pending') {
return '待生效';
}
if (s === 'disabled') {
return '已禁用';
}
return '未加载';
}
private statusBadgeColor(plugin: PluginRow): string {
const s: string = this.statusOf(plugin);
if (s === 'loaded') {
return '#17A964';
}
if (s === 'pending') {
return '#D99A2B';
}
if (s === 'disabled') {
return '#E84026';
}
return this.palette().textMuted;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
{
"module": {
"name": "entry",
"type": "entry",
"description": "HomeAgent client entry module",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone",
"tablet"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "HomeAgent main entry",
"icon": "$media:layered_image",
"label": "$string:app_name",
// 启动页图标用透明底字形,配合随主题切换的 start_window_background
// 浅色/深色模式下遮罩与图标都能对上,不再恒为深色样式。
"startWindowIcon": "$media:start_icon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"action.system.home"
]
}
]
}
]
}
}

View File

@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#F1F3F5"
}
]
}

View File

@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "HomeAgent"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 21h-1l1-7H7.5c-.88 0-.33-.75-.31-.78C8.48 10.94 10.42 7.54 13.01 3h1l-1 7h3.51c.4 0 .62.19.4.66C12.97 17.55 11 21 11 21z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 16.2l-3.5-3.5L4 14.2 9 19.2 20 8.2l-1.4-1.4z"/></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>

After

Width:  |  Height:  |  Size: 161 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M4 6h18V4H4c-1.1 0-2 .9-2 2v11H0v3h14v-3H4V6zm19 2h-6c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1V9c0-.55-.45-1-1-1zm-1 9h-4v-7h4v7z"/></svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 5v5m0 3v.5"/></svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2.5L17.5 8H14V4.5zM8 13h8v1.6H8V13zm0 3.4h8V18H8v-1.6zM8 9.6h4v1.6H8V9.6z"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 14H4v-2.6l3.6-3.6 3 3 4.2-4.2L20 15v3zm0-5.3-5.2-5.2-4.2 4.2-3-3L4 13.2V6h16v6.7zM8.4 9.9a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 2a7 7 0 0 0-5.3 11.5L17.5 6.7A7 7 0 0 0 12 5zm0 14a7 7 0 0 0 5.3-11.5L6.5 17.3A7 7 0 0 0 12 19z"/></svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 2v5H7V2h2zm8 0v5h-2V2h2zM5 9h14v3a7 7 0 0 1-6 6.9V22h-2v-3.1A7 7 0 0 1 5 12V9zm2 2v1a5 5 0 0 0 10 0v-1H7z"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z"/></svg>

After

Width:  |  Height:  |  Size: 151 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 12h3.3l2.1-5.4a1 1 0 0 1 1.9.1l2.6 9.1 1.9-4.4a1 1 0 0 1 .9-.6H21v2h-4.6l-2.6 6a1 1 0 0 1-1.9-.1L9.3 9.7 7.9 13.4a1 1 0 0 1-.9.6H3v-2z"/></svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 2c.8 5.2 4.8 9.2 10 10-5.2.8-9.2 4.8-10 10-.8-5.2-4.8-9.2-10-10 5.2-.8 9.2-4.8 10-10z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7 7h10c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1V8c0-.55.45-1 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12l-4-3-4 3V5h8v9z"/></svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4.5 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 17H6v-6h2v6zm4 0h-2V9h2v8zm4 0h-2v-4h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10zm1-17.93c3.94.49 7 3.85 7 7.93s-3.05 7.44-7 7.93V4.07z"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -0,0 +1,5 @@
{
"src": [
"pages/Index"
]
}

View File

@ -0,0 +1,14 @@
{
"network-security-config": {
"domain-config": [
{
"cleartextTraffic": true,
"domain": {
"include-domains": [
"*"
]
}
}
]
}
}

View File

@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#000000"
}
]
}

View File

@ -0,0 +1,7 @@
{
"modelVersion": "5.0.0",
"dependencies": {
"@ohos/hvigor": "file:/opt/huawei/command-line-tools/hvigor/hvigor",
"@ohos/hvigor-ohos-plugin": "file:/opt/huawei/command-line-tools/hvigor/hvigor-ohos-plugin"
}
}

View File

@ -0,0 +1 @@
export { appTasks } from '@ohos/hvigor-ohos-plugin';

View File

@ -0,0 +1,28 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21",
"@ycj3/streaming-markdown@^2.1.1": "@ycj3/streaming-markdown@2.1.1"
},
"packages": {
"@ohos/hypium@1.0.21": {
"name": "@ohos/hypium",
"version": "1.0.21",
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
"registryType": "ohpm"
},
"@ycj3/streaming-markdown@2.1.1": {
"name": "@ycj3/streaming-markdown",
"version": "2.1.1",
"integrity": "sha512-YLofL0X0wcQ5ZWSO1/cHUjK5F4hQpE2atygm1hs+w3bsaaVae3dHte7OMbcyZ3zcwFPswSEOa3A4+geeIIMXAg==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@ycj3/streaming-markdown/-/streaming-markdown-2.1.1.har",
"registryType": "ohpm"
}
}
}

View File

@ -0,0 +1,16 @@
{
"name": "homeagent",
"version": "1.0.0",
"modelVersion": "5.0.0",
"description": "HomeAgent HarmonyOS Client",
"main": "",
"author": "",
"license": "Apache-2.0",
"dependencies": {
"@ycj3/streaming-markdown": "^2.1.1"
},
"devDependencies": {
"@ohos/hypium": "1.0.21"
},
"dynamicDependencies": {}
}

99
cmd/ohos/README.md Normal file
View File

@ -0,0 +1,99 @@
# HomeAgent 鸿蒙客户端
HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebView 套壳)。
功能与 WebUI 对齐SSE 流式对话、工具调用卡片、思考过程折叠、附件上传预览、
设备桥、插件管理、设置编辑、宽屏双栏、深浅色主题。
## 工程结构
```
HomeAgent/
├── AppScope/ 应用级配置与图标
├── entry/src/main/
│ ├── ets/
│ │ ├── common/ 通信与全局状态
│ │ │ ├── ApiClient.ets REST 客户端X-API-Key 鉴权、超时、二进制附件)
│ │ │ ├── SseClient.ets SSE 长连接Last-Event-ID 断线续传)
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
│ │ │ ├── BridgeRouter.ets 桥请求路由
│ │ │ ├── BridgeCaps.ets 能力声明
│ │ │ ├── ConnStore.ets 连接配置持久化
│ │ │ ├── StatusStore.ets 运行状态缓存
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
│ │ │ └── UserError.ets 错误转人类可读文案
│ │ ├── components/ 可复用组件
│ │ │ ├── MarkdownView.ets 流式 Markdown增量渲染
│ │ │ ├── StaticMarkdown.ets 静态 Markdown历史消息一次成型
│ │ │ ├── Attachment.ets 附件卡片 + 详情
│ │ │ ├── StatusCards.ets 状态卡片
│ │ │ ├── SettingsEditor.ets 配置编辑器
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
│ │ │ ├── SubPage.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 依赖
```
## 编译
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`
1. **准备签名配置**`build-profile.json5` 含密码明文,未入库):
```bash
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` 生成签名材料。
2. **构建 HAP**
```bash
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
/path/to/command-line-tools/bin/hvigorw \
--mode module -p module=entry@default assembleHap --no-daemon
```
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
3. **安装到设备**
```bash
hdc install entry/build/default/outputs/default/entry-default-signed.hap
```
## 连接 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 辅助生成,改动请自行评估。

View File

@ -19,6 +19,12 @@ type Conn interface {
}
func dial(cfg *Config) (Conn, error) {
// 优先连接运行中的 daemon后台驻留模式
if daemonIsRunning() {
if c, err := dialDaemon(); err == nil {
return c, nil
}
}
if cfg.Remote != "" {
return dialRemote(cfg.Remote, cfg.APIKey)
}
@ -52,6 +58,39 @@ func dialLocal(socket, apiKey string) (Conn, error) {
return lc, nil
}
// daemonConn 是连接到运行中 waiter daemon 的轻量封装。
// 协议与 localConn 完全一致(行式 \n 分隔),但不做 /authdaemon 已集中鉴权)。
type daemonConn struct {
conn net.Conn
r *bufio.Reader
}
func dialDaemon() (Conn, error) {
sock := daemonSocketPath()
c, err := net.DialTimeout("unix", sock, 3*time.Second)
if err != nil {
return nil, fmt.Errorf("daemon %s: %w", sock, err)
}
return &daemonConn{conn: c, r: bufio.NewReader(c)}, nil
}
func (c *daemonConn) Send(line string) error {
_, err := fmt.Fprintf(c.conn, "%s\n", line)
return err
}
func (c *daemonConn) ReadLine() (string, error) {
s, err := c.r.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSuffix(s, "\n"), nil
}
func (c *daemonConn) Close() error {
return c.conn.Close()
}
type localConn struct {
conn net.Conn
r *bufio.Reader

378
cmd/waiter/daemon.go Normal file
View File

@ -0,0 +1,378 @@
package main
import (
"bufio"
"fmt"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
)
// ---------------------------------------------------------------------------
// Daemon 模式:后台驻留,维持 homed 连接 + 设备桥 + 消息缓冲
//
// 工作原理:
// - daemon 保持一个到 homed 的持久连接
// - TUI 实例通过 Unix socket 连接到 daemon
// - daemon 为每个 TUI 客户端分配独立的 homed 响应(通过 homeMu 序列化)
// - 新客户端连入时回放缓冲的历史消息(方便重连后看到上下文)
// ---------------------------------------------------------------------------
const (
daemonSocketName = "waiter.sock"
msgBufCap = 256 // 环形缓冲最近 N 行 homed 输出
)
type msgEntry struct {
line string
seq uint64
}
type daemonHandler struct {
// homed 连接
homeMu sync.Mutex
homeConn net.Conn
homeR *bufio.Reader
homeCfg *Config
// 消息缓冲(新客户端连入时回放)
bufMu sync.Mutex
buf []msgEntry
bufSeq uint64
bufCap int
// 生命周期
stopCh chan struct{}
}
func newDaemonHandler() *daemonHandler {
return &daemonHandler{
bufCap: msgBufCap,
stopCh: make(chan struct{}),
}
}
// ===== 消息缓冲 =====
func (h *daemonHandler) appendBuf(line string) {
h.bufMu.Lock()
defer h.bufMu.Unlock()
h.bufSeq++
h.buf = append(h.buf, msgEntry{line: line, seq: h.bufSeq})
if len(h.buf) > h.bufCap {
h.buf = h.buf[len(h.buf)-h.bufCap:]
}
}
func (h *daemonHandler) replayBuffer() []string {
h.bufMu.Lock()
defer h.bufMu.Unlock()
lines := make([]string, 0, len(h.buf))
for _, e := range h.buf {
lines = append(lines, e.line)
}
return lines
}
// ===== homed 连接 =====
func (h *daemonHandler) connectHome(cfg *Config) error {
h.homeCfg = cfg
if cfg.Remote != "" {
return fmt.Errorf("daemon: remote mode not supported")
}
if cfg.Socket == "" {
cfg.Socket = discoverSocket("")
}
c, err := net.DialTimeout("unix", cfg.Socket, 5*time.Second)
if err != nil {
return fmt.Errorf("daemon: connect home: %w", err)
}
h.homeConn = c
h.homeR = bufio.NewReader(c)
log.Printf("[daemon] connected to home %s", cfg.Socket)
return nil
}
func (h *daemonHandler) closeHome() {
if h.homeConn != nil {
h.homeConn.Close()
h.homeConn = nil
}
}
func (h *daemonHandler) reconnectHome() {
cfg := h.homeCfg
if cfg == nil {
cfg = discoverConfig("")
}
if cfg.Socket == "" && cfg.Remote == "" {
cfg.Socket = discoverSocket("")
}
for i := 0; i < 30; i++ {
select {
case <-h.stopCh:
return
default:
}
h.closeHome()
time.Sleep(2 * time.Second)
if err := h.connectHome(cfg); err != nil {
log.Printf("[daemon] reconnect home (%d/30): %v", i+1, err)
continue
}
log.Printf("[daemon] reconnected to home")
return
}
log.Printf("[daemon] gave up reconnecting to home")
}
// handleClient 处理单个 TUI 客户端:
// 1. 回放缓冲历史
// 2. 读客户端输入 → 转发到 homed
// 3. 读 homed 响应 → 回写给该客户端(独占响应,不广播)
func (h *daemonHandler) handleClient(c net.Conn) {
defer c.Close()
cid := fmt.Sprintf("%s", c.RemoteAddr())
log.Printf("[daemon] client %s connected", cid)
defer log.Printf("[daemon] client %s disconnected", cid)
// 1) 回放缓冲(新客户端看到最近对话上下文)
for _, line := range h.replayBuffer() {
fmt.Fprintf(c, "%s\n", line)
}
// 2) 循环:读客户端 → 转发 homed → 读 homed 响应 → 回写客户端
reader := bufio.NewReader(c)
for {
c.SetReadDeadline(time.Now().Add(5 * time.Minute))
line, err := reader.ReadString('\n')
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
return
}
line = strings.TrimSuffix(line, "\n")
if line == "" {
continue
}
// 转发到 homed加锁保证请求-响应配对)
h.homeMu.Lock()
if h.homeConn == nil {
h.homeMu.Unlock()
fmt.Fprintf(c, `{"type":"error","error":"not connected to home"}`+"\n")
continue
}
_, sendErr := fmt.Fprintf(h.homeConn, "%s\n", line)
if sendErr != nil {
h.homeMu.Unlock()
fmt.Fprintf(c, `{"type":"error","error":"send failed"}`+"\n")
continue
}
// 读 homed 响应所有帧reasoning_delta / content_delta / tool_call / response / error
for {
h.homeConn.SetReadDeadline(time.Now().Add(60 * time.Second))
respLine, readErr := h.homeR.ReadString('\n')
if readErr != nil {
h.homeMu.Unlock()
log.Printf("[daemon] home read error during client %s: %v", cid, readErr)
h.reconnectHome()
// 回写错误给客户端
fmt.Fprintf(c, `{"type":"error","error":"home disconnected"}`+"\n")
goto nextMessage
}
respLine = strings.TrimSuffix(respLine, "\n")
if respLine == "" {
continue
}
// 写入缓冲 + 回写给发起请求的客户端
h.appendBuf(respLine)
fmt.Fprintf(c, "%s\n", respLine)
// 检查是否是终结帧
if strings.Contains(respLine, `"type":"response"`) || strings.Contains(respLine, `"type":"error"`) {
break
}
}
h.homeMu.Unlock()
nextMessage:
}
}
// ===== 启动入口 =====
func runDaemon(cfg *Config) {
dh := newDaemonHandler()
// 连接 homed设备桥场景下可失败——被控主机无需 homed
if cfg.Socket != "" || cfg.Remote != "" {
if err := dh.connectHome(cfg); err != nil {
log.Printf("[daemon] home connect failed: %v (continue with device bridge only)", err)
dh.homeConn = nil
}
} else {
log.Printf("[daemon] no home socket configured, running device bridge only")
}
defer dh.closeHome()
// 启动设备桥(设备网关场景下为核心职责)
startDaemonDeviceBridge(cfg)
// 监听 Unix socket
sockPath := daemonSocketPath()
os.Remove(sockPath)
os.MkdirAll(filepath.Dir(sockPath), 0755)
ln, err := net.Listen("unix", sockPath)
if err != nil {
fmt.Fprintf(os.Stderr, "daemon: listen %s: %v\n", sockPath, err)
os.Exit(1)
}
defer func() {
ln.Close()
os.Remove(sockPath)
}()
log.Printf("[daemon] listening on %s", sockPath)
fmt.Printf("waiter daemon started\n socket: %s\n press Ctrl+C to stop\n", sockPath)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Printf("[daemon] shutting down")
close(dh.stopCh)
dh.closeHome()
ln.Close()
}()
// 单客户端模式:串行处理(同一时刻只有一个 TUI 连接)
// 这与 homed CLI 插件的行为一致——一个连接对应一个活跃会话。
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-dh.stopCh:
log.Printf("[daemon] stopped")
return
default:
log.Printf("[daemon] accept error: %v", err)
continue
}
}
dh.handleClient(conn)
}
}
// ===== Socket 工具 =====
func daemonSocketPath() string {
home, _ := os.UserHomeDir()
if home == "" {
home = "/tmp"
}
return filepath.Join(home, ".homeagent", daemonSocketName)
}
func daemonIsRunning() bool {
sock := daemonSocketPath()
c, err := net.DialTimeout("unix", sock, 500*time.Millisecond)
if err != nil {
return false
}
c.Close()
return true
}
func startDaemonDeviceBridge(cfg *Config) {
dg := cfg.DeviceGateway
dt := cfg.DeviceToken
if dg == "" || dt == "" {
return
}
// 设备桥重连循环WS 断开时自动重连
go runDeviceBridgeLoop(dg, dt)
}
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
func runDeviceBridgeLoop(gateway, token string) {
for {
bridge, err := connectDeviceBridge(gateway, token)
if err != nil {
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
time.Sleep(5 * time.Second)
continue
}
log.Printf("[daemon] device bridge connected, waiting...")
bridge.Wait() // 阻塞直到断开
log.Printf("[daemon] device bridge disconnected, reconnecting in 3s")
time.Sleep(3 * time.Second)
}
}
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "local"
}
deviceID := "waiter-" + sanitizeID(hostname)
caps := []string{
"status", "cmdrun", "deviceinfo", "cmdresult",
"computeruse", "screensee", "clipboardsee", "clipboardsue",
"camerasue", "speakeruse", "screensue",
}
info := map[string]interface{}{
"hostname": hostname,
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"cpus": runtime.NumCPU(),
}
// 确保 gateway URL 格式正确
gw := gateway
if !strings.HasPrefix(gw, "ws://") && !strings.HasPrefix(gw, "wss://") {
gw = "ws://" + gw
}
if !strings.Contains(gw, "/api/v1/device/ws") {
gw = gw + "/api/v1/device/ws"
}
bridge := client.New(gw, token, deviceID, hostname, caps, info)
// 注册命令处理器
cr := client.NewCmdRouter()
cr.Handle("homeagent-", handleHomeagentCmd)
cr.HandleDefault(handleShellCmd)
bridge.OnCmd(func(reqID, command string) {
cr.Dispatch(reqID, command)
})
if err := bridge.Start(); err != nil {
return nil, err
}
// 设置全局变量供 sendBridgeResult 使用
deviceBridge = bridge
deviceBridgeID = deviceID
auth := true // daemon 模式默认授权(配置已指定)
bridge.SetAuthorized(auth)
return bridge, nil
}

View File

@ -126,6 +126,7 @@ func main() {
deviceGateway := flag.String("device", "", "remotedevice 网关地址(如 127.0.0.1:9890启动设备桥")
deviceToken := flag.String("device-token", "", "设备接入 token")
deviceAuthorized := flag.Bool("device-authorized", false, "客户端本地授权(允许远程操控本机;也可在 waiter.yaml 配 device_authorized: true")
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
testCap := flag.String("test-cap", "", "测试本地能力screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue如 --test-cap screensue")
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
flag.Parse()
@ -144,6 +145,12 @@ func main() {
cfg.Socket = discoverSocket("")
}
// Daemon 模式:后台驻留
if *daemonMode {
runDaemon(cfg)
return
}
oneShotMsg := *chat
if oneShotMsg == "" {
oneShotMsg = *say

View File

@ -1,12 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# 本脚本位于 deploy/packaging/,故仓库根在上两级。
#
# v0.7.2 的根目录清理把 package/build.sh 移到 deploy/packaging/build.sh
# (深度 1 → 2 层),但这行的 ".." 没跟着改成 "../..",于是 PROJECT_ROOT
# 变成了 <repo>/deploy产物落进 deploy/build/、GUI 去找 deploy/cmd/gui。
# 跨平台构建从那次起一直是坏的Makefile 的单平台 build 不走这里,所以没暴露)。
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${PROJECT_ROOT}/build"
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.8.0")}"
COMMIT="${COMMIT:-$(git -C "$PROJECT_ROOT" rev-parse --short HEAD 2>/dev/null || echo "unknown")}"
BUILD_TIME="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}"
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}"
TARGET="${1:-native}"
@ -21,9 +27,14 @@ COMPONENT="${2:-all}"
case "$TARGET" in
native) GOOS="" GOARCH="" ;;
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
# arm64 刻意不设 CXX设了会让 Go 用 aarch64 的 g++ 去链接,
# 而它对 host 产生的 .o 报 "file format not recognized"。
# gojieba 的 C++ 源仍由 CC 对应的 gcc 驱动编译gcc 能编 C++)。
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;;
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
# Windows 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++
# 而宿主 g++ 不认 mingw 的 -mthreads报 unrecognized command-line option。
windows/amd64) GOOS=windows GOARCH=amd64 CC="${CC:-x86_64-w64-mingw32-gcc}" CXX="${CXX:-x86_64-w64-mingw32-g++}" ;;
all)
"$0" linux/amd64 "$COMPONENT"
@ -36,7 +47,7 @@ case "$TARGET" in
*)
echo "Unknown target: $TARGET"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]"
echo " [all|homed|waiter|gui]"
echo " [all|homed|waiter|initconfig|gui]"
exit 1
esac
@ -54,6 +65,34 @@ export CGO_ENABLED="${CGO_ENABLED:-1}"
mkdir -p "$BUILD_DIR"
# ---- .syso 隔离 ----
#
# cmd/{homed,waiter}/*.syso 是 Windows 资源对象COFF含图标/版本信息)。
# Go 会把同目录的 .syso 无条件链进任何目标,于是交叉编译到非 Windows 平台时:
# - linux/arm64、darwin/arm64 报 "unknown ARM64 relocation type 3"
# - 其他架构报 "file format not recognized"
# package-linux.sh 有 hide_syso(),但直接调本脚本时没有那层保护——
# 这正是 arm64 产物长期缺失的原因(曾被误判为缺 g++ 交叉编译器)。
SYSO_HIDDEN=()
hide_syso_for_target() {
[ "${GOOS:-}" = "windows" ] && return 0
local f
for f in "$PROJECT_ROOT"/cmd/homed/*.syso "$PROJECT_ROOT"/cmd/waiter/*.syso; do
[ -f "$f" ] || continue
mv "$f" "$f.hidden"
SYSO_HIDDEN+=("$f")
done
}
restore_syso_for_target() {
local f
for f in "${SYSO_HIDDEN[@]:-}"; do
[ -n "$f" ] && [ -f "$f.hidden" ] && mv "$f.hidden" "$f"
done
SYSO_HIDDEN=()
}
trap restore_syso_for_target EXIT
hide_syso_for_target
# ---- homed (CGO, sqlite3) ----
build_homed() {
local out="$BUILD_DIR/homed${SUFFIX:+_$SUFFIX}"
@ -67,7 +106,11 @@ build_homed() {
out="${out}.exe"
fi
echo "[BUILD] homed ${plat}$out"
CGO_ENABLED=1 "$GO" build -trimpath -installsuffix dynlink \
# Go 用 CC 驱动 CGO 编译与链接,用 CC 指定的交叉工具链来决定目标架构。
# 必须同时 export CC 给 Go 的 CGO 代码生成器,否则 CGO_ENABLED=1 下的
# 目标文件与 host 的 ld 不兼容(如 arm64 的 .o 给了 x86_64 的 ld
local _cc="${CC:-cc}"
CGO_ENABLED=1 CC="$_cc" "$GO" build -trimpath -installsuffix dynlink \
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))"
}
@ -84,6 +127,22 @@ build_waiter() {
echo " OK ($(du -h "$out" | cut -f1))"
}
# ---- initconfig (CGO-free 配置初始化器) ----
#
# NSIS 安装包installer.nsi:220 File "..\build\initconfig.exe")与
# package-linux.sh 的 stage_variant 都引用它,但此前 build.sh 从不构建它——
# Windows 安装包构建会直接失败在缺文件上。
build_initconfig() {
local plat="${GOOS:-linux}/${GOARCH:-amd64}"
local out="$BUILD_DIR/initconfig${SUFFIX:+_$SUFFIX}"
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] initconfig ${plat}$out"
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
-ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/
echo " OK ($(du -h "$out" | cut -f1))"
}
# ---- gui (Electron) ----
build_gui() {
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
@ -99,7 +158,10 @@ build_gui() {
(cd "$gui_dir" && npm install --production)
fi
(cd "$gui_dir" && npx electron-builder --config "$gui_dir/package.json" \
# 不传 --configelectron-builder 默认从 package.json 的 "build" 键读配置。
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
(cd "$gui_dir" && npx electron-builder \
--linux --win --mac \
--x64 --arm64 \
-p never \
@ -109,9 +171,10 @@ build_gui() {
# ---- dispatch ----
case "$COMPONENT" in
all) build_homed; build_waiter; build_gui ;;
all) build_homed; build_waiter; build_initconfig; build_gui ;;
homed) build_homed ;;
waiter) build_waiter ;;
initconfig) build_initconfig ;;
gui) build_gui ;;
*)
echo "Unknown component: $COMPONENT"

View File

@ -10,7 +10,12 @@
!define PRODUCT_NAME "HomeAgent"
!define PRODUCT_PUBLISHER "HomeAgent Team"
!define PRODUCT_VERSION "0.8.0"
# 版本号由 makensis -DPRODUCT_VERSION=X.Y.Z 注入;缺省值仅供本地手工构建。
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
# 会显示错误版本DisplayVersion 也取自这个宏)。
!ifndef PRODUCT_VERSION
!define PRODUCT_VERSION "1.0.0"
!endif
!if "${VARIANT}" == "full"
!define PRODUCT_DISPLAY_NAME "HomeAgent 完整版"
@ -38,7 +43,7 @@
!endif
Name "${PRODUCT_DISPLAY_NAME}"
OutFile "..\build\${OUTPUT_FILE}"
OutFile "..\..\build\${OUTPUT_FILE}"
InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}"
InstallDirRegKey HKLM "Software\${PRODUCT_NAME}" ""
RequestExecutionLevel admin
@ -217,17 +222,17 @@ Section "Install" SEC_INSTALL
CreateDirectory "$INSTDIR\data\adapters"
!if "${HAS_CORE}" == "1"
File "..\build\initconfig.exe"
File "..\build\homed.exe"
File "..\..\build\initconfig.exe"
File "..\..\build\homed.exe"
!endif
!if "${HAS_WAITER}" == "1"
File "..\build\waiter.exe"
File "..\..\build\waiter.exe"
!endif
!if "${HAS_GUI}" == "1"
SetOutPath "$INSTDIR\homeagent-gui-win32-x64"
File /r "..\build\homeagent-gui-win32-x64\*.*"
File /r "..\..\build\homeagent-gui-win32-x64\*.*"
SetOutPath "$INSTDIR"
!endif

Some files were not shown because too many files have changed in this diff Show More