mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11144c62b5 | |||
| d05161ac8d | |||
| 7587bd82d8 | |||
| 7b7d405463 | |||
| 5d395116d1 | |||
| fc8f153e34 | |||
| 3374e7dbd9 | |||
| ca6f4c510c | |||
| 88923ed663 | |||
| 631963fdc7 | |||
| 4078f3ac0a | |||
| 0c9a3900b8 | |||
| a35f2126a1 | |||
| 9b26db45bc | |||
| 47052cb115 | |||
| 09298886a2 | |||
| db8534e315 | |||
| fab27a1194 | |||
| 923d5d595f | |||
| 71faf8d9ad | |||
| c0274b71d5 | |||
| 7213edd181 | |||
| 8acd3ce1a8 | |||
| 943eef01cf | |||
| 01909bb914 | |||
| fe1d2672d8 | |||
| 69446a2649 | |||
| e273924511 | |||
| 53e7106985 | |||
| 6ddef5e49f | |||
| 95292aff8e | |||
| 2722d76095 | |||
| 01113664b4 | |||
| b1ec278136 | |||
| d202f2ceec | |||
| 895948b24e | |||
| 698ff7ddd5 | |||
| ccc2ac2d4d | |||
| 831bd2290b | |||
| 1f5af1dccf | |||
| 9de3b365a6 | |||
| ec13eb391a | |||
| e70d2171ee | |||
| 3cce605722 | |||
| 0227fc2d4d | |||
| 91c25fa536 | |||
| bcaa8f3f31 | |||
| 5333a33e20 | |||
| 95b1950bb3 | |||
| 307a6faee7 | |||
| baddaf387e | |||
| 15497ee0a2 | |||
| c151d391ee | |||
| 065732f42e | |||
| a9ad97240b | |||
| acc94723fd | |||
| 49695c38f3 | |||
| bb7e7979ae | |||
| d4f9a12db0 | |||
| b7e47a1b1f | |||
| 9d45cd0277 | |||
| d98bf512e1 | |||
| b37141f3f5 | |||
| 94995eaa64 | |||
| 6afe361804 | |||
| 3a780384d0 | |||
| b792a94b84 | |||
| 29d763e210 | |||
| 37d6796230 | |||
| 0493960985 | |||
| bc32fbbc98 | |||
| f3fa0e8f5b | |||
| 2163a7a092 | |||
| 0faf9fb4e8 | |||
| f89e57a732 | |||
| 37924b295b | |||
| bead5746c3 | |||
| 21db84e8dc | |||
| 0fd4fb1f7a | |||
| 5ea87a498a | |||
| 9eebd96ab7 | |||
| c252915083 | |||
| 36b577bff8 | |||
| 7768168dd2 | |||
| 97111778a9 | |||
| f722498dba | |||
| 27a7a3b439 | |||
| e4d69fa140 | |||
| 0fda210e8b | |||
| cdb2ea2207 | |||
| e8d7bb4c06 | |||
| 75f377fd4d | |||
| 8756f8d77f | |||
| d502fc1bf5 | |||
| 43536ba326 | |||
| c321388a21 | |||
| 1ce3a917a5 | |||
| c0e9dc1818 | |||
| 764939ed90 | |||
| 4cbfdc970c | |||
| 642e1c39b1 | |||
| c4998bb102 | |||
| 0beb389223 | |||
| 9e6627f0c3 | |||
| 3edab0fe68 | |||
| 129a3aeb38 | |||
| dcae21b24c | |||
| 62e1e02036 | |||
| 8a58cde3ee | |||
| e733d05a5e | |||
| 8a9fc05451 | |||
| 31bbde9cdd | |||
| e0514c3692 | |||
| f79e0f82dd | |||
| 131c1ff0e9 | |||
| 414e6a1627 | |||
| 431bb5a0c9 | |||
| 6878f0126d | |||
| 52127a3323 | |||
| 11d9038927 | |||
| a15e7c2dc1 | |||
| 4518c3eb03 | |||
| 44cb7243c5 | |||
| 162f33f81e | |||
| 180b96e21a | |||
| 3fda1db9c0 | |||
| 8910c8c454 | |||
| c83eca5e88 | |||
| 594496a225 | |||
| 56ba1332f3 | |||
| 1d4f2beeea | |||
| 5160d8d4d1 | |||
| e2500035d5 | |||
| 886ba78b11 | |||
| d0997f6279 | |||
| 91c4bc01f1 | |||
| fb2db2c304 | |||
| ba3ab5a7d9 | |||
| a4ebcb6e96 | |||
| f168862eaa | |||
| 44cc824b28 | |||
| 937359b4df | |||
| 55dc6545f5 | |||
| fddefc78a1 | |||
| 3f431063e2 | |||
| 18d7ad3a36 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,6 +31,7 @@ cmd/gui/dist/
|
||||
third_party/homeagent-sdk/bin/
|
||||
third_party/homeagent-sdk/tools/
|
||||
third_party/homeagent-sdk/package/
|
||||
third_party/homeagent-sdk/scripts/
|
||||
third_party/homeagent-sdk/.gitignore
|
||||
third_party/homeagent-sdk/README*
|
||||
third_party/homeagent-sdk/example/
|
||||
|
||||
46
.golangci.yml
Normal file
46
.golangci.yml
Normal file
@ -0,0 +1,46 @@
|
||||
# golangci-lint 配置 —— 「超大函数/超大文件」治理的防复发闸门。
|
||||
#
|
||||
# 背景:main 上曾有 4 个 ≥300 行函数、12 个 ≥200 行函数(见审查报告)。
|
||||
# 没有复杂度 linter 是它们能长期存活的直接原因。本配置先以 **warn-only**
|
||||
# 起步:`issues.exit-code: 0`,只产出清单、不阻断构建。等历史债降到可接受
|
||||
# 水位后,再把 exit-code 改成 1 收成硬门禁。
|
||||
#
|
||||
# 运行:make lint-full(需先 `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`)
|
||||
# 注意:这是 golangci-lint v1 的配置格式(v2 的 `linters.default` 写法不同)。
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- funlen # 函数长度
|
||||
- gocyclo # 圈复杂度
|
||||
- lll # 行宽
|
||||
- dupl # 重复代码
|
||||
- govet # 与 make lint 对齐的基线
|
||||
|
||||
linters-settings:
|
||||
funlen:
|
||||
lines: 150
|
||||
statements: 100
|
||||
gocyclo:
|
||||
min-complexity: 30
|
||||
lll:
|
||||
line-length: 140
|
||||
dupl:
|
||||
threshold: 200
|
||||
|
||||
issues:
|
||||
# 起步阶段不阻断(warn-only)。收紧后改为 1。
|
||||
exit-code: 0
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
# 测试与生成/夹具代码不受长度类规则约束。
|
||||
- path: _test\.go
|
||||
linters: [funlen, dupl, gocyclo]
|
||||
- path: internal/plugin/proc/testdata
|
||||
linters: [funlen, dupl]
|
||||
- path: third_party/
|
||||
linters: [funlen, dupl, gocyclo, lll]
|
||||
44
Makefile
44
Makefile
@ -1,4 +1,17 @@
|
||||
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt
|
||||
.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions
|
||||
|
||||
# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与
|
||||
# deploy/packaging/build.sh 保持一致)。
|
||||
#
|
||||
# 曾经这里是空 tags,实测的后果(2026-09-15 热部署):`make build` 产出的
|
||||
# homed 只有 33MB,而 onnxruntime 版是 84MB;启动日志里
|
||||
# 「multimodal space active: provider=chineseclip」整行消失,少加载一个插件,
|
||||
# 静态词向量也退化成 fallback——而打包脚本会直接**拒收**这种二进制
|
||||
# (package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」
|
||||
# 与「发行构建」不是同一个东西,部署时无从察觉。
|
||||
# 需要极简构建时显式 HOMED_TAGS= 关掉。
|
||||
HOMED_TAGS ?= onnxruntime
|
||||
TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),)
|
||||
|
||||
BINARY=homed
|
||||
CLI_BINARY=waiter
|
||||
@ -17,13 +30,15 @@ all: build build-cli
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||
CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')"
|
||||
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \
|
||||
|| echo "WARN: 本次构建不含 onnxruntime,本地向量空间不可用(HOMED_TAGS= 显式关掉时才符合预期)"
|
||||
|
||||
build-cli:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
|
||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))"
|
||||
|
||||
build-gui:
|
||||
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
|
||||
@ -61,3 +76,22 @@ fmt:
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
|
||||
# 客户端版本与内核版本同步(唯一事实源 internal/meta.Version)。
|
||||
# GUI/鸿蒙各有自版本字段,手工改必漂——用脚本拉齐,check 版给门禁用。
|
||||
sync-client-versions:
|
||||
@bash deploy/scripts/sync-client-versions.sh
|
||||
|
||||
check-client-versions:
|
||||
@bash deploy/scripts/sync-client-versions.sh --check
|
||||
|
||||
# lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。
|
||||
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
|
||||
.PHONY: lint-full
|
||||
lint-full:
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run; \
|
||||
else \
|
||||
echo "golangci-lint 未安装,跳过(阈值见 .golangci.yml)"; \
|
||||
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
|
||||
fi
|
||||
|
||||
66
README.md
66
README.md
@ -26,6 +26,16 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
||||
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
||||
|
||||
**输入调度:两类别 + 四级中断** — 输入不直接进 LLM,先进调度器。
|
||||
排队(待办工作)与中断(按"有多不能等"分 L1~L4)两类;高级可抢占低级并保存现场
|
||||
(中断栈),同级不抢占。L4 只归内核与内核级插件(如 WebUI 终止按钮)。
|
||||
见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「输入调度器与中断机制」。
|
||||
|
||||
**驻留式子 agent** — 内核可派驻轻量内核的子 agent(自己的调度器与 temp 图记忆,
|
||||
共享通道登记表),把长任务/积压交出去并行做。主 agent 忙久了,内核还会把排队输入
|
||||
交给临时**分诊助手**:简单的直接处理,需要主 agent 的立刻回「忙碌中,请稍候」,
|
||||
用户不再干等。见 [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)。
|
||||
|
||||
## 架构图
|
||||
|
||||
### 一、消息处理时序
|
||||
@ -34,6 +44,7 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
sequenceDiagram
|
||||
participant U as 用户/插件
|
||||
participant IO as IOManager
|
||||
participant SCH as 输入调度器
|
||||
participant EV as eventLoop
|
||||
participant CTX as RelevanceContext
|
||||
participant LLM as LLM+工具循环
|
||||
@ -41,7 +52,14 @@ sequenceDiagram
|
||||
participant MEM as 三层记忆
|
||||
|
||||
U->>IO: InjectInput(type, payload)
|
||||
IO->>EV: inputCh
|
||||
IO->>SCH: inputCh
|
||||
rect lavender
|
||||
Note over SCH: 两类别 + 四级中断(L1~L4)
|
||||
SCH->>SCH: 同级不抢占 → 入就绪队列/中断队列
|
||||
SCH->>SCH: 更高级 → 抢占(现场压中断栈,稍后可恢复)
|
||||
SCH->>SCH: 转投(主 agent 忙久了 → 交给临时分诊助手)
|
||||
end
|
||||
SCH->>EV: 选中一个任务开始跑
|
||||
rect lavender
|
||||
Note over EV: processTextInput
|
||||
EV->>ST: StageOnInput 插件可改写/短路
|
||||
@ -55,7 +73,7 @@ sequenceDiagram
|
||||
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
|
||||
EV->>ST: StagePreAction 插件可预拦截
|
||||
loop 工具循环
|
||||
LLM->>LLM: drainInterrupts
|
||||
LLM->>LLM: 安全点:中断求值/让位
|
||||
LLM->>LLM: LLM Chat
|
||||
LLM->>ST: StagePostAction 插件可修改/短路
|
||||
alt 无tool call
|
||||
@ -110,7 +128,7 @@ flowchart TB
|
||||
end
|
||||
subgraph D[② Document 文件记忆]
|
||||
DS[DocStore JSON+TF-IDF]
|
||||
Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP
|
||||
Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP
|
||||
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
|
||||
Q2 -->|原始时间戳写入上下文| RC
|
||||
CD[FindColdDocs 72h] -->|docToTriples| G
|
||||
@ -180,27 +198,44 @@ API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQ
|
||||
cmd/homed/ 守护进程入口,组装所有子系统
|
||||
cmd/waiter/ CLI 客户端(Unix socket)
|
||||
internal/
|
||||
├── agent/core/ Agent 核心:事件循环、LLM 工具循环、7 阶段管道
|
||||
├── agent/api/ LLM Provider + 8 个 Lua 适配器
|
||||
├── agent/core/ Agent 核心:输入调度器(两类别+四级中断)、事件循环、LLM 工具循环、7 阶段管道、驻留子
|
||||
├── agent/api/ LLM Provider(Lua 适配层:provider.go 调 vm)
|
||||
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
||||
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
||||
├── plugins/ 内置 11 个插件(webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||
├── plugins/ 内置 18 个插件(webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data 等)
|
||||
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
└── internal/lua/adapters/ 10 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.3.x 线**(v1.3.1–v1.3.12,最新已发布)—— **驻留式子 agent** + **输入调度器重做**。
|
||||
|
||||
- **驻留式子 agent**:内核可派驻轻量内核的子 agent(自己的调度器、自己的 temp 图记忆、
|
||||
共享通道登记表)。父经 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy)
|
||||
派活与收活;inputch 可划给子,输入**在进内核之前**就已路由到子。
|
||||
- **输出通道可寻址到具体 agent**:`AllowedOutputs` 授权集合(三处过滤点一致),
|
||||
父/子之间可互相投递;设备能力也 outputch 化(每设备一个 `device/<id>` 通道)。
|
||||
- **输入调度器**:排队/中断两类别 + 四级中断(L1~L4)+ 抢占/挂起/恢复/中断栈;
|
||||
同级不抢占、有饥饿防护与抢占冷却;L4 只归内核与内核级插件(WebUI 终止按钮)。
|
||||
- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口,
|
||||
主库以 query_only 受限句柄打开,写走自己的 temp 实例)。
|
||||
- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** ——
|
||||
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。
|
||||
- 修掉一批真实缺陷:销毁驻留子时入站 inputch(`child/<id>`)注册残留、
|
||||
子的轮次永远显示 0(`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、
|
||||
设备心跳 pong 忘了 Flush(每 60 秒掉线)、Lua 插件桥与 SDK 1.3.0 对齐。
|
||||
|
||||
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
||||
|
||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
||||
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
||||
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB);
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0;实测加载峰值 1.59GB、静置回收后稳态约 0.89GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
@ -221,6 +256,14 @@ internal/
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
>
|
||||
> 另有**两项性能断言的量纲需要更正**(2026-09-20 实测):
|
||||
> 「崩溃到恢复 <1s」不成立 —— 崩溃后是**线性退避重启**,即 1s / 2s / 3s(`procRestartBackoff=1s × 第 n 次`),
|
||||
> 首次重启就要等 1s。且 5 分钟窗口内第 **4** 次崩溃即停止自动重启待人工介入(`procMaxRestarts=3`,判定为 `n > 3`)。
|
||||
> 该断言写下时(v1.0.0)退避值已是 1s,故从未成立。
|
||||
> 「RPC 往返 p50 24.1µs」与当前实测同量级但不吻合:本机 `BenchmarkToolInvoke` 实测
|
||||
> inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。
|
||||
> 保留原文不修改,以免伪造历史。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
@ -265,7 +308,12 @@ make test # go test ./...
|
||||
make install # 安装到系统
|
||||
```
|
||||
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux。
|
||||
|
||||
> `homed` 需 Linux(依赖 fd 继承与共享内存段的段内偏移解引用,见
|
||||
> `cmd/homed/platform_windows.go`);Windows 上只构建 `waiter.exe`,
|
||||
> `homed` 跑在 WSL2 里(见「下载」)。macOS 可构建 `waiter`/`initconfig`,
|
||||
> `homed` 需在原生 macOS 构建。
|
||||
|
||||
## 许可
|
||||
|
||||
|
||||
78
README_EN.md
78
README_EN.md
@ -32,6 +32,19 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
|
||||
- **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions
|
||||
- **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations
|
||||
|
||||
**Input Scheduling: 2 classes + 4 interrupt levels** — Input does not go straight to the LLM;
|
||||
it first enters the scheduler. Two classes (queued = pending work, interrupt = ranked L1–L4 by
|
||||
"how urgent") with preemption and frame saving (interrupt stack); same level never preempts
|
||||
same level. L4 belongs only to the kernel and kernel-level plugins (e.g. the WebUI stop button).
|
||||
See "Input Scheduler & Interrupt Mechanism" in [`assets/docs/en/ARCHITECTURE.md`](assets/docs/en/ARCHITECTURE.md).
|
||||
|
||||
**Resident sub-agents** — The kernel can station lightweight-kernel child agents (their own
|
||||
scheduler and temp graph memory, sharing the channel registry) to run long or backlogged work
|
||||
in parallel. When the main agent stays busy, the kernel hands queued input to a temporary
|
||||
**triage assistant**: simple items are handled directly, items needing the main agent get an
|
||||
immediate "busy, please wait" — users no longer wait in silence.
|
||||
See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md).
|
||||
|
||||
## Architecture Diagrams
|
||||
|
||||
### 1. Message Processing Sequence
|
||||
@ -40,6 +53,7 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
|
||||
sequenceDiagram
|
||||
participant U as User/Plugin
|
||||
participant IO as IOManager
|
||||
participant SCH as Input Scheduler
|
||||
participant EV as eventLoop
|
||||
participant CTX as RelevanceContext
|
||||
participant LLM as LLM+Tool Loop
|
||||
@ -47,7 +61,14 @@ sequenceDiagram
|
||||
participant MEM as Three-Layer Memory
|
||||
|
||||
U->>IO: InjectInput(type, payload)
|
||||
IO->>EV: inputCh
|
||||
IO->>SCH: inputCh
|
||||
rect lavender
|
||||
Note over SCH: 2 task classes + 4 interrupt levels (L1-L4)
|
||||
SCH->>SCH: same level never preempts -> ready/interrupt queue
|
||||
SCH->>SCH: higher level -> preempt (frame pushed to interrupt stack)
|
||||
SCH->>SCH: offload (main agent busy too long -> temporary triage assistant)
|
||||
end
|
||||
SCH->>EV: pick one task and run it
|
||||
rect lavender
|
||||
Note over EV: processTextInput
|
||||
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
|
||||
@ -61,7 +82,7 @@ sequenceDiagram
|
||||
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
|
||||
EV->>ST: StagePreAction Plugin can pre-intercept
|
||||
loop Tool loop
|
||||
LLM->>LLM: drainInterrupts
|
||||
LLM->>LLM: safe point: interrupt eval / yield
|
||||
LLM->>LLM: LLM Chat
|
||||
LLM->>ST: StagePostAction Plugin can modify/short-circuit
|
||||
alt No tool call
|
||||
@ -169,28 +190,53 @@ API keys are configured via WebUI `http://localhost:8080` settings page, persist
|
||||
cmd/homed/ Daemon entry, assembles all subsystems
|
||||
cmd/waiter/ CLI client (Unix socket)
|
||||
internal/
|
||||
├── agent/core/ Agent core: event loop, LLM tool loop, 7-stage pipeline
|
||||
├── agent/api/ LLM Provider + 8 Lua adapters
|
||||
├── agent/core/ Agent core: input scheduler (2 classes + 4 levels), event loop, LLM tool loop, 7-stage pipeline, residents
|
||||
├── agent/api/ LLM Provider (Lua adapter layer: provider.go drives the vm)
|
||||
├── 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 + 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)
|
||||
├── plugins/ 18 built-in plugins (webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data, ...)
|
||||
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
└── internal/lua/adapters/ 10 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.3.x line** (v1.3.1–v1.3.12, latest released) — **resident sub-agents** + **input scheduler rework**.
|
||||
|
||||
- **Resident sub-agents**: the kernel can station lightweight-kernel child agents (their own
|
||||
scheduler, their own temp graph memory, sharing the channel registry). The parent dispatches
|
||||
and collects work via `resident_agents` (list/create/send/inspect/compress/reclaim/destroy).
|
||||
An inputch can be assigned to a child, so input is routed to it **before entering the kernel**.
|
||||
- **Output channels addressable to a specific agent**: the `AllowedOutputs` grant set
|
||||
(consistent across all three filter points) lets parent/child deliver to each other;
|
||||
device capabilities became output channels too (one `device/<id>` per device).
|
||||
- **Input scheduler**: two task classes (queued/interrupt) + four interrupt levels (L1–L4)
|
||||
+ preempt/suspend/resume/interrupt-stack; same level never preempts same level, with a
|
||||
starvation guard and preemption cooldown. L4 belongs only to the kernel and kernel-level
|
||||
plugins (e.g. the WebUI stop button).
|
||||
- **Lightweight kernel profile**: a child's memory surface narrows to "conventional context
|
||||
+ graph memory" (narrow interface; the main graph opens as a query_only handle, writes go
|
||||
to its own temp instance).
|
||||
- **Backlog timely feedback** (later in the line): when the main agent is busy for a long time,
|
||||
the kernel hands queued input to a temporary **triage assistant** — simple items are handled
|
||||
directly, items needing the main agent get an immediate "busy, please wait", so users no
|
||||
longer wait 10+ minutes in silence.
|
||||
- Fixed a batch of real defects: inbound inputch (`child/<id>`) registration leak on resident
|
||||
destruction, a child's round count always showing 0 (`info()` never filled it), an empty
|
||||
child-side childIO (output channels not inherited), device heartbeat pong missing Flush
|
||||
(dropping every 60s), and Lua plugin bridge alignment with SDK 1.3.0.
|
||||
|
||||
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
|
||||
|
||||
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
|
||||
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
|
||||
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
|
||||
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0; measured ~1.59GB peak on load, settling to ~0.89GB steady-state);
|
||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
||||
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
|
||||
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||
@ -217,6 +263,17 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
|
||||
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
|
||||
>
|
||||
> **Two performance claims also need correcting** (measured 2026-09-20):
|
||||
> "crash-to-recovery under 1s" does not hold — restarts are **linearly backed off**, i.e.
|
||||
> 1s / 2s / 3s (`procRestartBackoff=1s × nth crash`). Even the *first* restart waits 1s.
|
||||
> And the **4th** crash within a 5-minute window stops automatic restarts pending human
|
||||
> intervention (`procMaxRestarts=3`, tested as `n > 3`).
|
||||
> The backoff was already 1s when this claim was written (v1.0.0), so it never held.
|
||||
> "RPC round-trip p50 24.1µs" is the right order of magnitude but does not match current
|
||||
> measurements: `BenchmarkToolInvoke` on this machine gives inline/small **30.4µs**,
|
||||
> frame/small 51.5µs, inline/large 767µs, frame/large 398µs.
|
||||
> The original text is left unedited rather than rewritten, so the history isn't falsified.
|
||||
|
||||
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
|
||||
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||
@ -279,7 +336,12 @@ make test # go test ./...
|
||||
make install # Install to system
|
||||
```
|
||||
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux.
|
||||
|
||||
> `homed` requires Linux (it relies on fd inheritance and intra-segment offset
|
||||
dereferencing of the shared memory region; see `cmd/homed/platform_windows.go`).
|
||||
On Windows only `waiter.exe` is built and `homed` runs under WSL2 (see Downloads).
|
||||
macOS can build `waiter`/`initconfig`; `homed` must be built on native macOS.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@ -455,7 +455,74 @@ Extended fields:
|
||||
- Relation extension: Confidence
|
||||
|
||||
|
||||
## Interrupt Mechanism
|
||||
## Input Scheduler & Interrupt Mechanism
|
||||
|
||||
Inputs do not go straight to the LLM — they first enter the **input scheduler**
|
||||
(`internal/agent/core/scheduler.go`). Full design:
|
||||
[`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md).
|
||||
|
||||
### Two task classes
|
||||
|
||||
| Class | Level | Meaning |
|
||||
|-------|-------|---------|
|
||||
| `TaskQueued` | none (always 0) | Pending work. Any interrupt (≥ L1) preempts it |
|
||||
| `TaskInterrupt` | L1–L4 | "How urgent is this", declared by the source via `InjectOptions.Priority` |
|
||||
|
||||
### Four interrupt levels
|
||||
|
||||
| Level | Meaning | Typical source |
|
||||
|-------|---------|----------------|
|
||||
| L1 Background | Fully deferrable | QQ/WeChat messages, bulk notifications |
|
||||
| L2 Message | General notice | Plugin hints that should be seen soon but aren't urgent |
|
||||
| L3 Interactive | Needs timely handling | Timer expiry, terminal output, resident-agent reports |
|
||||
| L4 Critical | **Kernel-exclusive** | panic, kernel events, kernel-level plugin stop button |
|
||||
|
||||
When no level is declared it defaults to **L1** — "explicit is a privilege", so a new
|
||||
plugin never gets preemption rights by accident. L4 declared by an external plugin is
|
||||
**clamped to L3** (`clampPluginLevel`).
|
||||
|
||||
### Preemption and suspension
|
||||
|
||||
- **Same level never preempts same level** (`canPreempt` requires strictly greater) —
|
||||
this is why messages normally wait for the running task to finish.
|
||||
- A preempted task is pushed onto the **interrupt stack** (LIFO) with its frame saved,
|
||||
and resumed later; the stack is never re-sorted by priority.
|
||||
- **Starvation guard**: preemption count raises the effective level
|
||||
(`effectiveLevel = Level + min(PreemptCount, 2)`, capped at L4).
|
||||
- **Preemption cooldown**: a just-preempted task cannot be preempted again for
|
||||
`preemptCooldown` (2s), so a high-priority stream cannot interrupt the same task forever.
|
||||
- The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).
|
||||
|
||||
### Stop (user presses stop / `/stop`)
|
||||
|
||||
Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference;
|
||||
② short-circuit the x messages **already queued at the moment of stop** during their
|
||||
pre-action phase (`cancelBudget` snapshot), instead of running them as new input.
|
||||
Inputs arriving **after** the stop are unaffected.
|
||||
|
||||
`PendingInputs()` must include the segment still sitting in `io.inputCh` (not yet moved
|
||||
into the queue by `pumpInbox`) — during a stop the scheduler is usually busy running a
|
||||
task, and counting only `sched.queue` yields 0.
|
||||
|
||||
### Resident sub-agents and timely feedback
|
||||
|
||||
Design: [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md).
|
||||
|
||||
- A **resident** is an independent lightweight-kernel agent: its own scheduler, its own
|
||||
temp graph memory, sharing the channel registry.
|
||||
- Parent→child control plane: `resident_agents`
|
||||
(list / create / send / inspect / compress / reclaim / destroy).
|
||||
- **Backlog feedback**: when the main agent is busy for a long time (default > 5m,
|
||||
configurable), the kernel hands queued inputs to a temporary **triage assistant**
|
||||
(`offload_*` config): simple ones are handled directly, ones needing the main agent
|
||||
get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
|
||||
- The triage assistant gets **no inputch** (it receives no plugin user input) and
|
||||
**all output channels** (results must reach the original channel).
|
||||
- On reclaim/destroy, its **residual tasks are decided explicitly by the parent**:
|
||||
`residual=keep` (returned to the parent queue, default) or `drop` (explicitly
|
||||
discarded with a per-item log entry).
|
||||
|
||||
### Legacy three-path view (still present, now a layer beneath the scheduler)
|
||||
|
||||
```
|
||||
interceptLoop (goroutine)
|
||||
@ -465,15 +532,28 @@ interceptLoop (goroutine)
|
||||
└── (c) InjectInput() → Trigger new processing when idle
|
||||
```
|
||||
|
||||
Three delivery paths:
|
||||
Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
|
||||
|
||||
| Path | Effect | Timing |
|
||||
|------|--------|--------|
|
||||
| cancelLLM | Cancel current HTTP request | On context.Canceled |
|
||||
| interceptCh | Insert `[interrupt message]` in process() | Before each LLM call |
|
||||
| InjectInput | Trigger new processing when eventLoop is idle | No ongoing request |
|
||||
## Context Budget
|
||||
|
||||
Code: `internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
||||
|
||||
```
|
||||
maxCtx = provider.MaxContextTokens() // declared window (per-source context_window wins)
|
||||
targetUsage = min(maxCtx × 0.8, 600000) // working band, capped at 600K
|
||||
├── memory recall budget = (targetUsage - fixed) / 3
|
||||
└── context events budget = remaining 2/3
|
||||
```
|
||||
|
||||
★ **Window ≠ working band**: a source's real window may reach 1M, but near-full windows
|
||||
lose attention and cost/latency rise linearly, so `maxTargetTokens=600000` caps the
|
||||
working band separately. If the model name (e.g. `AUTO`) yields no window,
|
||||
`ModelContextWindow` **logs a warning** and falls back conservatively; operators should
|
||||
declare `core.llm.sources.<name>.context_window` explicitly.
|
||||
|
||||
**Budgets are ceilings, not fill targets**: memory is recall-ranked (it stops when nothing
|
||||
is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K
|
||||
budget, actual injection was still a few hundred characters.
|
||||
|
||||
|
||||
## Configuration System
|
||||
|
||||
@ -599,23 +599,26 @@ 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 (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.
|
||||
The `sdk.*` API of Lua plugins is aligned with external plugins (toolchain-built `plugin.bin` subprocesses) up to **SDK 1.3.0** (requires kernel **1.4.0+**, also backfilled by the Lua-alignment patch `v1.3.11`): 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.
|
||||
|
||||
> Historical note: the 1.1–1.3 media / inject-flags / priority capabilities were long available only on the Go side and were silently missing on the Lua side. They are now fully aligned, guarded by the contract test in `internal/plugin/lua_surface_test.go` (every function promised by the mock has a runtime binding).
|
||||
|
||||
**Registration**
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `sdk.log(level, msg)` | Log output |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) |
|
||||
| `sdk.register_api(name)` | Register API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | Register input channel; `def` as above |
|
||||
| `sdk.unregister_output_channel(name)` | Unregister an output channel (for resource-bound channels, e.g. remote devices); returns `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash |
|
||||
|
||||
**Stage hook context**
|
||||
|
||||
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 handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `reasoning_content`, `final_text`, `no_memory`, `context_msgs`, `token_usage`, `memory`, `extra`, `errors`, `response` (when responded), `tool_calls`, `tool_results`.
|
||||
|
||||
**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):
|
||||
|
||||
@ -644,20 +647,37 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
|
||||
| `sdk.inject_text(source, channel, text)` | Deliver text message |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
|
||||
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | Delivery with flags; `opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
|
||||
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Unavailable in Lua**: always returns `(nil, err)`. It waits for this turn's reply while a Lua callback holds the plugin lock, so it would self-deadlock. Use a Go plugin for synchronous waits, or the async injectors below |
|
||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same (unavailable) |
|
||||
| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks |
|
||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
|
||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Unavailable in Lua** (same as `inject_input_sync`) |
|
||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media |
|
||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
|
||||
| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) |
|
||||
|
||||
Each `blocks` item: `{ type="text", text="..." }`, `{ type="image_url", image_url={ url="...", detail="high" } }`, or `{ type="audio_url", audio_url={ url="..." } }`. An absent `opts` is the zero value (recorded in memory + no pruning), equivalent to the three-argument form.
|
||||
|
||||
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
|
||||
|
||||
| Sub-table | Functions |
|
||||
|-----------|-----------|
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})` (triple supports `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`), `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` |
|
||||
| `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` |
|
||||
| `sdk.events.*` | `subscribe(event_type, handler)` → returns an unsubscribe function; handler receives `{type,source,timestamp,payload}` |
|
||||
| `sdk.plugin_mgr.*` | `reload_one(name)`, `list_loaded()`, `is_disabled(name)` |
|
||||
| `sdk.json.*` | `encode(val)`, `decode(str)` |
|
||||
| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` |
|
||||
|
||||
Each `attachments` item: `{ digest=, mime=, name=, data=<base64> }`; with `data` it is new content (stored in the content-addressed store), with only `digest` it references existing content.
|
||||
|
||||
> The `sdk.events.subscribe` callback runs on the kernel's event-publishing goroutine, and Lua is single-state + mutex-guarded — **do only lightweight forwarding inside the callback; never block**, or every call of this plugin will stall.
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
@ -784,7 +804,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
||||
|---------|------|----------|
|
||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 20 tools, full input/output channel wiring |
|
||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
|
||||
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
|
||||
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
|
||||
@ -797,6 +817,12 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
|
||||
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
|
||||
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
|
||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) |
|
||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog publishing and management |
|
||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) |
|
||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) |
|
||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode |
|
||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself |
|
||||
|
||||
### Built-in Plugins
|
||||
|
||||
|
||||
@ -444,7 +444,64 @@ type Plugin interface {
|
||||
- Relation 扩展:Confidence
|
||||
|
||||
|
||||
## 中断机制
|
||||
## 输入调度器与中断机制
|
||||
|
||||
输入不直接进 LLM —— 它们先进**输入调度器**(`internal/agent/core/scheduler.go`)。
|
||||
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
|
||||
|
||||
### 两类任务
|
||||
|
||||
| 类别 | 级别 | 语义 |
|
||||
|------|------|------|
|
||||
| `TaskQueued` 排队输入 | 无级别(恒 0) | 待办工作。任何中断(≥ L1)都能抢它 |
|
||||
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
|
||||
|
||||
### 四级中断
|
||||
|
||||
| 级别 | 含义 | 典型来源 |
|
||||
|------|------|----------|
|
||||
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
|
||||
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
|
||||
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
|
||||
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
|
||||
|
||||
未声明级别时取 **L1**(`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
|
||||
外部插件声明 L4 会被**夹到 L3**(`clampPluginLevel`)。
|
||||
|
||||
### 抢占与挂起
|
||||
|
||||
- **同级不能抢占同级**(`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
|
||||
- 被抢占的任务压入**中断栈**(LIFO),用 `suspendStack` 保存现场,稍后恢复;
|
||||
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
|
||||
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`,
|
||||
封顶 L4),确保低级别流不会被困。
|
||||
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`(2s)内不再被抢,
|
||||
避免高优先级流把同一个任务反复打断到永不完结。
|
||||
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4,最多挂起 4 帧)。
|
||||
|
||||
### 停止(用户按停止按钮 / `/stop`)
|
||||
|
||||
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
|
||||
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
|
||||
当新输入再跑一遍。停止之后**新到**的输入不受影响。
|
||||
|
||||
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
|
||||
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
|
||||
|
||||
### 驻留式子 agent 与及时反馈
|
||||
|
||||
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
|
||||
|
||||
- **驻留子**是轻量内核的独立 agent:自己的调度器、自己的 temp 图记忆、共享的通道登记表。
|
||||
- 父对子的控制面:`resident_agents`(list / create / send / inspect / compress / reclaim / destroy)。
|
||||
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m,可配),内核把排队输入交给
|
||||
一个临时**分诊助手**(`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
|
||||
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
|
||||
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
|
||||
- 回收/销毁时它手头的**残余任务由父显式决定**:`residual=keep`(转回父队列,默认)
|
||||
或 `drop`(明确丢弃,逐条记日志)。
|
||||
|
||||
### 旧版三路径(仍存在,但已是调度器之下的一层)
|
||||
|
||||
```
|
||||
interceptLoop (goroutine)
|
||||
@ -454,15 +511,26 @@ interceptLoop (goroutine)
|
||||
└── (c) InjectInput() → 空闲时触发新处理
|
||||
```
|
||||
|
||||
三种投递路径:
|
||||
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
|
||||
|
||||
| 路径 | 效果 | 时机 |
|
||||
|------|------|------|
|
||||
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
|
||||
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
|
||||
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
|
||||
## 上下文预算
|
||||
|
||||
代码:`internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
||||
|
||||
```
|
||||
maxCtx = provider.MaxContextTokens() // 声明窗口(per-source context_window 优先)
|
||||
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
|
||||
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
|
||||
└── 上下文事件预算 = 其余 2/3
|
||||
```
|
||||
|
||||
★ **窗口 ≠ 工作面**:源的真实窗口可能到 1M,但接近满窗口时注意力涣散、
|
||||
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
|
||||
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
|
||||
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
|
||||
|
||||
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
|
||||
实测:预算 400K 时实际注入仍只有几百字符。
|
||||
|
||||
|
||||
## 配置系统
|
||||
|
||||
@ -592,23 +592,26 @@ lua main.lua
|
||||
|
||||
### Lua SDK API
|
||||
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐至 **SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||
|
||||
> 历史提醒:1.1–1.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧,Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
|
||||
|
||||
**注册类**
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `sdk.log(level, msg)` | 日志输出 |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`cleaner` |
|
||||
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`context_policy`(`"none"`/`"prune"`)、`cleaner` |
|
||||
| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
|
||||
| `sdk.register_api(name)` | 注册 API |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`cleaner` |
|
||||
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`context_policy`、`cleaner` |
|
||||
| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
|
||||
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
|
||||
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
|
||||
|
||||
**阶段钩子上下文**
|
||||
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`response`(已响应时)、`tool_calls`、`tool_results`。
|
||||
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`reasoning_content`、`final_text`、`no_memory`、`context_msgs`、`token_usage`、`memory`、`extra`、`errors`、`response`(已响应时)、`tool_calls`、`tool_results`。
|
||||
|
||||
**Stage 写回**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
|
||||
|
||||
@ -637,20 +640,37 @@ end)
|
||||
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
|
||||
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
||||
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
|
||||
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | 带标志位投递;`opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
|
||||
| `sdk.inject_input_sync(source, channel, text)` | ⚠️ **Lua 中不可用**:恒返回 `(nil, err)`。它要等本轮回复而 Lua 回调持有插件锁,必然自锁。需要同步等待请用 Go 插件,或用下面的异步注入 |
|
||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上(不可用) |
|
||||
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
|
||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Lua 中不可用**(同 `inject_input_sync`) |
|
||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
|
||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
|
||||
|
||||
`blocks` 每项形如:`{ type="text", text="..." }`、`{ type="image_url", image_url={ url="...", detail="high" } }`、`{ type="audio_url", audio_url={ url="..." } }`。`opts` 缺省即零值(记入记忆 + 不裁剪),与三参数版本等价。
|
||||
|
||||
**数据类(与子进程外部插件对齐,均返回 `(result, err)`)**
|
||||
|
||||
| 子表 | 函数 |
|
||||
|------|------|
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` |
|
||||
| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`(triple 支持 `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`)、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` |
|
||||
| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`insert_with_media(doc, attachments)`、`remove(id)`、`stats()` |
|
||||
| `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
|
||||
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
|
||||
| `sdk.llm.*` | `list_sources()`、`set_source(name)`、`current_source()` |
|
||||
| `sdk.social.*`(只读) | `get_person(name)`、`get_network(name, depth)`、`get_trait(name, trait)`、`get_relations(name)`、`list_persons()` |
|
||||
| `sdk.events.*` | `subscribe(event_type, handler)` → 返回取消订阅函数;handler 收到 `{type,source,timestamp,payload}` |
|
||||
| `sdk.plugin_mgr.*` | `reload_one(name)`、`list_loaded()`、`is_disabled(name)` |
|
||||
| `sdk.json.*` | `encode(val)`、`decode(str)` |
|
||||
| `sdk.http.*` | `get(url)`、`post(url, body, content_type)` |
|
||||
|
||||
`attachments` 每项:`{ digest=, mime=, name=, data=<base64> }`;带 `data` 是新内容(落进内容寻址存储),只带 `digest` 是引用已有内容。
|
||||
|
||||
> `sdk.events.subscribe` 的回调在内核事件发布 goroutine 上执行,且 Lua 是单状态 + 互斥锁——**回调内只做轻量转发,不可阻塞**,否则会卡死本插件的全部调用。
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
@ -777,7 +797,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|
||||
|------|------|------|
|
||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,20 个工具,输入/输出通道完整对接 |
|
||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 |
|
||||
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 |
|
||||
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) |
|
||||
@ -790,6 +810,12 @@ pmgr.ReloadPlugins() // 重载所有插件
|
||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
|
||||
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
|
||||
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
|
||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD) |
|
||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
|
||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
|
||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent) |
|
||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序),failback 模式的核心插件 |
|
||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
|
||||
|
||||
### 内置插件
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homeagent-gui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
|
||||
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
|
||||
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
|
||||
|
||||
@ -696,6 +696,10 @@ async function refreshDataOnly() {
|
||||
try {
|
||||
state.kernel = await api("/kernel");
|
||||
} catch (e) {}
|
||||
try {
|
||||
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
|
||||
state.runtime = await api("/runtime");
|
||||
} catch (e) {}
|
||||
try {
|
||||
var s = await api("/settings");
|
||||
state.settings = s.settings || {};
|
||||
@ -775,6 +779,10 @@ async function refreshAll() {
|
||||
try {
|
||||
state.kernel = await api("/kernel");
|
||||
} catch (e) {}
|
||||
try {
|
||||
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
|
||||
state.runtime = await api("/runtime");
|
||||
} catch (e) {}
|
||||
try {
|
||||
var s = await api("/settings");
|
||||
state.settings = s.settings || {};
|
||||
@ -1004,6 +1012,249 @@ function statCard(l, v) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 运行态面板:阶段管道 + 中断队列 =====
|
||||
//
|
||||
// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡,
|
||||
// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。
|
||||
// 数据来自 /api/v1/runtime(KernelStatus 的运行态子集)。
|
||||
var RT_LEVELS = [
|
||||
{ lv: 4, name: "L4", zh: "内核独占", en: "kernel only", cls: "rt-lv-4" },
|
||||
{ lv: 3, name: "L3", zh: "交互", en: "interactive", cls: "rt-lv-3" },
|
||||
{ lv: 2, name: "L2", zh: "消息", en: "message", cls: "rt-lv-2" },
|
||||
{ lv: 1, name: "L1", zh: "后台", en: "background", cls: "rt-lv-1" },
|
||||
];
|
||||
// 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
|
||||
// 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
|
||||
// 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
|
||||
var RT_PIPE_GROUPS = [
|
||||
{ zh: "输入", en: "in", ico: "in" },
|
||||
{ zh: "行动", en: "act", ico: "act" },
|
||||
{ zh: "工具", en: "tool", ico: "tool", loop: true },
|
||||
{ zh: "输出", en: "out", ico: "out" },
|
||||
{ zh: "结束", en: "done", ico: "done" },
|
||||
];
|
||||
// 图标一律内联 SVG(24x24 / currentColor),不用 emoji/符号字符充当图标。
|
||||
var RT_ICO = {
|
||||
in: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M21 12H8"/><path d="M13 6l-6 6 6 6"/></svg>',
|
||||
act: '<svg class="rt-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"/></svg>',
|
||||
tool: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M14.5 6.5a3.8 3.8 0 0 1 5 5L10 21l-5-5z"/><path d="M14.5 6.5 17.5 9.5"/></svg>',
|
||||
out: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg>',
|
||||
done: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
loop: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M21 3.5V9h-5.5"/></svg>',
|
||||
};
|
||||
|
||||
function rtPhaseGroup(phase) {
|
||||
switch (phase) {
|
||||
case "on_input":
|
||||
return 0;
|
||||
case "pre_action":
|
||||
case "post_action":
|
||||
return 1;
|
||||
case "before_toolcall":
|
||||
case "after_toolcall":
|
||||
return 2;
|
||||
case "before_output":
|
||||
return 3;
|
||||
case "after_output":
|
||||
return 4;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function rtShortTool(name) {
|
||||
var n = String(name || "");
|
||||
var i = n.lastIndexOf("__");
|
||||
if (i >= 0) n = n.slice(i + 2);
|
||||
return n.length > 14 ? n.slice(0, 13) + "…" : n;
|
||||
}
|
||||
// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。
|
||||
// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩文字,看上去就是「这块空着」。
|
||||
function rtSlots(depth, slots, cls) {
|
||||
var n = Math.max(5, Math.min(16, slots || 5));
|
||||
var d = depth || 0;
|
||||
var out = '<span class="rt-slots ' + (cls || "") + '">';
|
||||
for (var i = 0; i < n; i++) out += '<i class="' + (i < d ? "on" : "") + '"></i>';
|
||||
// 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去
|
||||
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
|
||||
return out + "</span>";
|
||||
}
|
||||
// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里;
|
||||
// 同一阶段重复的同一条(如同一工具连调 3 次)只累加计数,不刷屏。
|
||||
function rtTrailPush(g, kind, label, short) {
|
||||
if (!state.stageTrail) state.stageTrail = [];
|
||||
var arr = state.stageTrail;
|
||||
var last = arr.length ? arr[arr.length - 1] : null;
|
||||
if (last && last.g === g && last.kind === kind && last.short === short) {
|
||||
last.n = (last.n || 1) + 1;
|
||||
return;
|
||||
}
|
||||
arr.push({ g: g, kind: kind, label: label, short: short, n: 1 });
|
||||
if (arr.length > 24) arr.shift();
|
||||
}
|
||||
|
||||
function renderRuntimePanel() {
|
||||
var title = __("运行态", "Runtime");
|
||||
var rt = state.runtime;
|
||||
// 工具格的滑入动画只在「新到一条工具调用」那一次播放:overview 是整块
|
||||
// innerHTML 重建,节点每次都是新的;若无条件带动画类,任何重渲染都会闪一下。
|
||||
var toolFlash = !!state.toolFlash;
|
||||
state.toolFlash = false;
|
||||
if (!rt) {
|
||||
return (
|
||||
'<div class="card"><h2>' + title + '</h2><p class="rt-empty">' +
|
||||
__("运行态数据不可用", "runtime unavailable") + "</p></div>"
|
||||
);
|
||||
}
|
||||
var sc = rt.scheduler || {};
|
||||
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
|
||||
var pending = sc.pending_interrupts || 0;
|
||||
var ready = sc.ready_queue_depth || 0;
|
||||
var stack = sc.suspend_stack || 0;
|
||||
var maxStack = sc.max_suspend_depth || 4;
|
||||
var residents = rt.residents || [];
|
||||
var byLv = sc.interrupts_by_level || [];
|
||||
var preLv = sc.preempts_by_level || [];
|
||||
var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
|
||||
// 至少 5 格:0 时也有可见形状
|
||||
var qSlots = Math.max(5, Math.min(16, maxQ));
|
||||
|
||||
var html = '<div class="card"><h2>' + title + "</h2>";
|
||||
// 四个数字块(沿用本 app 的 statCard 风格)
|
||||
html +=
|
||||
'<div class="grid-4">' +
|
||||
statCard(__("排队", "Ready"), ready, "") +
|
||||
statCard(__("中断", "Pending"), pending, "") +
|
||||
statCard(__("栈", "Stack"), stack + "/" + maxStack, "") +
|
||||
statCard(__("子代理", "Subagents"), residents.length, "") +
|
||||
"</div>";
|
||||
|
||||
// ---- 阶段管道:五个等大表框 ----
|
||||
var g = rtPhaseGroup(state.pipelinePhase || "");
|
||||
html +=
|
||||
'<div class="rt-section-title">' + __("阶段管道", "Stage pipeline") +
|
||||
(g < 0 ? " " + __("(空闲)", "(idle)") : "") + "</div>";
|
||||
html += '<div class="rt-pipe-row' + (g < 0 ? " rt-pipe-idle" : "") + '">';
|
||||
html += RT_PIPE_GROUPS.map(function (s, i) {
|
||||
var items = (state.stageTrail || []).filter(function (t) {
|
||||
return (t.g | 0) === i;
|
||||
});
|
||||
// 「工具」是循环格:一轮里可能调几十次工具/输出通道,全部追加会把这一格
|
||||
// 撑成长条,反而看不出「现在在调什么」。只留**最新一条**,右侧给本轮累计
|
||||
// 次数(与 WebUI 同一口径,见 internal/plugins/webui/dashboard.js)。
|
||||
var cls = "rt-pipe-events";
|
||||
var body;
|
||||
if (!items.length) {
|
||||
body = '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
|
||||
} else if (s.loop) {
|
||||
cls += " rt-pipe-scroll";
|
||||
var total = 0;
|
||||
for (var k = 0; k < items.length; k++) total += items[k].n || 1;
|
||||
var latest = items[items.length - 1];
|
||||
var lkind = latest.kind || "stage";
|
||||
var lico = lkind === "output" ? RT_ICO.out : lkind === "tool" ? RT_ICO.tool : "";
|
||||
body =
|
||||
'<span class="rt-chip rt-chip-' + lkind + (toolFlash ? " rt-chip-enter" : "") +
|
||||
'" title="' + escHtml(latest.label) + '">' + lico +
|
||||
'<b class="rt-chip-t">' + escHtml(latest.short || latest.label) + "</b>" +
|
||||
(latest.n > 1 ? '<i class="rt-chip-n">x' + latest.n + "</i>" : "") +
|
||||
"</span>" +
|
||||
'<i class="rt-scroll-count" title="' +
|
||||
__("本轮工具调用累计次数", "tool calls this turn") + '">x' + total + "</i>";
|
||||
} else {
|
||||
body = items
|
||||
.map(function (t) {
|
||||
var kind = t.kind || "stage";
|
||||
var ico =
|
||||
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
|
||||
return (
|
||||
'<span class="rt-chip rt-chip-' + kind + '" title="' +
|
||||
escHtml(t.label) + '">' + ico +
|
||||
'<b class="rt-chip-t">' + escHtml(t.short || t.label) + "</b>" +
|
||||
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
|
||||
"</span>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return (
|
||||
'<div class="rt-pipe-cell' + (i === g ? " active" : "") + '">' +
|
||||
'<div class="rt-pipe-head">' + RT_ICO[s.ico] +
|
||||
"<b>" + __(s.zh, s.en) + "</b>" +
|
||||
(s.loop
|
||||
? '<em class="rt-loop" title="' +
|
||||
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") +
|
||||
'">' + RT_ICO.loop + "</em>"
|
||||
: "") +
|
||||
'</div><div class="' + cls + '">' + body + "</div></div>"
|
||||
);
|
||||
}).join("");
|
||||
html += "</div>";
|
||||
|
||||
// ---- 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)----
|
||||
html += '<div class="rt-section-title">' + __("队列", "Queues") + "</div>";
|
||||
html += '<div class="rt-queues">';
|
||||
RT_LEVELS.forEach(function (L) {
|
||||
var depth = q[L.lv] || 0;
|
||||
var reg = byLv[L.lv] || 0;
|
||||
var pre = preLv[L.lv] || 0;
|
||||
var desc = __(L.zh, L.en);
|
||||
html +=
|
||||
'<div class="rt-qcell ' + L.cls + (depth ? " rt-active" : "") +
|
||||
'" title="' + escHtml(desc) + '">' +
|
||||
'<div class="rt-qhead"><b>' + L.name + "</b><span>" + escHtml(desc) + "</span></div>" +
|
||||
'<div class="rt-qnum">' + depth + "</div>" +
|
||||
rtSlots(depth, qSlots, L.cls) +
|
||||
'<div class="rt-qmeta">' + reg + " " + __("登记", "reg") + " · " +
|
||||
pre + " " + __("抢占", "pre") + "</div></div>";
|
||||
});
|
||||
// 排队队列无级别:用虚线框与四级中断区分(另一**类别**,不是另一优先级)
|
||||
html +=
|
||||
'<div class="rt-qcell rt-qcell-queued rt-lv-q' + (ready ? " rt-active" : "") +
|
||||
'" title="' + __("排队(无级别,纯 FIFO)", "queued (no priority, FIFO)") + '">' +
|
||||
'<div class="rt-qhead"><b>' + __("排队", "queued") + "</b><span>FIFO</span></div>" +
|
||||
'<div class="rt-qnum">' + ready + "</div>" +
|
||||
rtSlots(ready, qSlots, "rt-lv-q") +
|
||||
'<div class="rt-qmeta">' + __("无级别", "no priority") + "</div></div>";
|
||||
html += "</div></div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// 开源许可卡:协议标识 + 协议全文 + 源码仓库。
|
||||
// AGPL-3.0 §13 的义务是「向网络使用者提供取得 Corresponding Source 的机会」——
|
||||
// 只给一个仓库链接、不写协议名,使用者看不出这受什么许可约束。
|
||||
function renderLegalCard() {
|
||||
var b = ((state.kernel || {}).build) || {};
|
||||
var src = b.source_url || "";
|
||||
var lic = b.license || "";
|
||||
var licURL = b.license_url || "";
|
||||
if (!lic && !src) return "";
|
||||
function row(key, val) {
|
||||
return (
|
||||
'<div class="kv-row"><span class="key">' + escHtml(key) +
|
||||
'</span><span class="val">' + val + "</span></div>"
|
||||
);
|
||||
}
|
||||
function a(href, text) {
|
||||
return (
|
||||
'<a href="' + escHtml(href) +
|
||||
'" target="_blank" rel="noopener noreferrer">' + escHtml(text) + "</a>"
|
||||
);
|
||||
}
|
||||
var rows = "";
|
||||
if (lic) rows += row(__("许可协议", "License"), licURL ? a(licURL, lic) : escHtml(lic));
|
||||
if (src) rows += row(__("源码仓库", "Source"), a(src, src));
|
||||
// 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。
|
||||
var note =
|
||||
lic && lic.toUpperCase().indexOf("AGPL") >= 0
|
||||
? '<p class="rt-empty">' +
|
||||
__(
|
||||
"网络服务条款(§13):把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径。",
|
||||
"Network clause (section 13): offering a modified version as a network service requires giving users a way to obtain the Corresponding Source.",
|
||||
) +
|
||||
"</p>"
|
||||
: "";
|
||||
return '<div class="card"><h2>' + __("开源许可", "License") + "</h2>" + rows + note + "</div>";
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
var s = state.status || {};
|
||||
var k = state.kernel;
|
||||
@ -1018,7 +1269,25 @@ function renderOverview() {
|
||||
"uptime",
|
||||
) +
|
||||
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
||||
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
|
||||
statCard(
|
||||
__("版本", "Version"),
|
||||
(function () {
|
||||
// 构建身份取自 /kernel 的 build(-ldflags 注入的真实版本/commit)。
|
||||
// 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 ——
|
||||
// 拿不到数据时会向用户展示一个不存在的版本号。
|
||||
var b = (k && k.build) || {};
|
||||
var v = b.version || s.version || "";
|
||||
if (!v) return "-";
|
||||
var sha =
|
||||
b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : "";
|
||||
return (
|
||||
"v" + escHtml(v) +
|
||||
'<div class="stat-sub">' + escHtml(b.kernel_name || "HomeAgent") +
|
||||
(sha ? " · " + escHtml(sha) : "") + "</div>"
|
||||
);
|
||||
})(),
|
||||
"version",
|
||||
) +
|
||||
"</div>";
|
||||
if (k) {
|
||||
html +=
|
||||
@ -1082,6 +1351,7 @@ function renderOverview() {
|
||||
"</span></div>" +
|
||||
"</div></div>";
|
||||
}
|
||||
html += renderRuntimePanel();
|
||||
html +=
|
||||
'<div class="card"><h2>' +
|
||||
__("运行时", "Runtime") +
|
||||
@ -1094,6 +1364,7 @@ function renderOverview() {
|
||||
) +
|
||||
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
||||
"</div></div>";
|
||||
html += renderLegalCard();
|
||||
document.getElementById("view-overview").innerHTML = html;
|
||||
}
|
||||
|
||||
@ -5211,17 +5482,48 @@ async function connectFetchSSE(url) {
|
||||
var phase = p.phase || "";
|
||||
var tool = p.tool || "";
|
||||
if (p.channel !== "_consolidation_") {
|
||||
if (phase === "pre_action")
|
||||
// 阶段轨迹:本轮真实发生过什么,按阶段落到运行态面板的对应框里。
|
||||
// 与 WebUI 同一套 g(阶段组)编号,见 rtPhaseGroup。
|
||||
if (phase === "on_input") {
|
||||
state.stageTrail = [];
|
||||
rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input"));
|
||||
} else if (phase === "pre_action") {
|
||||
rtTrailPush(
|
||||
1,
|
||||
"stage",
|
||||
__("组装上下文并思考", "assemble context and think"),
|
||||
__("思考", "think"),
|
||||
);
|
||||
state.chatStage = __("AI 思考中...", "AI thinking...");
|
||||
else if (phase === "before_toolcall") {
|
||||
} else if (phase === "before_toolcall") {
|
||||
if (tool)
|
||||
rtTrailPush(
|
||||
2,
|
||||
tool.indexOf("output_") === 0 ? "output" : "tool",
|
||||
tool,
|
||||
rtShortTool(tool),
|
||||
);
|
||||
if (tool) state.toolFlash = true;
|
||||
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
|
||||
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
||||
if (!state.pendingTools) state.pendingTools = [];
|
||||
state.pendingTools.push(tool);
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
} else if (phase === "before_output")
|
||||
} else if (phase === "before_output") {
|
||||
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
|
||||
state.chatStage = __("生成回复中...", "Generating response...");
|
||||
} else if (phase === "after_output") {
|
||||
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
|
||||
}
|
||||
state.pipelinePhase = phase;
|
||||
// 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。
|
||||
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
|
||||
state.pipelineTimer = setTimeout(function () {
|
||||
state.pipelinePhase = "";
|
||||
if (state.currentView === "overview") renderOverview();
|
||||
}, 2500);
|
||||
if (state.currentView === "overview") renderOverview();
|
||||
}
|
||||
var badge = document.getElementById("chat-stage");
|
||||
if (badge) {
|
||||
|
||||
@ -2191,3 +2191,284 @@ td .switch {
|
||||
.kv-row .val .switch {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ===== 运行态面板:阶段管道 + 中断队列(与 WebUI 总览同一套设计语言)=====
|
||||
两块都统一成**等大表框**,与 KPI 卡同一种骨架。
|
||||
此前桌面版总览只有四个数字卡:既看不到「这一轮走到哪一步」,
|
||||
也看不到四级中断队列的积压。 */
|
||||
.stat-card .stat-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
margin: 14px 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rt-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
/* 行内小图标:一律 SVG,不使用 emoji/符号字符。 */
|
||||
.rt-ico {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vertical-align: -1.5px;
|
||||
}
|
||||
.rt-pipe-row {
|
||||
display: grid;
|
||||
/* 100px 下限:容器再窄也要保证 5 个框一行,不留「4 个 + 1 个」的孤行 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 2px 0 8px;
|
||||
transition: opacity 0.3s var(--ease-out);
|
||||
}
|
||||
.rt-pipe-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rt-pipe-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 11px 12px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out),
|
||||
background 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-pipe-cell.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.rt-pipe-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-pipe-head > b {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rt-pipe-head .rt-ico {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rt-pipe-cell.active .rt-pipe-head > b,
|
||||
.rt-pipe-cell.active .rt-pipe-head .rt-ico {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rt-pipe-events {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-height: 23px;
|
||||
}
|
||||
/* 「工具」格的滚动视口:固定一行高,只露最新一条(与 WebUI 同一口径)。 */
|
||||
.rt-pipe-scroll {
|
||||
flex-wrap: nowrap;
|
||||
height: 23px;
|
||||
min-height: 23px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
}
|
||||
.rt-pipe-scroll .rt-chip {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.rt-chip-enter {
|
||||
animation: rt-chip-scroll-in 0.3s var(--ease-out);
|
||||
}
|
||||
@keyframes rt-chip-scroll-in {
|
||||
from {
|
||||
transform: translateY(115%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.rt-scroll-count {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-loop {
|
||||
display: inline-flex;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.rt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rt-chip-t {
|
||||
font-weight: inherit;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rt-chip-tool {
|
||||
border-color: rgba(136, 192, 208, 0.42);
|
||||
color: var(--frost-300);
|
||||
}
|
||||
.rt-chip-output {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rt-chip-stage {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.rt-chip-none {
|
||||
opacity: 0.35;
|
||||
border-style: dashed;
|
||||
}
|
||||
.rt-chip-n {
|
||||
font-style: normal;
|
||||
opacity: 0.75;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 队列:5 个等大框(L4/L3/L2/L1 + 排队),一行排开 */
|
||||
.rt-queues {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rt-qcell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 11px 12px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-qhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-qhead > b {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.rt-qhead > span {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rt-qnum {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-qmeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rt-qcell.rt-lv-4 .rt-qhead > b { color: #ff5c7a; }
|
||||
.rt-qcell.rt-lv-3 .rt-qhead > b { color: #ffa657; }
|
||||
.rt-qcell.rt-lv-2 .rt-qhead > b { color: var(--sakura-400); }
|
||||
.rt-qcell.rt-lv-1 .rt-qhead > b { color: var(--frost-300); }
|
||||
.rt-qcell.rt-lv-q .rt-qhead > b { color: #a3be8c; }
|
||||
/* 有积压时整个框描边点亮:一眼看出哪条队列在堵 */
|
||||
.rt-qcell.rt-lv-4.rt-active { border-color: rgba(255, 92, 122, 0.55); }
|
||||
.rt-qcell.rt-lv-3.rt-active { border-color: rgba(255, 166, 87, 0.55); }
|
||||
.rt-qcell.rt-lv-2.rt-active { border-color: var(--sakura-400); }
|
||||
.rt-qcell.rt-lv-1.rt-active { border-color: var(--frost-300); }
|
||||
.rt-qcell.rt-lv-q.rt-active { border-color: #a3be8c; }
|
||||
/* 排队队列无级别:虚线框与四级中断区分(另一**类别**,不是另一优先级) */
|
||||
.rt-qcell.rt-qcell-queued {
|
||||
border-style: dashed;
|
||||
}
|
||||
/* 队列格槽:固定可见的「车位」,占用多少一眼可数。
|
||||
用进度条时队列为 0 宽度就是 0,整行只剩文字,看上去就是「这块空着」。 */
|
||||
.rt-slots {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rt-slots > i {
|
||||
flex: 1 1 0;
|
||||
min-width: 3px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition:
|
||||
background 0.25s var(--ease-out),
|
||||
box-shadow 0.25s var(--ease-out);
|
||||
}
|
||||
.rt-slots.rt-lv-4 > i.on { background: #ff5c7a; box-shadow: 0 0 6px rgba(255, 92, 122, 0.45); }
|
||||
.rt-slots.rt-lv-3 > i.on { background: #ffa657; box-shadow: 0 0 6px rgba(255, 166, 87, 0.4); }
|
||||
.rt-slots.rt-lv-2 > i.on { background: var(--sakura-400); box-shadow: 0 0 6px rgba(255, 127, 172, 0.4); }
|
||||
.rt-slots.rt-lv-1 > i.on { background: var(--frost-300); box-shadow: 0 0 6px rgba(136, 192, 208, 0.4); }
|
||||
.rt-slots.rt-lv-q > i.on { background: #a3be8c; box-shadow: 0 0 6px rgba(163, 190, 140, 0.4); }
|
||||
.rt-slots-more {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
876
cmd/homed/bootstrap.go
Normal file
876
cmd/homed/bootstrap.go
Normal file
@ -0,0 +1,876 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// defaultSystemPrompt 是内置默认人格模板:不含版本号字面量,
|
||||
// 被问版本时以运行时快照为准(历史上写死版本号导致实例自称旧版本)。
|
||||
const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、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 表情。`
|
||||
|
||||
// setupLogging 初始化日志:行号前缀 + 同时输出到控制台与 <data>/log/ 下的本次启动文件。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func setupLogging(dataDir string) string {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
// 文件日志:同时输出到控制台和 data/log/ 目录
|
||||
logDir := filepath.Join(dataDir, "log")
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("[homed] warning: cannot create log dir: %v", err)
|
||||
} else {
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: cannot open log file: %v", err)
|
||||
} else {
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||
log.Printf("[homed] logging to %s", logPath)
|
||||
}
|
||||
}
|
||||
|
||||
return logDir
|
||||
}
|
||||
|
||||
// ensureDataDirs 建好启动期需要的全部目录,返回 agent 的 overlayfs 工作目录。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func ensureDataDirs(dataDir string) string {
|
||||
agentWorkDir := filepath.Join(dataDir, "agentfs")
|
||||
dirs := []string{
|
||||
dataDir,
|
||||
filepath.Join(dataDir, "snapshots"),
|
||||
filepath.Join(dataDir, "plugins"),
|
||||
filepath.Join(dataDir, "changesets"),
|
||||
filepath.Join(dataDir, "memory"),
|
||||
filepath.Join(dataDir, "memory", "raw"),
|
||||
filepath.Join(dataDir, "adapters"),
|
||||
agentWorkDir,
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
log.Fatalf("create dir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
return agentWorkDir
|
||||
}
|
||||
|
||||
// memoryStack 聚合记忆侧组件:图库、索引器、社交图、蒸馏器。
|
||||
type memoryStack struct {
|
||||
db *memory.GraphDB
|
||||
indexer *memory.Indexer
|
||||
social *social.SocialStore
|
||||
distiller *pipeline.Distiller
|
||||
}
|
||||
|
||||
// initMemoryStack 初始化图记忆 / 索引 / 社交图 / 蒸馏管线。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMemoryStack(dataDir string) (*memoryStack, func()) {
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(dataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
|
||||
distiller := pipeline.NewDistiller(memDB, dataDir, pipeline.DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
if memDB != nil {
|
||||
// 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发
|
||||
// defer,而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出,
|
||||
// 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。
|
||||
// 停机由调用点注册的 cleanup 负责(见下方返回值)。
|
||||
distiller.Start()
|
||||
}
|
||||
|
||||
return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller},
|
||||
func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停蒸馏器,再关图库。
|
||||
if memDB != nil {
|
||||
distiller.Stop()
|
||||
}
|
||||
if memDB != nil {
|
||||
memDB.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startMemoryCandidateConsumer 起一个常驻 goroutine:把 eventbus 上的 memory_candidate 事件写进文本记忆并喂给蒸馏管线。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startMemoryCandidateConsumer(ctx context.Context, iom *agentIO.IOManager, textMem *text.Memory, memDB *memory.GraphDB, distiller *pipeline.Distiller) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt, ok := <-iom.OutputChan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if evt.Target == "memory" && evt.Type == "memory_candidate" {
|
||||
source, _ := evt.Payload["source"].(string)
|
||||
input, _ := evt.Payload["input"].(string)
|
||||
response, _ := evt.Payload["response"].(string)
|
||||
toolsUsed, _ := evt.Payload["tools_used"].([]string)
|
||||
toolResults, _ := evt.Payload["tool_results"].([]interface{})
|
||||
agentID, _ := evt.Payload["agent_id"].(string)
|
||||
|
||||
if input != "" && textMem != nil {
|
||||
te := text.Event{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Source: source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
AgentID: agentID,
|
||||
}
|
||||
if err := textMem.Append(te); err != nil {
|
||||
log.Printf("[homed] text memory append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if input != "" && memDB != nil {
|
||||
distiller.Append("agent", "user", input)
|
||||
}
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
|
||||
// 工具输出接入蒸馏管线
|
||||
for _, tr := range toolResults {
|
||||
if trMap, ok := tr.(map[string]interface{}); ok {
|
||||
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
|
||||
distiller.Append("agent", "tool", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// initLLMProviders 按配置注册全部 LLM 源(每个源经 Lua 适配器协议转换),并把各适配器的并发额度汇总回 Lua VM。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initLLMProviders(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string) *agentAPI.ProviderManager {
|
||||
providerMgr := agentAPI.NewProviderManager()
|
||||
adapterConcurrency := map[string]int{}
|
||||
for _, src := range cfg.LLM.Sources {
|
||||
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||||
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
|
||||
continue
|
||||
}
|
||||
key := src.APIKey
|
||||
if key == "" {
|
||||
key = baseAPIKey
|
||||
}
|
||||
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: src.Model,
|
||||
BaseURL: src.BaseURL,
|
||||
APIKey: key,
|
||||
Temperature: cfg.LLM.Temperature,
|
||||
MaxTokens: cfg.LLM.MaxTokens,
|
||||
ContextWindow: src.ContextWindow,
|
||||
MaxConcurrent: src.MaxConcurrent,
|
||||
Priority: src.Priority,
|
||||
Vision: src.Vision,
|
||||
Audio: src.Audio,
|
||||
}, luaVM, src.Name, src.Adapter)
|
||||
providerMgr.Register(src.Name, luaProvider)
|
||||
if src.Adapter != "" {
|
||||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||||
}
|
||||
}
|
||||
luaVM.ConfigureConcurrency(adapterConcurrency)
|
||||
if cfg.LLM.Provider != "" {
|
||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||
}
|
||||
|
||||
return providerMgr
|
||||
}
|
||||
|
||||
// initDocStore 启动文档记忆。flush 的唯一入口是 Stop(),所以关停时必须调用它。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initDocStore(cfg *types.Config) (*document.Store, func()) {
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
|
||||
return docStore, func() { docStore.Stop() }
|
||||
}
|
||||
|
||||
// initMediaStore 按开关启动内容寻址的媒体存储;开不起来只告警(媒体记忆非对话必需品)。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMediaStore(cfgReg *internalConfig.ConfigRegistry, cfg *types.Config) (*media.Store, func()) {
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
ms, err := media.New(mediaDir)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节",
|
||||
st["count"], st["total_bytes"])
|
||||
}
|
||||
}
|
||||
|
||||
return mediaStore, func() {
|
||||
if mediaStore != nil {
|
||||
mediaStore.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initMultimodalSpace 从公共注册表打开多模态向量 provider。返回 (空间, provider 名, 失败原因, cleanup):后两个值只用于状态报告。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initMultimodalSpace(cfgReg *internalConfig.ConfigRegistry) (vector.MultimodalEmbedder, string, string, func()) {
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
var closeAdapted func()
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
info := provider.Info()
|
||||
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
|
||||
shortFP := info.Fingerprint
|
||||
if len(shortFP) > 12 {
|
||||
shortFP = shortFP[:12]
|
||||
}
|
||||
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
|
||||
mmProvider, info.Dimension, shortFP, info.Modalities)
|
||||
}
|
||||
}
|
||||
|
||||
if closeAdapted == nil {
|
||||
closeAdapted = func() {}
|
||||
}
|
||||
return multimodalSpace, mmProviderName, mmErr, closeAdapted
|
||||
}
|
||||
|
||||
// initKnowledgeStore 启动知识库。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initKnowledgeStore(cfg *types.Config) *knowledge.Store {
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
} else {
|
||||
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
|
||||
}
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
// loadPersonality 按「个人文件 > 配置项」的优先级解析人格内容,并对腐坏内容告警。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func loadPersonality(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) *agentPkg.Personality {
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
return personality
|
||||
}
|
||||
|
||||
// newStageAndRegistry 建阶段管道与插件注册表,把内核依赖接到注册表上。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func newStageAndRegistry(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, iom *agentIO.IOManager,
|
||||
evBus *events.Bus, memDB *memory.GraphDB, textMem *text.Memory, docStore *document.Store,
|
||||
mediaStore *media.Store, ks *knowledge.Store, providerMgr *agentAPI.ProviderManager,
|
||||
dataDir string) (*agentCore.StageHost, *plugin.Registry) {
|
||||
stageHost := agentCore.NewStageHost()
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
pluginReg.SetIOManager(iom)
|
||||
pluginReg.SetEventBus(evBus)
|
||||
pluginReg.SetMemory(memDB)
|
||||
pluginReg.SetTextMemory(textMem)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路;nil 时静默降级
|
||||
pluginReg.SetKnowledge(ks)
|
||||
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 {
|
||||
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||
return nil
|
||||
})
|
||||
pluginReg.SetToolCleaner(stageHost)
|
||||
|
||||
return stageHost, pluginReg
|
||||
}
|
||||
|
||||
// newMainAgent 组装主 Agent:把内核各面(IO/记忆/文档/知识/媒体/社交/文本/插件/状态)接进 AgentConfig。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, provider agentAPI.Provider,
|
||||
providerMgr *agentAPI.ProviderManager, iom *agentIO.IOManager, memDB *memory.GraphDB,
|
||||
memIdx *memory.Indexer, trk *tracker.Tracker, docStore *document.Store, ks *knowledge.Store,
|
||||
socialStore *social.SocialStore, textMem *text.Memory, mediaStore *media.Store,
|
||||
personality *agentPkg.Personality, pluginReg *plugin.Registry, embedder *memory.StaticEmbedder,
|
||||
multimodalSpace vector.MultimodalEmbedder, mmProviderName, mmErr string,
|
||||
stageHost *agentCore.StageHost, evBus *events.Bus) *agentCore.Agent {
|
||||
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultSystemPrompt)
|
||||
if sysPrompt == "" {
|
||||
sysPrompt = defaultSystemPrompt
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
Offload: agentCore.OffloadOptions{
|
||||
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
|
||||
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
|
||||
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
|
||||
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
|
||||
},
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// initONNXParser 初始化依存句法分析器(内嵌 ONNX 模型,失败则退回规则引擎)。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func initONNXParser(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) {
|
||||
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
|
||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
||||
ModelPath: modelPath,
|
||||
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
||||
} else {
|
||||
nlp.SetDefaultParser(onnxParser)
|
||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlugins 建插件目录、按启动模式决定 allowlist,然后加载全部插件。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
||||
stageHost *agentCore.StageHost, bootMode, dataDir string) {
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if bootMode == "failback" {
|
||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
||||
if task, terr := recovery.LoadTask(recovery.TaskPath(dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
||||
list = strings.Join(task.Plugins(), ",")
|
||||
}
|
||||
var names []string
|
||||
for _, s := range strings.Split(list, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
pluginReg.SetLoadAllowlist(names)
|
||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
||||
}
|
||||
|
||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||
log.Printf("[homed] warning: load plugins: %v", err)
|
||||
}
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
}
|
||||
|
||||
// startAgentRuntime 接线技能索引、起日志管理、启动 agent,返回逆序关停的 cleanup。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
||||
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
|
||||
// 技能索引接线: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)
|
||||
|
||||
agent.Start()
|
||||
|
||||
return func() {
|
||||
// 与原 main 的两个 defer 同序(LIFO):先停 agent,再停日志管理。
|
||||
agent.Stop()
|
||||
logManager.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// startIPCServer 起 PING/ACK 心跳服务(含 kernel 状态快照),返回仅在启动成功后生效的 cleanup。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startIPCServer(dataDir, bootMode string, agent *agentCore.Agent) (*ipc.Server, func()) {
|
||||
started := false
|
||||
ipcServer := ipc.NewServer(dataDir, func() *ipc.Status {
|
||||
st := agent.GetKernelStatus()
|
||||
llmOK := st != nil && st.LLM.Available
|
||||
tools := 0
|
||||
if st != nil {
|
||||
tools = len(st.Tools)
|
||||
}
|
||||
uptime := int64(0)
|
||||
if st != nil {
|
||||
if d, err := time.ParseDuration(st.Uptime); err == nil {
|
||||
uptime = int64(d.Seconds())
|
||||
}
|
||||
}
|
||||
return &ipc.Status{
|
||||
PID: os.Getpid(),
|
||||
Boot: bootMode,
|
||||
UptimeSec: uptime,
|
||||
LLMOK: &llmOK,
|
||||
Tools: tools,
|
||||
LastDiag: lastDiagSummary(dataDir),
|
||||
}
|
||||
})
|
||||
if err := ipcServer.Start(); err != nil {
|
||||
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
|
||||
} else {
|
||||
}
|
||||
|
||||
return ipcServer, func() {
|
||||
if started {
|
||||
ipcServer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startSupervisorRuntime 把真实存活源与重启通道接到 supervisor 上,返回重启请求通道。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startSupervisorRuntime(sup *supervisor.Daemon, trk *tracker.Tracker, agent *agentCore.Agent) chan struct{} {
|
||||
sup.SetTracker(trk)
|
||||
sup.RegisterAgent("main")
|
||||
|
||||
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
|
||||
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
|
||||
restartCh := make(chan struct{}, 1)
|
||||
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
|
||||
st := agent.GetKernelStatus()
|
||||
if st == nil {
|
||||
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
|
||||
}
|
||||
h := types.HealthHealthy
|
||||
if !st.LLM.Available {
|
||||
h = types.HealthDegraded
|
||||
}
|
||||
return time.Now(), h, nil
|
||||
})
|
||||
sup.SetRestartHandler(func(id types.AgentID) {
|
||||
select {
|
||||
case restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
return restartCh
|
||||
}
|
||||
|
||||
// startHeartbeat 每 5s 触碰 <data>/heartbeat(guard 据此判定 worker 存活/卡死),返回停止函数。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func startHeartbeat(dataDir string, ctx context.Context) func() {
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
hbPath := filepath.Join(dataDir, "heartbeat")
|
||||
hbStop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
writeHB := func() {
|
||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
writeHB()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
writeHB()
|
||||
case <-hbStop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() { close(hbStop) }
|
||||
}
|
||||
|
||||
// waitForShutdown 阻塞至 SIGINT/SIGTERM 或 supervisor 请求重启,然后按原 main 的顺序清理,需要重建时以退出码交回 guard。
|
||||
//
|
||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
||||
func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{}, stopHeartbeat func(),
|
||||
pluginReg *plugin.Registry, trk *tracker.Tracker, cfgReg *internalConfig.ConfigRegistry, sup *supervisor.Daemon) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
restartRequested := false
|
||||
select {
|
||||
case <-sigCh:
|
||||
log.Printf("[homed] shutting down...")
|
||||
case <-restartCh:
|
||||
restartRequested = true
|
||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
||||
}
|
||||
|
||||
stopHeartbeat()
|
||||
pluginReg.StopAll()
|
||||
if trk != nil {
|
||||
trk.Stop()
|
||||
}
|
||||
if err := cfgReg.Flush(); err != nil {
|
||||
log.Printf("[homed] flush config: %v", err)
|
||||
}
|
||||
sup.Shutdown()
|
||||
log.Printf("[homed] stopped")
|
||||
|
||||
if restartRequested {
|
||||
os.Exit(exitRestartRequested)
|
||||
}
|
||||
}
|
||||
|
||||
// initLuaVM 起 Lua VM(LLM 协议适配);启动失败只告警,cleanup 为 no-op。
|
||||
func initLuaVM(cfg *types.Config) (*luapkg.VM, func()) {
|
||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
return luaVM, func() {}
|
||||
}
|
||||
return luaVM, luaVM.Stop
|
||||
}
|
||||
|
||||
// initSupervisor 起守护管理(代理生命周期管理);起不来是致命错误。
|
||||
func initSupervisor(cfg *types.Config) *supervisor.Daemon {
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
return sup
|
||||
}
|
||||
|
||||
// initTracker 起 overlayfs 变更追踪;无 overlayfs 支持时降级为非致命告警。
|
||||
func initTracker(cfg *types.Config, agentWorkDir string) *tracker.Tracker {
|
||||
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
|
||||
tracker.WithKeepChangesets(100),
|
||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
||||
)
|
||||
if err := trk.Init(); err != nil {
|
||||
log.Printf("[homed] warning: tracker init: %v", err)
|
||||
} else {
|
||||
if err := trk.Start(); err != nil {
|
||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
||||
} else {
|
||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
||||
}
|
||||
}
|
||||
return trk
|
||||
}
|
||||
|
||||
// initKernelAPI 建内核与插件之间的两个通道:IOManager(IO 抽象层)+ EventBus(事件总线)。
|
||||
func initKernelAPI() (*agentIO.IOManager, *events.Bus) {
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
return iom, evBus
|
||||
}
|
||||
|
||||
// initTextMemory 起文本记忆;启动失败只告警,cleanup 为 no-op。
|
||||
func initTextMemory(cfg *types.Config) (*text.Memory, func()) {
|
||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
return textMem, func() {}
|
||||
}
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
return textMem, textMem.Stop
|
||||
}
|
||||
|
||||
// resolveBaseAPIKey 解析兜底 API key:配置项 > LLM_API_KEY > DEEPSEEK_API_KEY。
|
||||
func resolveBaseAPIKey(cfg *types.Config) string {
|
||||
apiKey := cfg.LLM.APIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// wirePluginSDK 把内核各面注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure)。
|
||||
func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey string, sup *supervisor.Daemon,
|
||||
trk *tracker.Tracker, cfg *types.Config, stageHost *agentCore.StageHost, memIdx *memory.Indexer,
|
||||
agent *agentCore.Agent) {
|
||||
pluginReg.SetLuaVM(luaVM)
|
||||
pluginReg.SetBaseAPIKey(baseAPIKey)
|
||||
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
|
||||
pluginReg.SetTracker(trk)
|
||||
pluginReg.SetConfig(cfg)
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetIndexer(memIdx)
|
||||
pluginReg.SetStatusProvider(agent)
|
||||
pluginReg.SetTerminalAPI(agent)
|
||||
}
|
||||
|
||||
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
|
||||
//
|
||||
// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当它被改成非内置默认值)。
|
||||
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
|
||||
//
|
||||
// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时
|
||||
// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的
|
||||
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
|
||||
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**,
|
||||
// 无论怎么传都监听 :8080。覆盖值改由插件自己接收(webui.SetListenOverride)。
|
||||
func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string {
|
||||
if strings.TrimSpace(httpAddr) != "" {
|
||||
return strings.TrimSpace(httpAddr)
|
||||
}
|
||||
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”,
|
||||
// 否则会把用户在设置页里改过的插件 addr 顶掉。
|
||||
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// options 是 worker 的命令行参数。
|
||||
type options struct {
|
||||
dataDir string
|
||||
httpAddr string
|
||||
cliSocket string
|
||||
role string
|
||||
boot string
|
||||
}
|
||||
|
||||
// parseFlags 解析命令行参数。
|
||||
func parseFlags() options {
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||
flag.Parse()
|
||||
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
|
||||
}
|
||||
|
||||
// compactConfigDB 在空闲页够多时压缩配置库;失败只告警(不影响启动)。
|
||||
//
|
||||
// 触发条件(见 internal/config.MaybeCompact):空闲页 >= 1MB 且占页数 >= 25%。
|
||||
// 放在插件加载之后调用——迁移/清理大值发生在插件 Start 里,之前调用没有意义。
|
||||
func compactConfigDB(cfgReg *internalConfig.ConfigRegistry) {
|
||||
before := int64(-1)
|
||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
||||
before = st.Size()
|
||||
}
|
||||
done, err := cfgReg.MaybeCompact(1<<20, 0.25)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: 配置库压缩失败: %v", err)
|
||||
return
|
||||
}
|
||||
if !done {
|
||||
return
|
||||
}
|
||||
after := before
|
||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
||||
after = st.Size()
|
||||
}
|
||||
log.Printf("[homed] 配置库已压缩: %d -> %d 字节", before, after)
|
||||
}
|
||||
42
cmd/homed/bootstrap_test.go
Normal file
42
cmd/homed/bootstrap_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
|
||||
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
|
||||
//
|
||||
// 历史 bug:main() 拆分时函数体内残留一句 `defer distiller.Stop()`,
|
||||
// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。
|
||||
// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调
|
||||
// distillOnce,绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的
|
||||
// 真实调用方式断言,才照得出来。
|
||||
func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。
|
||||
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, cleanup := initMemoryStack(dir)
|
||||
if st == nil || st.distiller == nil {
|
||||
cleanup()
|
||||
t.Fatal("initMemoryStack 未返回蒸馏器")
|
||||
}
|
||||
if st.db == nil {
|
||||
cleanup()
|
||||
t.Skip("图库未初始化,无法验证蒸馏接线")
|
||||
}
|
||||
if st.distiller.Stopped() {
|
||||
cleanup()
|
||||
t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉(defer Stop 残留?)")
|
||||
}
|
||||
|
||||
// cleanup 是唯一的停机点:先停蒸馏器、再关图库。
|
||||
cleanup()
|
||||
if !st.distiller.Stopped() {
|
||||
t.Fatal("cleanup 之后蒸馏器应已停止")
|
||||
}
|
||||
}
|
||||
@ -2,49 +2,19 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/ipc"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/recovery"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
|
||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
||||
@ -52,699 +22,164 @@ import (
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
||||
)
|
||||
|
||||
// main 是 worker 进程的启动序列。
|
||||
//
|
||||
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
|
||||
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
|
||||
// - 阶段实现体在同包 bootstrap.go,与这里的调用一一对应;
|
||||
// - 需要逆序释放的资源由阶段函数返回 cleanup,在**原位** defer 注册,
|
||||
// 因此释放顺序与拆分前完全一致。
|
||||
func main() {
|
||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||
// platform_windows.go。
|
||||
requireSupportedPlatform()
|
||||
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||
flag.Parse()
|
||||
opt := parseFlags()
|
||||
|
||||
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
||||
if *role == "guard" {
|
||||
runGuard(resolveDataDir(*dataDir))
|
||||
if opt.role == "guard" {
|
||||
runGuard(resolveDataDir(opt.dataDir))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[homed] role=agent boot=%s", *boot)
|
||||
log.Printf("[homed] role=agent boot=%s", opt.boot)
|
||||
|
||||
if *dataDir == "" {
|
||||
*dataDir = resolveDataDir(*dataDir)
|
||||
if opt.dataDir == "" {
|
||||
opt.dataDir = resolveDataDir(opt.dataDir)
|
||||
}
|
||||
|
||||
if *cliSocket == "" {
|
||||
*cliSocket = filepath.Join(*dataDir, "cli.sock")
|
||||
if opt.cliSocket == "" {
|
||||
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
|
||||
}
|
||||
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
// 文件日志:同时输出到控制台和 data/log/ 目录
|
||||
logDir := filepath.Join(*dataDir, "log")
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("[homed] warning: cannot create log dir: %v", err)
|
||||
} else {
|
||||
logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05")))
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: cannot open log file: %v", err)
|
||||
} else {
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||
log.Printf("[homed] logging to %s", logPath)
|
||||
}
|
||||
}
|
||||
logDir := setupLogging(opt.dataDir)
|
||||
|
||||
log.Printf("[homed] starting %s", meta.FullVersion())
|
||||
|
||||
agentWorkDir := filepath.Join(*dataDir, "agentfs")
|
||||
dirs := []string{
|
||||
*dataDir,
|
||||
filepath.Join(*dataDir, "snapshots"),
|
||||
filepath.Join(*dataDir, "plugins"),
|
||||
filepath.Join(*dataDir, "changesets"),
|
||||
filepath.Join(*dataDir, "memory"),
|
||||
filepath.Join(*dataDir, "memory", "raw"),
|
||||
filepath.Join(*dataDir, "adapters"),
|
||||
agentWorkDir,
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
log.Fatalf("create dir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
agentWorkDir := ensureDataDirs(opt.dataDir)
|
||||
|
||||
// ========================================================================
|
||||
// 基础设施层:记忆、技能
|
||||
// ========================================================================
|
||||
// ---- 基础设施层:记忆、技能 ----
|
||||
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db"))
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||
memDB = nil
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
if memDB != nil {
|
||||
defer memDB.Close()
|
||||
}
|
||||
mem, closeMem := initMemoryStack(opt.dataDir)
|
||||
defer closeMem()
|
||||
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
// ---- 配置中心(SQLite 持久化,唯一配置源) ----
|
||||
|
||||
distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
if memDB != nil {
|
||||
distiller.Start()
|
||||
defer distiller.Stop()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 配置中心(SQLite 持久化,唯一配置源)
|
||||
// ========================================================================
|
||||
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db"))
|
||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
|
||||
defer cfgReg.Close()
|
||||
cfgReg.SeedDefaults(*dataDir)
|
||||
cfgReg.SeedDefaults(opt.dataDir)
|
||||
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json"))
|
||||
cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json"))
|
||||
cfg := cfgReg.ToConfig()
|
||||
|
||||
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
||||
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
||||
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
||||
distiller.SetEmbedder(embedder)
|
||||
mem.distiller.SetEmbedder(embedder)
|
||||
|
||||
// ========================================================================
|
||||
// Lua VM(LLM 协议适配)
|
||||
// ========================================================================
|
||||
// ---- Lua VM(LLM 协议适配) ----
|
||||
|
||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||
if err := luaVM.Start(); err != nil {
|
||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||
} else {
|
||||
defer luaVM.Stop()
|
||||
}
|
||||
luaVM, closeLuaVM := initLuaVM(cfg)
|
||||
defer closeLuaVM()
|
||||
|
||||
// ========================================================================
|
||||
// 守护管理(代理生命周期管理)
|
||||
// ========================================================================
|
||||
// ---- 守护管理(代理生命周期管理) ----
|
||||
|
||||
sup := supervisor.New(cfg)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("start supervisor: %v", err)
|
||||
}
|
||||
sup := initSupervisor(cfg)
|
||||
|
||||
// ========================================================================
|
||||
// 变更追踪(overlayfs)
|
||||
// ========================================================================
|
||||
// ---- 变更追踪(overlayfs) ----
|
||||
|
||||
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
|
||||
tracker.WithKeepChangesets(100),
|
||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
||||
)
|
||||
if err := trk.Init(); err != nil {
|
||||
log.Printf("[homed] warning: tracker init: %v", err)
|
||||
} else {
|
||||
if err := trk.Start(); err != nil {
|
||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
||||
} else {
|
||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
||||
}
|
||||
}
|
||||
trk := initTracker(cfg, agentWorkDir)
|
||||
|
||||
// ========================================================================
|
||||
// 内核 API:IOManager(IO 抽象层) + EventBus(事件总线)
|
||||
// 所有插件通过这两个通道与核心交互
|
||||
// ========================================================================
|
||||
|
||||
iom := agentIO.NewIOManager()
|
||||
evBus := events.NewBus()
|
||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||
iom, evBus := initKernelAPI()
|
||||
|
||||
// ========================================================================
|
||||
// 文本记忆 + 记忆蒸馏管线
|
||||
// ========================================================================
|
||||
// ---- 文本记忆 + 记忆蒸馏管线 ----
|
||||
|
||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
if err := textMem.Start(); err != nil {
|
||||
log.Printf("[homed] warning: text memory start: %v", err)
|
||||
} else {
|
||||
defer textMem.Stop()
|
||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||
}
|
||||
textMem, closeTextMem := initTextMemory(cfg)
|
||||
defer closeTextMem()
|
||||
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case evt, ok := <-iom.OutputChan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if evt.Target == "memory" && evt.Type == "memory_candidate" {
|
||||
source, _ := evt.Payload["source"].(string)
|
||||
input, _ := evt.Payload["input"].(string)
|
||||
response, _ := evt.Payload["response"].(string)
|
||||
toolsUsed, _ := evt.Payload["tools_used"].([]string)
|
||||
toolResults, _ := evt.Payload["tool_results"].([]interface{})
|
||||
agentID, _ := evt.Payload["agent_id"].(string)
|
||||
startMemoryCandidateConsumer(ctx, iom, textMem, mem.db, mem.distiller)
|
||||
|
||||
if input != "" && textMem != nil {
|
||||
te := text.Event{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Source: source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
AgentID: agentID,
|
||||
}
|
||||
if err := textMem.Append(te); err != nil {
|
||||
log.Printf("[homed] text memory append: %v", err)
|
||||
}
|
||||
}
|
||||
// ---- LLM Provider 管理(多源,通过 Lua 适配器协议转换) ----
|
||||
|
||||
if input != "" && memDB != nil {
|
||||
distiller.Append("agent", "user", input)
|
||||
}
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
baseAPIKey := resolveBaseAPIKey(cfg)
|
||||
|
||||
// 工具输出接入蒸馏管线
|
||||
for _, tr := range toolResults {
|
||||
if trMap, ok := tr.(map[string]interface{}); ok {
|
||||
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
|
||||
distiller.Append("agent", "tool", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// ========================================================================
|
||||
// LLM Provider 管理(多源,通过 Lua 适配器协议转换)
|
||||
// ========================================================================
|
||||
|
||||
apiKey := cfg.LLM.APIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||
}
|
||||
baseAPIKey := apiKey
|
||||
|
||||
providerMgr := agentAPI.NewProviderManager()
|
||||
adapterConcurrency := map[string]int{}
|
||||
for _, src := range cfg.LLM.Sources {
|
||||
if !agentAPI.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) {
|
||||
log.Printf("[homed] skip invalid llm source %q (base_url=%q model=%q adapter=%q)", src.Name, src.BaseURL, src.Model, src.Adapter)
|
||||
continue
|
||||
}
|
||||
key := src.APIKey
|
||||
if key == "" {
|
||||
key = baseAPIKey
|
||||
}
|
||||
luaProvider := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: src.Model,
|
||||
BaseURL: src.BaseURL,
|
||||
APIKey: key,
|
||||
Temperature: cfg.LLM.Temperature,
|
||||
MaxTokens: cfg.LLM.MaxTokens,
|
||||
ContextWindow: src.ContextWindow,
|
||||
MaxConcurrent: src.MaxConcurrent,
|
||||
Priority: src.Priority,
|
||||
Vision: src.Vision,
|
||||
Audio: src.Audio,
|
||||
}, luaVM, src.Name, src.Adapter)
|
||||
providerMgr.Register(src.Name, luaProvider)
|
||||
if src.Adapter != "" {
|
||||
adapterConcurrency[src.Adapter] += src.MaxConcurrent
|
||||
}
|
||||
}
|
||||
luaVM.ConfigureConcurrency(adapterConcurrency)
|
||||
if cfg.LLM.Provider != "" {
|
||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||
}
|
||||
providerMgr := initLLMProviders(cfg, luaVM, baseAPIKey)
|
||||
provider := providerMgr.Default()
|
||||
|
||||
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
||||
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
||||
if *boot == "failback" {
|
||||
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
if opt.boot == "failback" {
|
||||
runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 文档记忆 + 知识库
|
||||
// ========================================================================
|
||||
// ---- 文档记忆 + 知识库 ----
|
||||
|
||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
defer docStore.Stop()
|
||||
docStore, closeDocStore := initDocStore(cfg)
|
||||
defer closeDocStore()
|
||||
|
||||
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||
var mediaStore *media.Store
|
||||
if cfgReg.GetBool("core.memory.media.enabled", true) {
|
||||
mediaDir := cfgReg.GetString("core.memory.media.dir",
|
||||
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
|
||||
ms, err := media.New(mediaDir)
|
||||
if err != nil {
|
||||
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
|
||||
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
|
||||
} else {
|
||||
mediaStore = ms
|
||||
defer mediaStore.Close()
|
||||
st := mediaStore.Stats()
|
||||
log.Printf("[homed] media store active: %v 条 / %v 字节",
|
||||
st["count"], st["total_bytes"])
|
||||
}
|
||||
}
|
||||
mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
|
||||
defer closeMediaStore()
|
||||
|
||||
// 统一多模态向量空间。
|
||||
//
|
||||
// 核心**不**知道任何具体模型:它只按配置里的 provider 名从公共注册表
|
||||
// (pkg/embedding)打开一个 provider,并把 options.* 原样交给它。模型文件
|
||||
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
|
||||
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
defer adapted.Close()
|
||||
info := provider.Info()
|
||||
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
|
||||
shortFP := info.Fingerprint
|
||||
if len(shortFP) > 12 {
|
||||
shortFP = shortFP[:12]
|
||||
}
|
||||
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
|
||||
mmProvider, info.Dimension, shortFP, info.Modalities)
|
||||
}
|
||||
}
|
||||
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
|
||||
defer closeMultimodal()
|
||||
|
||||
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||
} else {
|
||||
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
|
||||
}
|
||||
ks := initKnowledgeStore(cfg)
|
||||
|
||||
// ========================================================================
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
// ---- 人格设定 ----
|
||||
|
||||
// 人格来源优先级:personal/personal.md(高级覆盖,存在且非空才生效)
|
||||
// > 配置项 core.agent.personal_prompt(默认模板 = config.DefaultPersonaPrompt)。
|
||||
//
|
||||
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
|
||||
// 反过来让实例自称旧版本(v1.2.0 压测发现)。故:
|
||||
// - 配置项化 + 内置默认模板(不含版本号字面量)
|
||||
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
personality := loadPersonality(cfg, cfgReg)
|
||||
|
||||
// ========================================================================
|
||||
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||
// ========================================================================
|
||||
// ---- 阶段管道(StageHost)+ 插件系统(Registry) ----
|
||||
|
||||
stageHost := agentCore.NewStageHost()
|
||||
stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
|
||||
docStore, mediaStore, ks, providerMgr, opt.dataDir)
|
||||
|
||||
pluginReg := plugin.NewRegistry()
|
||||
pluginReg.SetIOManager(iom)
|
||||
pluginReg.SetEventBus(evBus)
|
||||
pluginReg.SetMemory(memDB)
|
||||
pluginReg.SetTextMemory(textMem)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路;nil 时静默降级
|
||||
pluginReg.SetKnowledge(ks)
|
||||
pluginReg.SetProviderManager(providerMgr)
|
||||
pluginReg.SetConfigRegistry(cfgReg)
|
||||
pluginReg.SetPluginDir(cfg.Plugin.Dir)
|
||||
pluginReg.SetDataDir(*dataDir) // 插件 SettingsAPI.DataDir() 的数据根目录
|
||||
// ---- Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
|
||||
|
||||
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||
return nil
|
||||
})
|
||||
pluginReg.SetToolCleaner(stageHost)
|
||||
agent := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
|
||||
docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
|
||||
multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
|
||||
|
||||
// ========================================================================
|
||||
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||
// ========================================================================
|
||||
|
||||
defaultPrompt := `你是 HomeAgent,一个持续运行的个人管家。
|
||||
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
|
||||
如需切换回复通道,使用 output_set_channel。
|
||||
如需异步发送消息或通知,使用 output_send 指定通道和内容。
|
||||
使用 output_list_channels 查看可用通道及其能力。
|
||||
|
||||
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具:
|
||||
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
|
||||
- knowledge_* — 知识库(查阅预设知识文档)
|
||||
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
|
||||
- person_* — 人物特质与社交关系网
|
||||
- llm_* — LLM 源管理(列出/切换模型提供商)
|
||||
- output_* — 输出通道管理(切换/发送消息)
|
||||
- timer_set — 设置定时提醒
|
||||
- plgreload — 热重载插件
|
||||
- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5)
|
||||
|
||||
并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行;
|
||||
长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。
|
||||
- describe_image — 描述用户上传的图片
|
||||
- transcribe_audio — 转写用户上传的音频
|
||||
- ocr_image — 识别图片中的文字
|
||||
|
||||
命令与文件操作策略:
|
||||
- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、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
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
// 通过 Registry 将内核依赖注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure)
|
||||
pluginReg.SetLuaVM(luaVM)
|
||||
pluginReg.SetBaseAPIKey(baseAPIKey)
|
||||
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
|
||||
pluginReg.SetTracker(trk)
|
||||
pluginReg.SetConfig(cfg)
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetIndexer(memIdx)
|
||||
pluginReg.SetStatusProvider(agent)
|
||||
wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
|
||||
|
||||
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
||||
cli.DefaultSocket = *cliSocket
|
||||
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080)。
|
||||
// 保留 CLI --webui 与 webui.listen_addr 配置对监听地址的覆盖。
|
||||
webuiListenAddr := *httpAddr
|
||||
if webuiListenAddr == "" {
|
||||
webuiListenAddr = cfgReg.GetString("webui.listen_addr", ":8080")
|
||||
}
|
||||
if ps := cfgReg.PluginConfig("webui"); ps != nil {
|
||||
if v, _ := ps.Get("addr"); v == nil {
|
||||
_ = ps.Set("addr", webuiListenAddr)
|
||||
}
|
||||
}
|
||||
cli.DefaultSocket = opt.cliSocket
|
||||
// webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。
|
||||
webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
|
||||
|
||||
// ========================================================================
|
||||
// 依存句法分析器(内嵌 ONNX 模型 / 规则引擎)
|
||||
// ========================================================================
|
||||
// ---- 依存句法分析器(内嵌 ONNX 模型 / 规则引擎) ----
|
||||
|
||||
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
|
||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
||||
ModelPath: modelPath,
|
||||
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
||||
} else {
|
||||
nlp.SetDefaultParser(onnxParser)
|
||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
||||
}
|
||||
initONNXParser(cfg, cfgReg)
|
||||
|
||||
// Auto-create plugins directory (without hardcoding plugin names)
|
||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||
loadPlugins(cfg, cfgReg, pluginReg, stageHost, opt.boot, opt.dataDir)
|
||||
|
||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||
if *boot == "failback" {
|
||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
||||
if task, terr := recovery.LoadTask(recovery.TaskPath(*dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
||||
list = strings.Join(task.Plugins(), ",")
|
||||
}
|
||||
var names []string
|
||||
for _, s := range strings.Split(list, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
pluginReg.SetLoadAllowlist(names)
|
||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
||||
}
|
||||
// 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除,
|
||||
// 而 SQLite 的 DELETE 不会缩小文件。
|
||||
compactConfigDB(cfgReg)
|
||||
|
||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||
log.Printf("[homed] warning: load plugins: %v", err)
|
||||
}
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
|
||||
defer stopRuntime()
|
||||
|
||||
// 技能索引接线: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")
|
||||
}
|
||||
}
|
||||
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
|
||||
defer closeIPC()
|
||||
|
||||
// 日志管理:层级压缩 + 保留策略
|
||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||
go logManager.Start(ctx)
|
||||
defer logManager.Stop()
|
||||
|
||||
agent.Start()
|
||||
defer agent.Stop()
|
||||
|
||||
// PING/ACK 心跳服务:worker 监听 unix socket,guard 发 PING、worker 回 ACK
|
||||
// (含自诊断 kernel 状态快照),替换纯文件心跳。文件心跳保留作回退。
|
||||
ipcServer := ipc.NewServer(*dataDir, func() *ipc.Status {
|
||||
st := agent.GetKernelStatus()
|
||||
llmOK := st != nil && st.LLM.Available
|
||||
tools := 0
|
||||
if st != nil {
|
||||
tools = len(st.Tools)
|
||||
}
|
||||
uptime := int64(0)
|
||||
if st != nil {
|
||||
if d, err := time.ParseDuration(st.Uptime); err == nil {
|
||||
uptime = int64(d.Seconds())
|
||||
}
|
||||
}
|
||||
return &ipc.Status{
|
||||
PID: os.Getpid(),
|
||||
Boot: *boot,
|
||||
UptimeSec: uptime,
|
||||
LLMOK: &llmOK,
|
||||
Tools: tools,
|
||||
LastDiag: lastDiagSummary(*dataDir),
|
||||
}
|
||||
})
|
||||
if err := ipcServer.Start(); err != nil {
|
||||
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
|
||||
} else {
|
||||
defer ipcServer.Stop()
|
||||
}
|
||||
|
||||
sup.SetTracker(trk)
|
||||
sup.RegisterAgent("main")
|
||||
|
||||
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
|
||||
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
|
||||
restartCh := make(chan struct{}, 1)
|
||||
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
|
||||
st := agent.GetKernelStatus()
|
||||
if st == nil {
|
||||
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
|
||||
}
|
||||
h := types.HealthHealthy
|
||||
if !st.LLM.Available {
|
||||
h = types.HealthDegraded
|
||||
}
|
||||
return time.Now(), h, nil
|
||||
})
|
||||
sup.SetRestartHandler(func(id types.AgentID) {
|
||||
select {
|
||||
case restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
restartCh := startSupervisorRuntime(sup, trk, agent)
|
||||
|
||||
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
|
||||
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||
|
||||
// ========================================================================
|
||||
// 等待退出信号
|
||||
// ========================================================================
|
||||
// ---- 等待退出信号 ----
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||
hbPath := filepath.Join(*dataDir, "heartbeat")
|
||||
hbStop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
writeHB := func() {
|
||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
writeHB()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
writeHB()
|
||||
case <-hbStop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
restartRequested := false
|
||||
select {
|
||||
case <-sigCh:
|
||||
log.Printf("[homed] shutting down...")
|
||||
case <-restartCh:
|
||||
restartRequested = true
|
||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
||||
}
|
||||
|
||||
close(hbStop)
|
||||
pluginReg.StopAll()
|
||||
if trk != nil {
|
||||
trk.Stop()
|
||||
}
|
||||
if err := cfgReg.Flush(); err != nil {
|
||||
log.Printf("[homed] flush config: %v", err)
|
||||
}
|
||||
sup.Shutdown()
|
||||
log.Printf("[homed] stopped")
|
||||
|
||||
if restartRequested {
|
||||
os.Exit(exitRestartRequested)
|
||||
}
|
||||
stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
|
||||
waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
|
||||
}
|
||||
|
||||
123
cmd/memgc/main.go
Normal file
123
cmd/memgc/main.go
Normal file
@ -0,0 +1,123 @@
|
||||
// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。
|
||||
//
|
||||
// 为什么需要这个命令:噪音闸门(internal/memory.IsNoiseEntity)只能拦住
|
||||
// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是
|
||||
// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被
|
||||
// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。
|
||||
//
|
||||
// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端
|
||||
// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。
|
||||
//
|
||||
// 用法(默认 dry-run,只列不删):
|
||||
//
|
||||
// memgc -db /home/newqqagent/memory/graph.db
|
||||
// memgc -db /home/newqqagent/memory/graph.db -orphans -apply
|
||||
//
|
||||
// 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"
|
||||
// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := flag.String("db", "", "graph.db 路径(必填)")
|
||||
apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印")
|
||||
orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
|
||||
tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)")
|
||||
entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景")
|
||||
sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要")
|
||||
flag.Parse()
|
||||
|
||||
if *path == "" {
|
||||
flag.Usage()
|
||||
log.Fatal("memgc: 必须指定 -db")
|
||||
}
|
||||
|
||||
g, err := memory.NewGraphDB(*path)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: open %s: %v", *path, err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
if *sceneStats {
|
||||
stats, err := g.SceneStats()
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: scene stats: %v", err)
|
||||
}
|
||||
fmt.Printf("场景 %d 个:\n", len(stats))
|
||||
for _, st := range stats {
|
||||
fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n",
|
||||
st.Origin, st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features,
|
||||
st.UpdatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型)
|
||||
// 没有任何场景引用,不补挂就永远吃不到场景召回。
|
||||
if *tagScene != "" {
|
||||
if *entityGlob == "" {
|
||||
log.Fatal("memgc: -tag-scene 需要配套 -entity-glob(如 '*QQ*');不做自动猜测")
|
||||
}
|
||||
n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: tag scene: %v", err)
|
||||
}
|
||||
if *apply {
|
||||
fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene)
|
||||
} else {
|
||||
fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q(未写库)\n", n, *tagScene)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
junk, err := g.NoiseEntities()
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: scan: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("噪音实体 %d 个:\n", len(junk))
|
||||
for _, e := range junk {
|
||||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
||||
}
|
||||
|
||||
de, dr, err := g.PurgeNoise(!*apply)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: purge: %v", err)
|
||||
}
|
||||
if *apply {
|
||||
fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr)
|
||||
} else {
|
||||
fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d(未写库,加 -apply 才落地)\n", de, dr)
|
||||
}
|
||||
|
||||
if *orphans {
|
||||
list, err := g.OrphanEntities()
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: orphans: %v", err)
|
||||
}
|
||||
fmt.Printf("孤立实体(零关系)%d 个:\n", len(list))
|
||||
for _, e := range list {
|
||||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
||||
}
|
||||
n, err := g.PurgeOrphans(!*apply)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: purge orphans: %v", err)
|
||||
}
|
||||
if *apply {
|
||||
fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n)
|
||||
} else {
|
||||
fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n)
|
||||
}
|
||||
}
|
||||
|
||||
if *apply {
|
||||
fmt.Println("提示:运行中的进程会在下一个 archive 心跳(Indexer.Sync)重建实体名向量索引,无需重启。")
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1001001,
|
||||
"versionName": "1.1.1",
|
||||
"versionCode": 1004000,
|
||||
"versionName": "1.4.0",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||
"icon": "$media:layered_image",
|
||||
|
||||
19
cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets
Normal file
19
cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets
Normal file
@ -0,0 +1,19 @@
|
||||
import { bundleManager } from '@kit.AbilityKit';
|
||||
|
||||
/**
|
||||
* 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。
|
||||
*
|
||||
* AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh
|
||||
* 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相,
|
||||
* 实测已经漂过:app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。
|
||||
* 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。
|
||||
*/
|
||||
export function appVersion(): string {
|
||||
try {
|
||||
const info: bundleManager.BundleInfo =
|
||||
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
|
||||
return info.versionName;
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 附件的字节获取与解码(网络 / 沙箱 I/O)。
|
||||
*
|
||||
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
|
||||
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
|
||||
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
|
||||
*
|
||||
* 从 components/Attachment.ets 抽出:缩略图与详情大图都走同一条解码路径。
|
||||
*/
|
||||
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { apiClient } from './ApiClient';
|
||||
|
||||
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
103
cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets
Normal file
103
cmd/ohos/HomeAgent/entry/src/main/ets/common/AttachmentMeta.ets
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 附件的解析与格式化(纯函数,无 UI、无平台 I/O)。
|
||||
*
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
*
|
||||
* 从 components/Attachment.ets 抽出:附件卡与附件详情都要用这几个函数,
|
||||
* 放在 common 里两边共用,也不必让 UI 文件承担这段纯逻辑。
|
||||
*/
|
||||
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
|
||||
/** 从后端 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 : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
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();
|
||||
}
|
||||
|
||||
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
|
||||
export function sanitize(name: string): string {
|
||||
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
|
||||
if (s.length === 0) {
|
||||
s = 'attachment';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@ -5,6 +5,9 @@ import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||
import { componentSnapshot } from '@kit.ArkUI';
|
||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||
import { camera, cameraPicker } from '@kit.CameraKit';
|
||||
import { fileIo, fileUri } from '@kit.CoreFileKit';
|
||||
import { appVersion } from './AppVersion';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
@ -17,12 +20,17 @@ export const LOCAL_DEVICE_CAPS: string[] = [
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
'camerasue',
|
||||
];
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
|
||||
// DeviceBridge 不要再发 cmd_result;否则网关会把后续分块挂在一条已完成的
|
||||
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
|
||||
chunked?: boolean;
|
||||
}
|
||||
|
||||
interface DeviceStatusPayload {
|
||||
@ -71,6 +79,19 @@ function errResult(errMsg: string): CapResult {
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二进制分块发送回调。由 BridgeRouter 注入(它持有 deviceBridge + reqId),
|
||||
* BridgeCaps 因此不必 import DeviceBridge —— 否则 DeviceBridge 为取 CapResult
|
||||
* 而 import BridgeCaps,两边成环。分层也更干净:能力实现不碰 socket。
|
||||
*/
|
||||
export type DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => void;
|
||||
|
||||
// chunkedResult:结果已由能力自己分块发出,不再回 cmd_result。
|
||||
function chunkedResult(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '', chunked: true };
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
||||
|
||||
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
||||
@ -129,6 +150,142 @@ export async function capScreensee(): Promise<CapResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== camerasue:系统相机抓拍 =====
|
||||
//
|
||||
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
|
||||
// (CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
|
||||
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
|
||||
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
|
||||
//
|
||||
// 结果落在应用沙箱(saveUri 指向 filesDir),不写系统媒体库,也就不需要
|
||||
// READ_IMAGEVIDEO 这类受限权限。
|
||||
const CAMERASUE_MAX_B64: number = 950000;
|
||||
|
||||
/** 录像回传上限:与网关 mediaDir 落盘模式配合,避免把设备内存/WS 打爆。 */
|
||||
const CAMERASUE_MAX_VIDEO: number = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* camerasue 实现。
|
||||
*
|
||||
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
|
||||
* `<N秒>` = 录 N 秒视频。
|
||||
*
|
||||
* 视频为什么要走二进制分块:一段 10s 录像动辄数 MB,base64 后还要再膨胀
|
||||
* 1/3,既撑爆模型上下文也撑爆 WS 单帧。cameraPicker 本身支持 VIDEO
|
||||
* 模式(系统相机会直接进录像界面),取回文件后用 sendDataChunked 按
|
||||
* cmd_data_start/分块/cmd_data_end 回传——网关侧聚合后落盘成文件,agent 拿路径。
|
||||
* 这与 GUI/CLI 客户端的 camerasue 录像路径一致。
|
||||
*/
|
||||
export async function capCamerasue(context: common.UIAbilityContext,
|
||||
rawArgs: string,
|
||||
sendChunked: DataChunkSender | null): Promise<CapResult> {
|
||||
const raw: string = rawArgs.trim();
|
||||
let videoSeconds: number = 0;
|
||||
if (raw.length > 0) {
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (!digits.test(raw)) {
|
||||
return errResult('camerasue 参数只接受纯数字秒数,如 camerasue 5');
|
||||
}
|
||||
videoSeconds = parseInt(raw, 10);
|
||||
if (videoSeconds <= 0 || videoSeconds > 300) {
|
||||
return errResult('录像时长需在 1~300 秒之间');
|
||||
}
|
||||
}
|
||||
const isVideo: boolean = videoSeconds > 0;
|
||||
|
||||
const ext: string = isVideo ? '.mp4' : '.jpg';
|
||||
const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + ext;
|
||||
try {
|
||||
// cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位
|
||||
const f: fileIo.File = fileIo.openSync(filePath,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
|
||||
fileIo.closeSync(f);
|
||||
} catch (e) {
|
||||
return errResult('无法创建相机输出文件');
|
||||
}
|
||||
|
||||
try {
|
||||
const profile: cameraPicker.PickerProfile = {
|
||||
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
|
||||
saveUri: fileUri.getUriFromPath(filePath),
|
||||
};
|
||||
if (isVideo) {
|
||||
profile.videoDuration = videoSeconds;
|
||||
}
|
||||
const mediaType: cameraPicker.PickerMediaType = isVideo
|
||||
? cameraPicker.PickerMediaType.VIDEO
|
||||
: cameraPicker.PickerMediaType.PHOTO;
|
||||
const res: cameraPicker.PickerResult =
|
||||
await cameraPicker.pick(context, [mediaType], profile);
|
||||
if (res.resultCode !== 0 || res.resultUri.length === 0) {
|
||||
return errResult(isVideo ? '未获取到录像(可能被取消)' : '未获取到照片(可能被取消)');
|
||||
}
|
||||
} catch (e) {
|
||||
return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机');
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return readAndSendVideo(filePath, sendChunked);
|
||||
}
|
||||
return readPhotoAsBase64(filePath);
|
||||
}
|
||||
|
||||
/** 读回录像并以二进制分块回传;网关聚合后落盘,agent 拿文件路径。 */
|
||||
function readAndSendVideo(filePath: string, sendChunked: DataChunkSender | null): CapResult {
|
||||
let fd: number = -1;
|
||||
try {
|
||||
const stat: fileIo.Stat = fileIo.statSync(filePath);
|
||||
if (stat.size <= 0) {
|
||||
return errResult('录像文件为空,请重试');
|
||||
}
|
||||
if (stat.size > CAMERASUE_MAX_VIDEO) {
|
||||
return errResult('录像文件过大(超过 64MB),请缩短时长');
|
||||
}
|
||||
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
|
||||
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
|
||||
fd = rf.fd;
|
||||
fileIo.readSync(fd, buf);
|
||||
fileIo.closeSync(rf);
|
||||
fd = -1;
|
||||
if (sendChunked === null) {
|
||||
return errResult('录像回传通道未就绪,请重试');
|
||||
}
|
||||
sendChunked('camera_video', 'video/mp4', new Uint8Array(buf));
|
||||
// 分块已代表本次请求的完整结果,DeviceBridge 不再回 cmd_result。
|
||||
return chunkedResult('录像已回传(' + stat.size.toString() + ' 字节)');
|
||||
} catch (e) {
|
||||
if (fd >= 0) {
|
||||
try { fileIo.closeSync(fd); } catch (ignore) {}
|
||||
}
|
||||
return errResult('录像读取失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 照片仍走小体积 base64 内联(图片不大,不必分块)。 */
|
||||
function readPhotoAsBase64(filePath: string): CapResult {
|
||||
let fd: number = -1;
|
||||
try {
|
||||
const stat: fileIo.Stat = fileIo.statSync(filePath);
|
||||
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
|
||||
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
|
||||
fd = rf.fd;
|
||||
fileIo.readSync(fd, buf);
|
||||
fileIo.closeSync(rf);
|
||||
fd = -1;
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(buf));
|
||||
if (b64.length > CAMERASUE_MAX_B64) {
|
||||
return errResult('照片数据过大,请降低分辨率后重试');
|
||||
}
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
if (fd >= 0) {
|
||||
try { fileIo.closeSync(fd); } catch (ignore) {}
|
||||
}
|
||||
return errResult('照片读取失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||
@ -249,7 +406,7 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
os_release: deviceInfo.osFullName,
|
||||
version: '1.1.1',
|
||||
version: appVersion(),
|
||||
cpus: 0,
|
||||
brand: deviceInfo.brand,
|
||||
manufacturer: deviceInfo.manufacture,
|
||||
@ -269,29 +426,3 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||
|
||||
export interface ScreensuePayload {
|
||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||
const firstSpace: RegExp = new RegExp('\\s');
|
||||
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||
const splitAt: number = rest.search(firstSpace);
|
||||
if (splitAt > 0) {
|
||||
const first: string = rest.substring(0, splitAt);
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (digits.test(first)) {
|
||||
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||
}
|
||||
}
|
||||
p.content = rest;
|
||||
return p;
|
||||
}
|
||||
|
||||
173
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets
Normal file
173
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeProtocol.ets
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 设备桥协议:消息结构、帧构造与分块切片。
|
||||
*
|
||||
* 与 homed 的 remotedevice 插件对齐(internal/plugins/remotedevice)。
|
||||
* 从 common/DeviceBridge.ets 抽出:这里只有"协议形状"和"帧怎么拼",
|
||||
* 没有任何 socket、状态机与重连逻辑 —— 那些留在 DeviceBridgeClient 里。
|
||||
*
|
||||
* 注意:DeviceBridgeClient 的对外方法名与语义不因本文件而改变,
|
||||
* 各 send* 方法仍是"拼帧 + 发出去"两步,只是第一步搬到了这里。
|
||||
*/
|
||||
|
||||
import { CapResult } from './BridgeCaps';
|
||||
import { appVersion } from './AppVersion';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
export interface HelloDeviceInfo {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
export interface HelloDevice {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
info: HelloDeviceInfo;
|
||||
}
|
||||
|
||||
export interface HelloMessage {
|
||||
op: string;
|
||||
device: HelloDevice;
|
||||
}
|
||||
|
||||
export interface BindMessage {
|
||||
op: string;
|
||||
device_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
status: string;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface DataStartMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
kind: string;
|
||||
mime: string;
|
||||
total: number;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
export interface DataEndMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
status: string;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const CHUNK_SIZE: number = 8192;
|
||||
|
||||
// ===== 命令处理器回调 =====
|
||||
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
|
||||
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
|
||||
|
||||
// ===== 帧构造 =====
|
||||
|
||||
export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
|
||||
caps: string[], hostname: string,
|
||||
authorized: boolean): string {
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: appVersion(),
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
device_id: deviceId,
|
||||
name: name,
|
||||
kind: kind,
|
||||
authorized: authorized,
|
||||
caps: caps,
|
||||
info: info,
|
||||
};
|
||||
const hello: HelloMessage = { op: 'hello', device: device };
|
||||
return JSON.stringify(hello);
|
||||
}
|
||||
|
||||
export function bridgeBindFrame(deviceId: string, token: string): string {
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: deviceId,
|
||||
token: token,
|
||||
};
|
||||
return JSON.stringify(bind);
|
||||
}
|
||||
|
||||
export function bridgeResultFrame(reqId: string, status: string,
|
||||
output: string, errMsg: string): string {
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
export function bridgeDataStartFrame(reqId: string, kind: string, mime: string,
|
||||
total: number): string {
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
mime: mime,
|
||||
total: total,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
return JSON.stringify(startMsg);
|
||||
}
|
||||
|
||||
export function bridgeDataEndFrame(reqId: string): string {
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
return JSON.stringify(endMsg);
|
||||
}
|
||||
|
||||
export function bridgeEventFrame(deviceId: string, eventType: string, detail: string): string {
|
||||
const payload: Record<string, string> = { 'detail': detail };
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'event',
|
||||
'device_id': deviceId,
|
||||
'type': eventType,
|
||||
'payload': payload,
|
||||
};
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
export function bridgeStatusFrame(deviceId: string, status: string): string {
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': deviceId,
|
||||
'status': status,
|
||||
};
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
/** 按 CHUNK_SIZE 切二进制;切片顺序即发送顺序。 */
|
||||
export function bridgeChunkSlices(bytes: Uint8Array): Uint8Array[] {
|
||||
const out: Uint8Array[] = [];
|
||||
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||
out.push(bytes.slice(off, end));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -1,15 +1,16 @@
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
DataChunkSender,
|
||||
capScreensee,
|
||||
capCamerasue,
|
||||
capClipboardSee,
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
capStatus,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
|
||||
import { connStore } from './ConnStore';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@ -84,6 +85,17 @@ async function executeCommand(reqId: string, command: string): Promise<CapResult
|
||||
}
|
||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
if (name === 'camerasue') {
|
||||
if (appContext === null) {
|
||||
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
// 录像走二进制分块:把「往本请求回传字节」的能力注入能力实现,
|
||||
// 避免 BridgeCaps 反向 import DeviceBridge 形成循环依赖。
|
||||
const sender: DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => {
|
||||
deviceBridge.sendDataChunked(reqId, kind, mime, bytes);
|
||||
};
|
||||
return capCamerasue(appContext, args, sender);
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('clipboardsee 不接受额外参数');
|
||||
|
||||
223
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets
Normal file
223
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatFormat.ets
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 聊天页的纯格式化/判定逻辑(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:这些函数只吃数据吐字符串/布尔,
|
||||
* 抽出来后气泡、工具卡、渠道头像三个组件可以共用同一份口径。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo } from '../model/Model';
|
||||
|
||||
/**
|
||||
* 由文件名后缀推断 Content-Type。
|
||||
* 后端按 multipart 部件的 Content-Type 判定 image/file,
|
||||
* 给错会让图片被当成普通文件(缩略图就没了)。
|
||||
*/
|
||||
export function mimeOf(name: string, isImage: boolean): string {
|
||||
const i: number = name.lastIndexOf('.');
|
||||
const ext: string = i >= 0 ? name.substring(i + 1).toLowerCase() : '';
|
||||
if (ext === 'png') {
|
||||
return 'image/png';
|
||||
}
|
||||
if (ext === 'jpg' || ext === 'jpeg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (ext === 'webp') {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (ext === 'gif') {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (ext === 'bmp') {
|
||||
return 'image/bmp';
|
||||
}
|
||||
if (ext === 'heic' || ext === 'heif') {
|
||||
return 'image/heic';
|
||||
}
|
||||
if (isImage) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
return 'application/pdf';
|
||||
}
|
||||
if (ext === 'txt' || ext === 'log' || ext === 'md') {
|
||||
return 'text/plain';
|
||||
}
|
||||
if (ext === 'json') {
|
||||
return 'application/json';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** payload 字段可能是字符串、对象或数组,统一转成可展示文本。 */
|
||||
export function stringifyField(raw: Object | undefined): string {
|
||||
if (raw === undefined || raw === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw as string;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(raw);
|
||||
} catch (e) {
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ForEach 键:消息结构变化即换键 → 旧气泡销毁重建 → @Builder 里的
|
||||
* if 分支重新求值。这是 ArkUI V1 渲染模型决定的:ForEach 对相同键
|
||||
* 只更新 @Prop/@Link 绑定,不重新执行 @Builder 体,所以
|
||||
* 「思考卡/工具卡/附件」这些用 if 包裹的条件分支在首次渲染后
|
||||
* 永远不会再次求值——气泡里的这些面板就永远不出现。
|
||||
*
|
||||
* 反过来,content_delta 不进 structSig:正文文本靠 MarkdownView
|
||||
* 的 @Prop content 响应式更新,不重建气泡 → 流式渲染平滑。
|
||||
* 实测 SSE 里 reasoning_delta 与 content_delta 不交错(思考阶段
|
||||
* 先于输出阶段),所以思考期间重建气泡不会打断正文流式动画。
|
||||
*/
|
||||
export function structSig(msg: ChatMessage): string {
|
||||
let s: string = msg.id.toString();
|
||||
const rc: string | undefined = msg.reasoningContent;
|
||||
s += '_r' + (rc !== undefined ? rc.length.toString() : '0');
|
||||
s += '_ro' + (msg.reasoningOpen === true ? '1' : '0');
|
||||
const tcs: ToolCallInfo[] | undefined = msg.toolCalls;
|
||||
if (tcs !== undefined) {
|
||||
s += '_t' + tcs.length.toString();
|
||||
for (let i = 0; i < tcs.length; i++) {
|
||||
const tc: ToolCallInfo = tcs[i];
|
||||
s += '_' + (tc.status ?? '');
|
||||
s += '_' + (tc.open === true ? 'o' : 'c');
|
||||
s += '_' + (tc.args !== undefined ? tc.args.length.toString() : '0');
|
||||
s += '_' + (tc.result !== undefined ? tc.result.length.toString() : '0');
|
||||
s += '_' + (tc.plugin ?? '');
|
||||
}
|
||||
} else {
|
||||
s += '_t0';
|
||||
}
|
||||
s += '_a' + (msg.attachment !== undefined ? '1' : '0');
|
||||
s += '_src' + (msg.source ?? '');
|
||||
s += '_f' + (msg.isFinal === true ? '1' : '0');
|
||||
s += '_s' + (msg.isStreaming === true ? '1' : '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 折叠时也要能看出思考在增长:显示字数 */
|
||||
export function reasoningLenLabel(msg: ChatMessage): string {
|
||||
const rc: string | undefined = msg.reasoningContent;
|
||||
if (rc === undefined || rc.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return rc.length.toString() + ' 字';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否仍在执行。
|
||||
* 判据是 status 而不是 result:后端 status=ok 的工具也可能返回空串,
|
||||
* 用 result 判断会让这类调用永远显示"调用中"。
|
||||
*/
|
||||
export function tcRunning(tc: ToolCallInfo): boolean {
|
||||
const s: string | undefined = tc.status;
|
||||
return s === undefined || s.length === 0 || s === 'running';
|
||||
}
|
||||
|
||||
export function tcError(tc: ToolCallInfo): boolean {
|
||||
return tc.status === 'denied' || tc.status === 'error';
|
||||
}
|
||||
|
||||
/** 工具卡左侧色条(accent 由调用方从 palette 取) */
|
||||
export function tcLeftColor(tc: ToolCallInfo, accent: string): string {
|
||||
if (tcError(tc)) {
|
||||
return '#DB3694';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return accent;
|
||||
}
|
||||
return 'rgba(23, 169, 100, 0.8)';
|
||||
}
|
||||
|
||||
/** 工具卡状态图标颜色 */
|
||||
export function tcIcoColor(tc: ToolCallInfo, accent: string): string {
|
||||
if (tcError(tc)) {
|
||||
return '#DB3694';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return accent;
|
||||
}
|
||||
return 'rgba(23, 169, 100, 0.9)';
|
||||
}
|
||||
|
||||
export function tcStateLabel(tc: ToolCallInfo): string {
|
||||
if (tc.status === 'denied') {
|
||||
return '已拒绝';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return '调用中';
|
||||
}
|
||||
return '完成';
|
||||
}
|
||||
|
||||
export function tcStateColor(tc: ToolCallInfo): string {
|
||||
if (tc.status === 'denied') {
|
||||
return '#FF9EC6';
|
||||
}
|
||||
if (tcRunning(tc)) {
|
||||
return '#A3B8FF';
|
||||
}
|
||||
return '#6EE7A8';
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡最大宽度(相对 BubbleSlot 的宽度,即扣掉头像与间距后的真实可用宽)。
|
||||
* 纯文本 78% 好看;但工具卡/思考卡是"面板",78% 会把里面的状态文字和
|
||||
* 参数/结果压成一团(还会被 clip 切掉),所以带卡片时放宽到 92%。
|
||||
*/
|
||||
export function bubbleMaxWidth(msg: ChatMessage): string {
|
||||
const hasPanels: boolean =
|
||||
(msg.toolCalls !== undefined && msg.toolCalls.length > 0) ||
|
||||
(msg.reasoningContent !== undefined && msg.reasoningContent.length > 0);
|
||||
return hasPanels ? '92%' : '78%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否"别处来的"消息。对齐 GUI 的 source !== 'webui' 判定,但多减一项:
|
||||
* 本机自己发的消息在后端会被写成 webui/<device_id>,那仍然是"我发的",
|
||||
* 不能当成渠道消息挂上别人的头像。
|
||||
*/
|
||||
export function isChannelMsg(source: string, deviceId: string): boolean {
|
||||
if (source.length === 0 || source === 'webui') {
|
||||
return false;
|
||||
}
|
||||
return source !== 'webui/' + deviceId;
|
||||
}
|
||||
|
||||
/** 自己发的消息(右对齐、"我"头像):渠道消息即使 role=user 也不算 */
|
||||
export function isSelfMsg(role: string, channel: boolean): boolean {
|
||||
return role === 'user' && !channel;
|
||||
}
|
||||
|
||||
/** 渠道名展示:webui/<id> 只显示 <id>,其余原样。 */
|
||||
export function chanLabel(src: string): string {
|
||||
if (src.startsWith('webui/')) {
|
||||
return src.substring(6);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
/** 渠道首字母(大写),用作头像文字。 */
|
||||
export function chanLetter(src: string): string {
|
||||
const label: string = chanLabel(src);
|
||||
if (label.length === 0) {
|
||||
return '?';
|
||||
}
|
||||
return label.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
/** 由渠道名散列出稳定色,避免每次渲染换色。 */
|
||||
export function chanColor(src: string): string {
|
||||
const label: string = chanLabel(src);
|
||||
let h: number = 0;
|
||||
for (let i = 0; i < label.length; i++) {
|
||||
h = (h * 31 + label.charCodeAt(i)) % 360;
|
||||
}
|
||||
return 'hsl(' + h.toString() + ', 52%, 46%)';
|
||||
}
|
||||
119
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets
Normal file
119
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatHistory.ets
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* /chat/history 响应解析(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:首屏与向上翻页共用同一套解析口径,
|
||||
* 消息 id 由调用方提供的分配器给出(页面自己维护 id 计数器)。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { parseAttachment } from './AttachmentMeta';
|
||||
import { stringifyField } from './ChatFormat';
|
||||
|
||||
/** 分页历史解析结果:消息列表 + 服务端分页元数据 */
|
||||
export interface ParsedHistory {
|
||||
msgs: ChatMessage[];
|
||||
/** 本页首条在服务端全量历史中的下标,作为下次向上翻页的 before 游标 */
|
||||
offset: number;
|
||||
/** 服务端是否还有更早的历史 */
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* 服务端下发的增量游标(响应里的 last_seq)。
|
||||
*
|
||||
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
|
||||
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
|
||||
* (jianf 说的“暴露数据查询 api,前端轮询后 patch 视图”那条路)。
|
||||
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
|
||||
*/
|
||||
lastSeq: number;
|
||||
}
|
||||
|
||||
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
|
||||
export function parseHistoryPayload(
|
||||
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
|
||||
const rawList: Object | undefined = obj['messages'] as Object | undefined;
|
||||
if (rawList === undefined || rawList === null) {
|
||||
return { msgs: [], offset: 0, hasMore: false, lastSeq: 0 };
|
||||
}
|
||||
const arr: Object[] = rawList as Object[];
|
||||
const msgs: ChatMessage[] = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const role: string = item['role'] as string ?? '';
|
||||
const content: string = item['content'] as string ?? '';
|
||||
const att: ChatAttachment | undefined = parseAttachment(item['attachment']);
|
||||
// 纯附件消息 content 可能为空,不能再按"无内容就丢弃"处理
|
||||
if (role.length === 0 || (content.length === 0 && att === undefined)) {
|
||||
continue;
|
||||
}
|
||||
const msg: ChatMessage = {
|
||||
id: alloc(),
|
||||
role: role,
|
||||
content: content,
|
||||
isFinal: true,
|
||||
};
|
||||
// seq:服务端单调递增序号,增量游标与 keyed 对账的定位符。
|
||||
// 缺失(旧后端/本地乐观消息)时保持 undefined,不编造。
|
||||
const seqVal: Object | undefined = item['seq'];
|
||||
if (typeof seqVal === 'number' && (seqVal as number) > 0) {
|
||||
msg.seq = seqVal as number;
|
||||
}
|
||||
if (att !== undefined) {
|
||||
msg.attachment = att;
|
||||
}
|
||||
// 后端 handler.go 保证 history 不裁剪 reasoning_content / tool_calls,
|
||||
// 这里必须还原,否则刷新后思考与工具卡就凭空消失。
|
||||
const rc: string = item['reasoning_content'] as string ?? '';
|
||||
if (rc.length > 0) {
|
||||
msg.reasoningContent = rc;
|
||||
}
|
||||
const tcs: ToolCallInfo[] | undefined = parseHistoryToolCalls(item['tool_calls']);
|
||||
if (tcs !== undefined) {
|
||||
msg.toolCalls = tcs;
|
||||
}
|
||||
// 渠道/设备来源:后端 ChatMsg.source,用于区分 channel_output 等非 webui 消息
|
||||
const src: string = item['source'] as string ?? '';
|
||||
if (src.length > 0) {
|
||||
msg.source = src;
|
||||
}
|
||||
msgs.push(msg);
|
||||
}
|
||||
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
|
||||
const hasMore: boolean = obj['has_more'] === true;
|
||||
// last_seq:增量游标。缺失时回退到本页最大 seq,保证游标不会倒退。
|
||||
let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
const sq: number | undefined = msgs[i].seq;
|
||||
if (sq !== undefined && sq > lastSeq) {
|
||||
lastSeq = sq;
|
||||
}
|
||||
}
|
||||
return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq };
|
||||
}
|
||||
|
||||
/** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */
|
||||
export function parseHistoryToolCalls(raw: Object | undefined): ToolCallInfo[] | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const tcs: ToolCallInfo[] = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const name: string = (item['tool'] as string ?? '') || (item['name'] as string ?? '');
|
||||
if (name.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const tc: ToolCallInfo = {
|
||||
name: name,
|
||||
args: stringifyField(item['args']),
|
||||
result: stringifyField(item['result']),
|
||||
status: item['status'] as string ?? undefined,
|
||||
plugin: item['plugin'] as string ?? undefined,
|
||||
};
|
||||
tcs.push(tc);
|
||||
}
|
||||
return tcs.length > 0 ? tcs : undefined;
|
||||
}
|
||||
206
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets
Normal file
206
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSession.ets
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 发送 / 中断(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:POST /chat 与 POST /chat/file 的请求体、
|
||||
* 兜底消息合并、超时口径都收在这里,输入区组件只负责把文本/附件递进来。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ChatAttachment } from '../model/Model';
|
||||
import { apiClient } from './ApiClient';
|
||||
import { connStore } from './ConnStore';
|
||||
import { userMessage, isTimeout } from './UserError';
|
||||
import { chatStore } from './ChatStore';
|
||||
import { parseAttachment } from './AttachmentMeta';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
|
||||
interface SendChatBody {
|
||||
message: string;
|
||||
client_msg_id: string;
|
||||
/** 非空时后端编码 source = "webui/<device_id>",agent 可见来源设备 */
|
||||
device_id?: string;
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
/** POST /chat/interrupt 的请求体。 */
|
||||
interface InterruptBody {
|
||||
/** true = 停止(立即结束当前推理 + 短路已排队消息);false/省略 = 普通中断。 */
|
||||
stop: boolean;
|
||||
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本发送:POST /chat。
|
||||
* 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。
|
||||
*/
|
||||
export async function sendChatText(text: string): Promise<void> {
|
||||
const trimmed: string = text.trim();
|
||||
if (chatStore.isLoading()) {
|
||||
return;
|
||||
}
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 重置 SSE 标记
|
||||
chatStore.setSseActive(false);
|
||||
|
||||
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: trimmed };
|
||||
chatStore.pushNew(userMsg);
|
||||
chatStore.setLoading(true);
|
||||
chatStore.setStage('等待 AI 回复...');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
|
||||
const bodyObj: SendChatBody = {
|
||||
message: trimmed,
|
||||
client_msg_id: Date.now().toString(36),
|
||||
// 必须带设备身份:后端没有 device_id 就把来源编码成 webui,
|
||||
// agent 会以为消息来自网页端。device_id 非空时后端编码
|
||||
// source = "webui/<device_id>" 并注入设备上下文。
|
||||
device_id: connStore.ensureDeviceId(),
|
||||
device_name: connStore.getDeviceName(),
|
||||
};
|
||||
|
||||
// 如果 SSE 已连接,POST 作为触发器(响应由 SSE 推送渲染);
|
||||
// 仅在 SSE 未推送内容时才用 POST 响应兜底创建消息。
|
||||
try {
|
||||
const resp = await apiClient.postWithTimeout('/chat', bodyObj, 120000);
|
||||
|
||||
// SSE 已经处理了响应,跳过 POST 消息创建
|
||||
if (chatStore.sseActive()) {
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed: Record<string, string> = JSON.parse(resp.body) as Record<string, string>;
|
||||
const respText: string = parsed['response'] ?? '(无响应)';
|
||||
const reasoning: string = parsed['reasoning_content'] ?? '';
|
||||
const last: ChatMessage | null = chatStore.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && !last.isFinal) {
|
||||
last.content = respText;
|
||||
last.isFinal = true;
|
||||
last.isStreaming = false;
|
||||
if (reasoning.length > 0 && last.reasoningContent === undefined) {
|
||||
last.reasoningContent = reasoning;
|
||||
}
|
||||
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
|
||||
// 已有最终消息,合并(不应发生,但防御性处理)
|
||||
if (respText.length > last.content.length) {
|
||||
last.content = respText;
|
||||
}
|
||||
} else {
|
||||
const msg: ChatMessage = {
|
||||
id: chatStore.allocId(),
|
||||
role: 'assistant',
|
||||
content: respText,
|
||||
isFinal: true,
|
||||
};
|
||||
if (reasoning.length > 0) {
|
||||
msg.reasoningContent = reasoning;
|
||||
}
|
||||
chatStore.pushNew(msg);
|
||||
}
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('');
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
} catch (e) {
|
||||
// 超时通常意味着后端仍在生成,不算失败;其余一律显示人话,
|
||||
// 原始错误只进 hilog(之前把 e.message 拼进 chatStage 会把
|
||||
// "Failed to connect to the server."、内网地址直接摆到聊天流里)。
|
||||
if (isTimeout(e)) {
|
||||
chatStore.setStage('请求已发送,等待回复...');
|
||||
} else {
|
||||
chatStore.setStage(userMessage('chat.send', e));
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带附件发送:POST /chat/file(multipart),字段与 WebGUI 一致。
|
||||
* 后端收下后自身会把用户消息与附件写进会话并触发 agent,
|
||||
* 回复照常从 SSE 过来,所以这里不再走 /chat。
|
||||
*/
|
||||
export async function sendChatFile(text: string, path: string, name: string,
|
||||
size: number, isImage: boolean, mime: string): Promise<void> {
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
const att: ChatAttachment = {
|
||||
type: isImage ? 'image' : 'file',
|
||||
// 本地待上传:先用沙箱路径预览,上传成功后替换成服务端 URL
|
||||
url: 'file://' + path,
|
||||
size: size,
|
||||
name: name,
|
||||
};
|
||||
const userMsg: ChatMessage = { id: chatStore.allocId(), role: 'user', content: text };
|
||||
userMsg.attachment = att;
|
||||
chatStore.pushNew(userMsg);
|
||||
chatStore.setLoading(true);
|
||||
chatStore.setStage('正在上传附件...');
|
||||
chatStore.setSseActive(false);
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
|
||||
const parts: http.MultiFormData[] = [
|
||||
{ name: 'file', contentType: mime, remoteFileName: name, filePath: path },
|
||||
{ name: 'message', contentType: 'text/plain', data: text },
|
||||
{ name: 'client_msg_id', contentType: 'text/plain', data: Date.now().toString(36) },
|
||||
{ name: 'device_id', contentType: 'text/plain', data: connStore.ensureDeviceId() },
|
||||
{ name: 'device_name', contentType: 'text/plain', data: connStore.getDeviceName() },
|
||||
];
|
||||
|
||||
try {
|
||||
const resp = await apiClient.postMultipart('/chat/file', parts, 180000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const uploaded: ChatAttachment | undefined = parseAttachment(obj['file']);
|
||||
if (uploaded !== undefined) {
|
||||
userMsg.attachment = uploaded;
|
||||
}
|
||||
chatStore.setStage('等待 AI 回复...');
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.upload', e));
|
||||
chatStore.setLoading(false);
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前生成(停止按钮)。
|
||||
*
|
||||
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
|
||||
* - stop:true(无 message)= ①立即结束当前 LLM 推理(不重试);
|
||||
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
|
||||
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
|
||||
*
|
||||
* 为什么必须带 stop:此前这里 POST 的是 null(空 body),后端把空内容当成
|
||||
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
|
||||
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
|
||||
*/
|
||||
export async function interruptChat(): Promise<void> {
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body: InterruptBody = { stop: true };
|
||||
await apiClient.post('/chat/interrupt', body);
|
||||
} catch (e) {
|
||||
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
|
||||
// 否则按钮会一直停在“停止”态,用户以为没生效。
|
||||
}
|
||||
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
|
||||
// 若后端稍后真的推来终态,SSE 处理器会再刷一次(幂等)。
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('已停止');
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
196
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets
Normal file
196
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets
Normal file
@ -0,0 +1,196 @@
|
||||
/**
|
||||
* SSE 事件 → 聊天流状态的翻译层(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出。这里只认「事件帧」与「往状态里写什么」,
|
||||
* 具体状态由 ChatStreamSink 提供 —— 这样本文件不必 import ChatStore,
|
||||
* 两边不会形成 ArkTS 里被拒绝的模块循环依赖。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { attachmentFromChannelOutput } from './AttachmentMeta';
|
||||
import { SseEvent } from './SseClient';
|
||||
import { stringifyField } from './ChatFormat';
|
||||
import { stageTrail } from './StageTrail';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
/** 聊天流状态机对外暴露的最小写入面(由 common/ChatStore.ets 实现) */
|
||||
export interface ChatStreamSink {
|
||||
allocId(): number;
|
||||
lastMessage(): ChatMessage | null;
|
||||
/** 取/建"未定稿的助手消息" */
|
||||
ensureAssistant(): ChatMessage;
|
||||
/** 取/建同名未完成工具卡 */
|
||||
ensureToolCall(name: string): ToolCallInfo;
|
||||
/** 追加一条新消息并播入场动画 */
|
||||
pushNew(msg: ChatMessage): void;
|
||||
setLoading(v: boolean): void;
|
||||
setStage(s: string): void;
|
||||
setSseActive(v: boolean): void;
|
||||
/** 防抖刷新(合并高频 delta) */
|
||||
refresh(): void;
|
||||
/** 请求滚到底 */
|
||||
requestScroll(): void;
|
||||
/** sync_required:补拉历史 */
|
||||
reloadHistory(): void;
|
||||
}
|
||||
|
||||
export function applyChatSse(ev: SseEvent, sink: ChatStreamSink): void {
|
||||
try {
|
||||
// 服务端 data 字段是完整 sdk.Event:{type, source, payload, timestamp}
|
||||
// 业务字段全部在 payload 之下,历史实现直接读顶层导致流式/思考/工具调用全部失效。
|
||||
const frame: Record<string, Object> = JSON.parse(ev.data) as Record<string, Object>;
|
||||
const inner: Object | undefined = frame['payload'];
|
||||
const payload: Record<string, Object> =
|
||||
inner !== undefined && inner !== null ? inner as Record<string, Object> : frame;
|
||||
const frameType: string = frame['type'] as string ?? '';
|
||||
const type: string = ev.event.length > 0 ? ev.event : frameType;
|
||||
// 诊断只记事件类型(内容可能含隐私,不落盘)
|
||||
hilog.debug(0x0000, 'HomeAgent', 'sse %{public}s', type);
|
||||
|
||||
if (type === 'agent_output') {
|
||||
const content: string = payload['content'] as string ?? '';
|
||||
// channel_output 携带图片/文件:url/size/output_type 三个字段在 payload 顶层,
|
||||
// 它是一条独立的附件消息,不能合并进上一条文本气泡。
|
||||
const kind: string = payload['kind'] as string ?? '';
|
||||
if (kind === 'channel_output') {
|
||||
const att: ChatAttachment | undefined = attachmentFromChannelOutput(
|
||||
payload['output_type'] as string ?? '',
|
||||
payload['url'] as string ?? '',
|
||||
payload['size'] as number ?? 0);
|
||||
if (att !== undefined) {
|
||||
const amsg: ChatMessage = {
|
||||
id: sink.allocId(),
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
isFinal: true,
|
||||
source: 'channel',
|
||||
attachment: att,
|
||||
};
|
||||
sink.pushNew(amsg);
|
||||
sink.setLoading(false);
|
||||
sink.setStage('');
|
||||
sink.setSseActive(false);
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const last: ChatMessage | null = sink.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && !last.isFinal) {
|
||||
last.content = content;
|
||||
last.isFinal = true;
|
||||
last.isStreaming = false;
|
||||
} else if (last !== null && last.role === 'assistant' && last.isFinal) {
|
||||
// POST 已经创建了最终消息,仅合并内容(如果有增量)
|
||||
if (content.length > last.content.length) {
|
||||
last.content = content;
|
||||
}
|
||||
} else {
|
||||
const msg: ChatMessage = {
|
||||
id: sink.allocId(),
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
isFinal: true,
|
||||
};
|
||||
sink.pushNew(msg);
|
||||
}
|
||||
sink.setLoading(false);
|
||||
sink.setStage('');
|
||||
sink.setSseActive(false);
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
} else if (type === 'reasoning') {
|
||||
const rc: string = payload['content'] as string ?? '';
|
||||
if (rc.length > 0) {
|
||||
sink.setStage('AI 思考中...');
|
||||
// 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
|
||||
// 此时还没有"未完成的助手消息",必须新建一条,否则思考内容直接丢失。
|
||||
const last: ChatMessage = sink.ensureAssistant();
|
||||
last.reasoningContent = rc;
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
}
|
||||
} else if (type === 'sync_required') {
|
||||
// 断线重连时服务端要求补拉历史(ring 里没有可重放的聚合事件)
|
||||
sink.reloadHistory();
|
||||
} else if (type === 'agent_error') {
|
||||
// 后端错误一律转人话,技术细节不上 UI
|
||||
sink.setLoading(false);
|
||||
sink.setStage('本轮处理失败,请重试');
|
||||
sink.refresh();
|
||||
} else if (type === 'content_delta') {
|
||||
const delta: string = payload['content'] as string ?? '';
|
||||
if (delta.length > 0) {
|
||||
sink.setSseActive(true);
|
||||
const last: ChatMessage = sink.ensureAssistant();
|
||||
last.content += delta;
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
}
|
||||
} else if (type === 'reasoning_delta') {
|
||||
const delta: string = payload['content'] as string ?? '';
|
||||
if (delta.length > 0) {
|
||||
sink.setSseActive(true);
|
||||
sink.setStage('AI 思考中...');
|
||||
const last: ChatMessage = sink.ensureAssistant();
|
||||
if (last.reasoningContent === undefined) {
|
||||
last.reasoningContent = '';
|
||||
}
|
||||
last.reasoningContent += delta;
|
||||
sink.refresh();
|
||||
}
|
||||
} else if (type === 'tool_call') {
|
||||
const toolName: string = payload['tool'] as string ?? '';
|
||||
const toolStatus: string = payload['status'] as string ?? '';
|
||||
const toolPlugin: string = payload['plugin'] as string ?? '';
|
||||
if (toolName.length > 0) {
|
||||
sink.setStage('工具调用: ' + toolName);
|
||||
const target: ToolCallInfo = sink.ensureToolCall(toolName);
|
||||
if (toolPlugin.length > 0) {
|
||||
target.plugin = toolPlugin;
|
||||
}
|
||||
const argsText: string = stringifyField(payload['args']);
|
||||
if (argsText.length > 0) {
|
||||
target.args = argsText;
|
||||
}
|
||||
if (toolStatus.length > 0) {
|
||||
// 后端只在工具执行【结束】时发 tool_call(status=ok/denied/interrupted),
|
||||
// 所以拿到 status 就意味着这次调用已收尾,result 一并落卡。
|
||||
target.status = toolStatus;
|
||||
target.result = stringifyField(payload['result']);
|
||||
} else {
|
||||
target.status = 'running';
|
||||
}
|
||||
sink.refresh();
|
||||
sink.requestScroll();
|
||||
}
|
||||
} else if (type === 'stage') {
|
||||
const phase: string = payload['phase'] as string ?? '';
|
||||
const channel: string = payload['channel'] as string ?? '';
|
||||
const stageTool: string = payload['tool'] as string ?? '';
|
||||
if (channel !== '_consolidation_') {
|
||||
// 运行态面板的阶段管道靠这条轨迹活着:先喂轨迹,再管聊天侧的角标。
|
||||
// 两者是独立消费者,轨迹不依赖任何聊天状态。
|
||||
stageTrail.onStage(phase, stageTool);
|
||||
if (phase === 'pre_action') {
|
||||
sink.setStage('AI 思考中...');
|
||||
} else if (phase === 'before_toolcall') {
|
||||
sink.setStage('工具调用: ' + stageTool);
|
||||
// 关键:tool_call 事件只在执行【结束】后才发,所以"调用中"这一态
|
||||
// 必须由 before_toolcall 建卡,否则用户永远看不到工具正在跑。
|
||||
if (stageTool.length > 0) {
|
||||
const tc: ToolCallInfo = sink.ensureToolCall(stageTool);
|
||||
if (tc.status === undefined) {
|
||||
tc.status = 'running';
|
||||
}
|
||||
}
|
||||
} else if (phase === 'before_output') {
|
||||
sink.setStage('生成回复中...');
|
||||
}
|
||||
sink.refresh();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
602
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets
Normal file
602
cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatStore.ets
Normal file
@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 聊天流状态源(单例)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出:消息数组、分页游标、"哪几条是新消息"、
|
||||
* SSE 连接与历史拉取都属于同一个状态机;页面只剩渲染与输入。
|
||||
*
|
||||
* 为什么数组不进 AppStorage:StatusStore 已经踩过一次 —— 数组同步语义不可靠。
|
||||
* 这里沿用同一套做法:数组留在 store 内部,标量走 AppStorage 广播,
|
||||
* 另加一个自增版本号 K_CHAT_REV 通知订阅组件"重取一次快照"。
|
||||
*
|
||||
* 订阅组件的接法(见 components/ChatStream.ets):
|
||||
* @StorageProp(K_CHAT_REV) @Watch('onRev') private rev: number = 0;
|
||||
* onRev(): void { this.messages = chatStore.messages(); }
|
||||
* ForEach 拿到的仍然是"每次刷新一个新数组引用",与拆分前
|
||||
* (this.messages = this.messages.slice())的渲染语义完全一致。
|
||||
*
|
||||
* SSE 事件的翻译在 common/ChatSse.ets:本类实现它的 ChatStreamSink 接口,
|
||||
* 依赖方向只有"ChatStore → ChatSse"一条,不构成循环。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { SseClient, SseEvent } from './SseClient';
|
||||
import { connStore } from './ConnStore';
|
||||
import { apiClient } from './ApiClient';
|
||||
import { CHAT_PAGE_SIZE } from './Constants';
|
||||
import { ParsedHistory, parseHistoryPayload } from './ChatHistory';
|
||||
import { applyChatSse, ChatStreamSink } from './ChatSse';
|
||||
|
||||
// ===== AppStorage 键:页面/聊天流/输入区共用 =====
|
||||
export const K_CHAT_REV: string = 'chatRev';
|
||||
export const K_CHAT_SCROLL_REV: string = 'chatScrollRev';
|
||||
export const K_CHAT_LOADING: string = 'chatBusy';
|
||||
export const K_CHAT_STAGE: string = 'chatStageText';
|
||||
export const K_CHAT_CONNECTED: string = 'chatSseUp';
|
||||
|
||||
const SSE_RECONNECT_MS: number = 5000;
|
||||
/** 增量轮询间隔:与 WebUI 的 chatTicker 一致(3s)。 */
|
||||
const CHAT_POLL_MS: number = 3000;
|
||||
|
||||
/**
|
||||
* 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的
|
||||
* applyServerMessages 同口径)。
|
||||
*
|
||||
* 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把
|
||||
* 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把
|
||||
* 其中一条误判成重复而丢弃(“App 发出的消息不显示”就是这个表现)。
|
||||
* seq 是服务端分配的唯一序号,才是可靠的定位符。
|
||||
*
|
||||
* 规则:
|
||||
* - 服务端消息带 seq:本地已有同 seq → 原地更新(工具卡/最终文本是
|
||||
* 原地改的,不产生新 seq,只靠 after 拿不到,必须靠尾部探测更新);
|
||||
* 本地没有 → 追加。
|
||||
* - 服务端消息无 seq(旧后端):退化为「本地末尾同角色同内容则认领」。
|
||||
* - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq),
|
||||
* 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。
|
||||
*
|
||||
* tailOnly:只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新),
|
||||
* 避免把历史中间的消息插进来造成顺序错乱。
|
||||
*/
|
||||
function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[],
|
||||
tailOnly: boolean, alloc: () => number): ChatMessage[] {
|
||||
const out: ChatMessage[] = local.slice();
|
||||
for (let i = 0; i < incoming.length; i++) {
|
||||
const sm: ChatMessage = incoming[i];
|
||||
const sq: number | undefined = sm.seq;
|
||||
let found: number = -1;
|
||||
if (sq !== undefined) {
|
||||
// 从尾部往前找:新消息总在尾部,省掉全表扫描
|
||||
for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) {
|
||||
if (out[j].seq === sq) {
|
||||
found = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found >= 0) {
|
||||
// 原地更新:保留本地 id(组件按 id 复用,不重建气泡),
|
||||
// 只覆盖服务端权威字段。
|
||||
const prev: ChatMessage = out[found];
|
||||
if (prev.content !== sm.content) {
|
||||
prev.content = sm.content;
|
||||
}
|
||||
if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) {
|
||||
prev.reasoningContent = sm.reasoningContent;
|
||||
}
|
||||
if (sm.toolCalls !== undefined) {
|
||||
prev.toolCalls = sm.toolCalls;
|
||||
}
|
||||
if (sm.attachment !== undefined) {
|
||||
prev.attachment = sm.attachment;
|
||||
}
|
||||
if (sm.source !== undefined) {
|
||||
prev.source = sm.source;
|
||||
}
|
||||
prev.isFinal = true;
|
||||
prev.isStreaming = false;
|
||||
continue;
|
||||
}
|
||||
if (tailOnly) {
|
||||
// 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间)
|
||||
let maxLocalSeq: number = 0;
|
||||
for (let j = 0; j < out.length; j++) {
|
||||
const ls: number | undefined = out[j].seq;
|
||||
if (ls !== undefined && ls > maxLocalSeq) {
|
||||
maxLocalSeq = ls;
|
||||
}
|
||||
}
|
||||
if (sq !== undefined && sq > maxLocalSeq) {
|
||||
sm.id = alloc();
|
||||
out.push(sm);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。
|
||||
if (sq !== undefined) {
|
||||
let claimed: number = -1;
|
||||
for (let j = out.length - 1; j >= 0; j--) {
|
||||
const lm: ChatMessage = out[j];
|
||||
if (lm.seq !== undefined) {
|
||||
break;
|
||||
}
|
||||
if (lm.role === sm.role) {
|
||||
claimed = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (claimed >= 0) {
|
||||
const prev: ChatMessage = out[claimed];
|
||||
prev.seq = sq;
|
||||
prev.isFinal = true;
|
||||
prev.isStreaming = false;
|
||||
if (sm.source !== undefined) {
|
||||
prev.source = sm.source;
|
||||
}
|
||||
if (sm.attachment !== undefined) {
|
||||
prev.attachment = sm.attachment;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
sm.id = alloc();
|
||||
out.push(sm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class ChatStore implements ChatStreamSink {
|
||||
private msgs: ChatMessage[] = [];
|
||||
private nextId: number = 1;
|
||||
private refreshTimer: number = -1;
|
||||
private reconnectTimer: number = -1;
|
||||
/** 分页历史:当前已加载消息在服务端全量中的起始下标 */
|
||||
private offset: number = 0;
|
||||
/** 是否还有更早历史可向上加载 */
|
||||
private hasEarlier: boolean = false;
|
||||
private loadingOlder: boolean = false;
|
||||
/** 正在为新消息播入场动画的 id */
|
||||
private newIds: number[] = [];
|
||||
// SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息
|
||||
private sseActiveForTurn: boolean = false;
|
||||
/** 增量游标:本地已知的最大服务端 seq(对应 WebUI 的 state.chatLastSeq) */
|
||||
private lastSeq: number = 0;
|
||||
/** 增量轮询中进行中,避免重入 */
|
||||
private polling: boolean = false;
|
||||
private pollTimer: number = -1;
|
||||
private sse: SseClient = new SseClient();
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_CHAT_LOADING, false);
|
||||
AppStorage.setOrCreate<string>(K_CHAT_STAGE, '');
|
||||
AppStorage.setOrCreate<boolean>(K_CHAT_CONNECTED, false);
|
||||
AppStorage.setOrCreate<number>(K_CHAT_REV, 0);
|
||||
AppStorage.setOrCreate<number>(K_CHAT_SCROLL_REV, 0);
|
||||
}
|
||||
|
||||
// ===================== 读取 =====================
|
||||
|
||||
messages(): ChatMessage[] {
|
||||
return this.msgs;
|
||||
}
|
||||
|
||||
findMessage(id: number): ChatMessage | undefined {
|
||||
for (let i = 0; i < this.msgs.length; i++) {
|
||||
if (this.msgs[i].id === id) {
|
||||
return this.msgs[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
lastMessage(): ChatMessage | null {
|
||||
if (this.msgs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.msgs[this.msgs.length - 1];
|
||||
}
|
||||
|
||||
isFresh(id: number): boolean {
|
||||
return this.newIds.indexOf(id) >= 0;
|
||||
}
|
||||
|
||||
hasMore(): boolean {
|
||||
return this.hasEarlier;
|
||||
}
|
||||
|
||||
isFetchingOlder(): boolean {
|
||||
return this.loadingOlder;
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return AppStorage.get<boolean>(K_CHAT_LOADING) ?? false;
|
||||
}
|
||||
|
||||
setLoading(v: boolean): void {
|
||||
AppStorage.set<boolean>(K_CHAT_LOADING, v);
|
||||
}
|
||||
|
||||
setStage(s: string): void {
|
||||
AppStorage.set<string>(K_CHAT_STAGE, s);
|
||||
}
|
||||
|
||||
setConnected(v: boolean): void {
|
||||
AppStorage.set<boolean>(K_CHAT_CONNECTED, v);
|
||||
}
|
||||
|
||||
sseActive(): boolean {
|
||||
return this.sseActiveForTurn;
|
||||
}
|
||||
|
||||
setSseActive(v: boolean): void {
|
||||
this.sseActiveForTurn = v;
|
||||
}
|
||||
|
||||
/** 聊天流里最后一个带附件的消息:宽屏进入 Split 时用它填充右栏 */
|
||||
latestAttachment(): ChatAttachment | undefined {
|
||||
for (let i = this.msgs.length - 1; i >= 0; i--) {
|
||||
const a: ChatAttachment | undefined = this.msgs[i].attachment;
|
||||
if (a !== undefined) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ===================== 列表变更 =====================
|
||||
|
||||
allocId(): number {
|
||||
return this.nextId++;
|
||||
}
|
||||
|
||||
/** 追加一条消息并播入场动画 */
|
||||
pushNew(msg: ChatMessage): void {
|
||||
this.msgs.push(msg);
|
||||
this.markNew(msg.id);
|
||||
}
|
||||
|
||||
/** 标记新消息,触发入场动画 */
|
||||
markNew(msgId: number): void {
|
||||
const arr: number[] = this.newIds.slice();
|
||||
arr.push(msgId);
|
||||
this.newIds = arr;
|
||||
// 这里不切片也不广播:与拆分前一致,入场动画的开场交给紧随其后的
|
||||
// refresh()(50ms 防抖后换新数组引用)那一次一起触发。
|
||||
setTimeout(() => {
|
||||
const idx: number = this.newIds.indexOf(msgId);
|
||||
if (idx >= 0) {
|
||||
const updated: number[] = this.newIds.slice();
|
||||
updated.splice(idx, 1);
|
||||
this.newIds = updated;
|
||||
this.forceRefresh();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前助手消息里名为 name 的未完成工具卡,没有就建一张。
|
||||
* 顺带保证一定存在一条"未定稿的助手消息"来挂这些卡。
|
||||
*/
|
||||
ensureToolCall(name: string): ToolCallInfo {
|
||||
const last: ChatMessage = this.ensureAssistant();
|
||||
if (last.toolCalls === undefined) {
|
||||
last.toolCalls = [];
|
||||
}
|
||||
for (let i = 0; i < last.toolCalls.length; i++) {
|
||||
const t: ToolCallInfo = last.toolCalls[i];
|
||||
// 只复用"仍在执行"的同名卡:同一轮里同名工具被多次调用时,
|
||||
// 已完成的那张不能被后来的调用覆盖。
|
||||
const st: string | undefined = t.status;
|
||||
if (t.name === name && (st === undefined || st.length === 0 || st === 'running')) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
const created: ToolCallInfo = { name: name, args: '' };
|
||||
last.toolCalls.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证存在一条"未定稿的助手消息",返回它。
|
||||
* 聚合 reasoning 可能先于任何 delta 到达(非流式后端就只有这一条),
|
||||
* 此时还没有"未完成的助手消息",必须新建一条,否则内容直接丢失。
|
||||
*/
|
||||
ensureAssistant(): ChatMessage {
|
||||
const last: ChatMessage | null = this.lastMessage();
|
||||
if (last !== null && last.role === 'assistant' && last.isFinal !== true) {
|
||||
return last;
|
||||
}
|
||||
const msg: ChatMessage = {
|
||||
id: this.allocId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
isFinal: false,
|
||||
};
|
||||
this.pushNew(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 流式/高频变更的通知信号:让 ChatStream 滚到底 */
|
||||
requestScroll(): void {
|
||||
const cur: number = AppStorage.get<number>(K_CHAT_SCROLL_REV) ?? 0;
|
||||
AppStorage.set<number>(K_CHAT_SCROLL_REV, cur + 1);
|
||||
}
|
||||
|
||||
/** 防抖刷新:合并高频 SSE delta,最多 ~20fps */
|
||||
refresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = -1;
|
||||
this.msgs = this.msgs.slice();
|
||||
this.bump();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/** 强制立即刷新(用于状态切换等需要即时响应的场景) */
|
||||
forceRefresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = -1;
|
||||
}
|
||||
this.msgs = this.msgs.slice();
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_CHAT_REV) ?? 0;
|
||||
AppStorage.set<number>(K_CHAT_REV, cur + 1);
|
||||
}
|
||||
|
||||
cancelRefresh(): void {
|
||||
if (this.refreshTimer >= 0) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 折叠开关 =====================
|
||||
|
||||
reasoningOpen(msgId: number): boolean {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
return m !== undefined && m.reasoningOpen === true;
|
||||
}
|
||||
|
||||
setReasoningOpen(msgId: number, open: boolean): void {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m !== undefined) {
|
||||
m.reasoningOpen = open;
|
||||
}
|
||||
}
|
||||
|
||||
toolOpen(msgId: number, index: number): boolean {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m === undefined || m.toolCalls === undefined || index >= m.toolCalls.length) {
|
||||
return false;
|
||||
}
|
||||
return m.toolCalls[index].open === true;
|
||||
}
|
||||
|
||||
setToolOpen(msgId: number, index: number, open: boolean): void {
|
||||
const m: ChatMessage | undefined = this.findMessage(msgId);
|
||||
if (m !== undefined && m.toolCalls !== undefined && index < m.toolCalls.length) {
|
||||
m.toolCalls[index].open = open;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 历史 =====================
|
||||
|
||||
private parseHistory(obj: Record<string, Object>): ParsedHistory {
|
||||
return parseHistoryPayload(obj, () => this.allocId());
|
||||
}
|
||||
|
||||
async loadHistory(): Promise<void> {
|
||||
try {
|
||||
// 分段懒加载:首屏只拉最新 CHAT_PAGE_SIZE 条,向上滚动触顶再拉更早的。
|
||||
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
// 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退,
|
||||
// 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。
|
||||
if (this.msgs.length === 0) {
|
||||
this.msgs = parsed.msgs;
|
||||
} else {
|
||||
// 与本地未回显的消息按 seq 对账,而不是整表替换。
|
||||
//
|
||||
// 为什么:sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争;
|
||||
// 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失
|
||||
// (“客户端侧发出的消息不显示”)。
|
||||
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
|
||||
() => this.allocId());
|
||||
}
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
// 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。
|
||||
if (parsed.lastSeq > this.lastSeq) {
|
||||
this.lastSeq = parsed.lastSeq;
|
||||
}
|
||||
this.forceRefresh();
|
||||
this.requestScroll();
|
||||
this.startPolling();
|
||||
} catch (e) {
|
||||
// ignore history load failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。
|
||||
*
|
||||
* 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供
|
||||
* after=<seq> 游标(commit 9711177),WebUI 前端据此 3s 轮询增量并 patch
|
||||
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
|
||||
* 永远进不来**(页面不会加载新的聊天信息)。
|
||||
*
|
||||
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
|
||||
* 不会产生新 seq,单靠 after 拿不到这些更新。
|
||||
*/
|
||||
async pollIncremental(): Promise<void> {
|
||||
if (this.polling) {
|
||||
return;
|
||||
}
|
||||
this.polling = true;
|
||||
try {
|
||||
if (this.lastSeq <= 0) {
|
||||
// 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。
|
||||
this.polling = false;
|
||||
await this.loadHistory();
|
||||
return;
|
||||
}
|
||||
const resp = await apiClient.getWithTimeout(
|
||||
'/chat/history?after=' + this.lastSeq, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
let changed: boolean = false;
|
||||
if (parsed.msgs.length > 0) {
|
||||
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
|
||||
() => this.allocId());
|
||||
changed = true;
|
||||
}
|
||||
if (parsed.lastSeq > this.lastSeq) {
|
||||
this.lastSeq = parsed.lastSeq;
|
||||
}
|
||||
// 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。
|
||||
try {
|
||||
const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000);
|
||||
const tailObj: Record<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
|
||||
const tail: ParsedHistory = this.parseHistory(tailObj);
|
||||
if (tail.msgs.length > 0) {
|
||||
const before: number = this.msgs.length;
|
||||
this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true,
|
||||
() => this.allocId());
|
||||
if (this.msgs.length !== before) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 尾部探测失败不影响增量结果
|
||||
}
|
||||
if (changed) {
|
||||
this.forceRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
// 轮询失败静默:下一拍会重试(SSE 仍在负责流式渲染)
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */
|
||||
startPolling(): void {
|
||||
if (this.pollTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.pollIncremental();
|
||||
}, CHAT_POLL_MS);
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
if (this.pollTimer >= 0) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
|
||||
* 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。
|
||||
*/
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.loadingOlder || !this.hasEarlier) {
|
||||
return;
|
||||
}
|
||||
this.loadingOlder = true;
|
||||
try {
|
||||
const before: number = this.offset;
|
||||
if (before <= 0) {
|
||||
this.hasEarlier = false;
|
||||
return;
|
||||
}
|
||||
const resp = await apiClient.getWithTimeout(
|
||||
'/chat/history?limit=' + CHAT_PAGE_SIZE + '&before=' + before, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
if (parsed.msgs.length === 0) {
|
||||
this.hasEarlier = false;
|
||||
return;
|
||||
}
|
||||
// 前置插入新页(更早的在前),追加到当前列表头部;id 用新分配的避免与新消息撞号
|
||||
this.msgs = parsed.msgs.concat(this.msgs);
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
// 失败保留 hasEarlier,允许下次滚动重试
|
||||
} finally {
|
||||
this.loadingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** sync_required:断线重连后服务端要求补拉历史 */
|
||||
reloadHistory(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
// ===================== SSE 连接 =====================
|
||||
|
||||
connect(): void {
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
// 增量轮询与 SSE 同时拉起:SSE 负责 token 级流式观感,
|
||||
// 轮询负责「界面最终状态」——两者是两条腿,缺一不可
|
||||
// (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。
|
||||
// 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。
|
||||
this.startPolling();
|
||||
this.sse.close();
|
||||
this.sse.connect(cur, '/chat/events',
|
||||
(ev: SseEvent) => {
|
||||
applyChatSse(ev, this);
|
||||
},
|
||||
() => {
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
() => {
|
||||
this.setConnected(true);
|
||||
}).catch(() => {
|
||||
this.setConnected(false);
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = -1;
|
||||
this.connect();
|
||||
}, SSE_RECONNECT_MS);
|
||||
}
|
||||
|
||||
cancelReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面消失:断线、停表,避免后台空转 */
|
||||
disconnect(): void {
|
||||
this.cancelReconnect();
|
||||
this.stopPolling();
|
||||
this.sse.close();
|
||||
this.cancelRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
export const chatStore: ChatStore = new ChatStore();
|
||||
@ -17,6 +17,18 @@ export const DEFAULT_WS_PORT: number = 9890;
|
||||
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
||||
export const CHAT_PAGE_SIZE: number = 40;
|
||||
|
||||
// ===== AppStorage 跨页面信号键 =====
|
||||
//
|
||||
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
|
||||
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
|
||||
// 中间还隔着 Index,逐层传回调会把两个无关页面耦在一起。
|
||||
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
|
||||
export const K_HAS_CONN: string = 'hasConn';
|
||||
/** 外部请求切换主 Tab(-1 = 无请求),由 Index 监听 */
|
||||
export const K_REQUESTED_TAB: string = 'requestedTab';
|
||||
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
|
||||
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
|
||||
|
||||
// ===== 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)';
|
||||
|
||||
@ -1,67 +1,8 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
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;
|
||||
}
|
||||
|
||||
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>;
|
||||
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
|
||||
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
|
||||
bridgeChunkSlices } from './BridgeProtocol';
|
||||
|
||||
export class DeviceBridgeClient {
|
||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
@ -247,33 +188,12 @@ export class DeviceBridgeClient {
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: this.hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.1',
|
||||
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));
|
||||
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
|
||||
this.hostname, authorized));
|
||||
}
|
||||
|
||||
private sendBind(): void {
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: this.deviceId,
|
||||
token: this.token,
|
||||
};
|
||||
this.send(JSON.stringify(bind));
|
||||
this.send(bridgeBindFrame(this.deviceId, this.token));
|
||||
}
|
||||
|
||||
// ===== 命令处理 =====
|
||||
@ -327,6 +247,11 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
|
||||
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
|
||||
if (res.chunked === true) {
|
||||
return;
|
||||
}
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||
@ -334,32 +259,16 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
|
||||
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));
|
||||
this.send(bridgeResultFrame(reqId, status, output, errMsg));
|
||||
}
|
||||
|
||||
// ===== 二进制分块回传(协议与 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;
|
||||
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
|
||||
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
|
||||
try {
|
||||
this.ws.send(ab).catch(() => {
|
||||
// ignore per-chunk failure; end frame reports error below
|
||||
@ -368,32 +277,15 @@ export class DeviceBridgeClient {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
this.send(JSON.stringify(endMsg));
|
||||
this.send(bridgeDataEndFrame(reqId));
|
||||
}
|
||||
|
||||
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));
|
||||
this.send(bridgeEventFrame(this.deviceId, eventType, detail));
|
||||
}
|
||||
|
||||
sendStatus(status: string): void {
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': this.deviceId,
|
||||
'status': status,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
this.send(bridgeStatusFrame(this.deviceId, status));
|
||||
}
|
||||
|
||||
send(text: string): void {
|
||||
|
||||
73
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets
Normal file
73
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 设备页的纯逻辑:本机 device_id 兜底与在线设备列表解析。
|
||||
*
|
||||
* 从 pages/DevicePage.ets 抽出(非 UI,可被其它页面/桥复用)。
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { connStore } from './ConnStore';
|
||||
|
||||
// ===== 二级页面路由 id(页面与一级入口列表共用)=====
|
||||
export const SUB_NONE: string = '';
|
||||
export const SUB_LOCAL: string = 'local';
|
||||
export const SUB_CAPS: string = 'caps';
|
||||
export const SUB_GATEWAY: string = 'gateway';
|
||||
export const SUB_LIST: string = 'list';
|
||||
|
||||
/**
|
||||
* 本机 device_id:桥里已有就用桥的,其次读持久化,都没有则生成一个并落盘。
|
||||
* 生成后必须持久化,否则每次冷启动换 id,网关侧会累积成一堆幽灵设备。
|
||||
*
|
||||
* 判定顺序与原 DevicePage.aboutToAppear 一致:桥的 id 优先于持久化的 id。
|
||||
*/
|
||||
export function resolveDeviceId(bridgeId: string): string {
|
||||
let id: string = bridgeId;
|
||||
if (id.length === 0) {
|
||||
id = connStore.getDeviceId();
|
||||
}
|
||||
if (id.length === 0) {
|
||||
id = 'ohos-' + Date.now().toString(36);
|
||||
try {
|
||||
connStore.saveDeviceId(id);
|
||||
} catch (e) {
|
||||
// ignore persist failure
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 /device/online 响应体。
|
||||
*
|
||||
* apiClient 已自动前置 /api/v1,调用方只写其后的部分
|
||||
* (否则会拼成 /api/v1/api/v1/device/online 并 404)。
|
||||
*/
|
||||
export function parseOnlineDevices(parsed: Record<string, Object>): DeviceInfo[] {
|
||||
const devs: Object = parsed['devices'];
|
||||
const list: DeviceInfo[] = [];
|
||||
if (devs === undefined || devs === null) {
|
||||
return list;
|
||||
}
|
||||
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);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
282
cmd/ohos/HomeAgent/entry/src/main/ets/common/MarkdownParser.ets
Normal file
282
cmd/ohos/HomeAgent/entry/src/main/ets/common/MarkdownParser.ets
Normal file
@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Markdown 解析器(无 UI 依赖)。
|
||||
*
|
||||
* 从 components/StaticMarkdown.ets 抽出:解析与渲染分家后,
|
||||
* 解析规则可以单独被复用/测试,渲染组件也回到可读长度。
|
||||
*
|
||||
* 覆盖:标题、段落、代码围栏、无序/有序列表、引用块、分隔线、表格,
|
||||
* 以及行内的粗体/斜体/行内代码/链接。
|
||||
*/
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
/** 表格行 → 单元格数组(去掉首尾空串产生的空单元格)。 */
|
||||
function parseTableRow(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;
|
||||
}
|
||||
|
||||
// ── 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 headers: string[] = parseTableRow(tLines[0]);
|
||||
const rows: string[][] = [];
|
||||
for (let k = 1; k < tLines.length; k++) {
|
||||
if (!isTableSep(tLines[k].trim())) {
|
||||
rows.push(parseTableRow(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;
|
||||
}
|
||||
174
cmd/ohos/HomeAgent/entry/src/main/ets/common/PluginApi.ets
Normal file
174
cmd/ohos/HomeAgent/entry/src/main/ets/common/PluginApi.ets
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 插件数据获取(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出:三处接口的取数、合并口径与字段解析
|
||||
* 都放在这里,页面只负责把结果落到 @State。
|
||||
*
|
||||
* 数据源对齐 WebGUI renderPlugins:
|
||||
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
|
||||
* - GET /plugins → 已安装外部插件元数据(version/description 等)
|
||||
* - GET /plugins/disabled → {disabled:[{name,...}]}
|
||||
* 三方按名称合并去重排序。
|
||||
*/
|
||||
|
||||
import { apiClient } from './ApiClient';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
|
||||
/** 拉取并合并插件列表;失败时抛错,由调用方转成人话提示。 */
|
||||
export async function fetchPluginRows(): Promise<PluginRow[]> {
|
||||
// ---- kernel: loaded plugins + tool ownership ----
|
||||
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
|
||||
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
|
||||
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
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 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);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||
* 内置插件不在 /plugins 里,取不到详情时由调用方退回列表已有信息。
|
||||
*/
|
||||
export async function fetchPluginDetail(name: string): Promise<PluginDetail> {
|
||||
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 = strArray(o['tags']);
|
||||
d.files = strArray(o['files']);
|
||||
return d;
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 插件状态展示口径(纯函数,无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出:列表行与详情页共用同一套状态判定/文案/配色,
|
||||
* 抽成模块后两处不会再各写一份。
|
||||
*
|
||||
* 状态口径与 WebGUI 一致:已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。
|
||||
*/
|
||||
|
||||
import { PluginRow } from '../model/Model';
|
||||
|
||||
/** 'loaded' | 'pending' | 'disabled' | 'notloaded' */
|
||||
export function pluginStatusOf(plugin: PluginRow): string {
|
||||
if (plugin.loaded && !plugin.disabled) {
|
||||
return 'loaded'; // 已加载
|
||||
}
|
||||
if (plugin.loaded && plugin.disabled) {
|
||||
return 'pending'; // 运行中(禁用待生效)
|
||||
}
|
||||
if (plugin.disabled) {
|
||||
return 'disabled'; // 已禁用
|
||||
}
|
||||
return 'notloaded'; // 未加载
|
||||
}
|
||||
|
||||
export function pluginStatusText(plugin: PluginRow): string {
|
||||
const s: string = pluginStatusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '待生效';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '已禁用';
|
||||
}
|
||||
return '未加载';
|
||||
}
|
||||
|
||||
/** 状态点颜色;未加载态用主题里的弱化色(mutedColor 由调用方从 palette 取)。 */
|
||||
export function pluginStatusColor(plugin: PluginRow, mutedColor: string): string {
|
||||
const s: string = pluginStatusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '#17A964';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '#D99A2B';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '#E84026';
|
||||
}
|
||||
return mutedColor;
|
||||
}
|
||||
|
||||
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
|
||||
export function pluginRowSubtitle(plugin: PluginRow): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(pluginStatusText(plugin));
|
||||
parts.push(plugin.external ? '外部' : '内置');
|
||||
if (plugin.tools !== undefined && plugin.tools.length > 0) {
|
||||
parts.push(plugin.tools.length.toString() + ' 工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** 详情页状态行:状态 + 内置/外部 + 已废弃 + 工具数(顺序与原实现一致) */
|
||||
export function pluginDetailStatusLine(row: PluginRow, deprecated: boolean): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(pluginStatusText(row));
|
||||
parts.push(row.external ? '外部' : '内置');
|
||||
if (deprecated) {
|
||||
parts.push('已废弃');
|
||||
}
|
||||
if (row.tools !== undefined && row.tools.length > 0) {
|
||||
parts.push(row.tools.length.toString() + ' 个工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
182
cmd/ohos/HomeAgent/entry/src/main/ets/common/ScreensueHtml.ets
Normal file
182
cmd/ohos/HomeAgent/entry/src/main/ets/common/ScreensueHtml.ets
Normal file
@ -0,0 +1,182 @@
|
||||
/**
|
||||
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
|
||||
*
|
||||
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
|
||||
* 超出工程「单文件 ≤400 行」的约定;而「screensue 内容怎么解析、怎么渲染」
|
||||
* 与「设备能力怎么实现」本就是两件事。
|
||||
*/
|
||||
|
||||
import { util } from '@kit.ArkTS';
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||
|
||||
export interface ScreensuePayload {
|
||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||
const firstSpace: RegExp = new RegExp('\\s');
|
||||
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||
const splitAt: number = rest.search(firstSpace);
|
||||
if (splitAt > 0) {
|
||||
const first: string = rest.substring(0, splitAt);
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (digits.test(first)) {
|
||||
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||
}
|
||||
}
|
||||
p.content = rest;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 agent 下发的 screensue 内容是不是 HTML。
|
||||
*
|
||||
* 服务端两侧协议都允许 HTML(localuse 的 local_screensue 在 Linux 用 browsh/w3m
|
||||
* 渲染 HTML;remotedevice 的工具说明写的就是「显示内容/HTML」)。
|
||||
*
|
||||
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
|
||||
* (`'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
|
||||
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
|
||||
*
|
||||
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
|
||||
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
|
||||
*/
|
||||
export function looksLikeHtml(content: string): boolean {
|
||||
return findHtmlStart(content) >= 0;
|
||||
}
|
||||
|
||||
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
|
||||
function findHtmlStart(content: string): number {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content.charAt(i) !== '<') {
|
||||
continue;
|
||||
}
|
||||
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
|
||||
if (next === '/') {
|
||||
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
|
||||
if (isAsciiLetter(after)) {
|
||||
return i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isAsciiLetter(next)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isAsciiLetter(ch: string): boolean {
|
||||
if (ch.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const c: number = ch.charCodeAt(0);
|
||||
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
|
||||
*
|
||||
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
|
||||
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
|
||||
*/
|
||||
export function screensueHtmlDocument(content: string): string {
|
||||
let body: string = content.trim();
|
||||
// 反复剥成对的包裹引号(agent 把整段 HTML 当命令参数传时的常见形态)。
|
||||
while (body.length >= 2) {
|
||||
const first: string = body.charAt(0);
|
||||
const last: string = body.charAt(body.length - 1);
|
||||
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
|
||||
body = body.substring(1, body.length - 1).trim();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const idx: number = findHtmlStart(body);
|
||||
if (idx < 0) {
|
||||
return '';
|
||||
}
|
||||
if (idx > 0) {
|
||||
body = body.substring(idx);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
|
||||
*
|
||||
* 为什么必须上 Web(不再用 RichText):RichText 只认极小标签子集,
|
||||
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
|
||||
* HTML 文档(含 @keyframes 与 <svg>),RichText 下只能看到源码。用户明确要求引入 webview。
|
||||
*
|
||||
* 为什么用 base64 而不是明文 loadData:encoding 非 base64 时按 URL 规则转义,
|
||||
* 一个几 KB 的完整文档会撞上长度/转义问题;base64 是整篇加载的推荐方式,
|
||||
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8)。
|
||||
*
|
||||
* 片段(非完整文档)补一层 shell:加 <meta viewport> 让窄屏排版正确,
|
||||
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML(走纯文本渲染)。
|
||||
*/
|
||||
export function screensueWebData(content: string, dark: boolean): string {
|
||||
const fragment: string = screensueHtmlDocument(content);
|
||||
if (fragment.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (hasHtmlShell(fragment)) {
|
||||
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
|
||||
return base64Utf8(fragment);
|
||||
}
|
||||
const fg: string = dark ? '#E8ECF4' : '#1B2430';
|
||||
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
|
||||
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
+ '<style>html,body{margin:0;padding:0}'
|
||||
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
|
||||
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
|
||||
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
|
||||
+ fragment + '</body></html>';
|
||||
return base64Utf8(wrapped);
|
||||
}
|
||||
|
||||
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
|
||||
function hasHtmlShell(s: string): boolean {
|
||||
const head: string = s.substring(0, 400).toLowerCase();
|
||||
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 字符串 → base64。
|
||||
*
|
||||
* 不能把 UTF-16 码元直接交给 Base64Helper:那样中文会变成乱码。
|
||||
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
|
||||
*/
|
||||
export function base64Utf8(s: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let code: number = s.charCodeAt(i);
|
||||
// 代理对(emoji 等)合成成一个码点。
|
||||
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
|
||||
const next: number = s.charCodeAt(i + 1);
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
||||
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
|
||||
} else {
|
||||
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
|
||||
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
|
||||
}
|
||||
}
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
return helper.encodeToStringSync(new Uint8Array(bytes));
|
||||
}
|
||||
313
cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets
Normal file
313
cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets
Normal file
@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 设置数据模型与纯解析逻辑(无 UI 依赖)。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:/settings 的响应解析、key 分区归类、
|
||||
* 分页取项都是纯函数,页面只负责把结果落到 @State。
|
||||
*/
|
||||
|
||||
/** One settings key card rendered in the editor list. */
|
||||
export interface SettingEntry {
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
type: string; // bool | int | duration | select | password | text | string
|
||||
options: string[];
|
||||
value: string; // raw string value as stored by backend
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsSection {
|
||||
id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SettingMetaRaw {
|
||||
key: string;
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export const SETTINGS_PAGE_SIZE: number = 40;
|
||||
|
||||
/**
|
||||
* 二级页面标识。
|
||||
* 一级入口列表(components/SettingsRootEntries.ets)与页面路由表分开成文件后,
|
||||
* 这些 id 必须只有一个来源 —— 否则改一处就会"点了没反应"。
|
||||
*/
|
||||
export const SUB_NONE: string = '';
|
||||
export const SUB_STATUS: string = 'status';
|
||||
export const SUB_CONNECTIONS: string = 'connections';
|
||||
export const SUB_APPEARANCE: string = 'appearance';
|
||||
export const SUB_BACKEND: string = 'backend';
|
||||
export const SUB_SECTION: string = 'section';
|
||||
|
||||
/** /settings 响应解析结果 */
|
||||
export interface ParsedSettings {
|
||||
meta: Record<string, SettingMetaRaw>;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 分区统计结果 */
|
||||
export interface SectionStats {
|
||||
sections: SettingsSection[];
|
||||
/** plugin.* 配置项总数(编辑入口在插件详情页,这里只用于提示去向) */
|
||||
pluginKeyCount: number;
|
||||
/** 涉及的插件个数 */
|
||||
pluginConfigCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 GET /settings 响应:meta 定义 + 当前值。
|
||||
* 值统一转成字符串(后端可能给 bool/number/嵌套对象)。
|
||||
*/
|
||||
export function parseSettingsPayload(body: string): ParsedSettings {
|
||||
const obj: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
const metaStore: Record<string, SettingMetaRaw> = {};
|
||||
const valuesStore: Record<string, string> = {};
|
||||
|
||||
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 optsArr: Object | undefined = item['options'];
|
||||
const opts: string[] = [];
|
||||
if (optsArr !== undefined && optsArr !== null) {
|
||||
const oa: Object[] = optsArr as Object[];
|
||||
for (let i = 0; i < oa.length; i++) {
|
||||
opts.push(oa[i] as string);
|
||||
}
|
||||
}
|
||||
const meta: 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,
|
||||
};
|
||||
metaStore[mk] = meta;
|
||||
}
|
||||
}
|
||||
|
||||
const rawVals: Object | undefined = obj['settings'];
|
||||
if (rawVals !== undefined && rawVals !== null) {
|
||||
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
|
||||
for (const vk of Object.keys(vObj)) {
|
||||
if (vk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const val: Object = vObj[vk];
|
||||
let strVal: string;
|
||||
if (typeof val === 'string') {
|
||||
strVal = val as string;
|
||||
} else if (typeof val === 'boolean' || typeof val === 'number') {
|
||||
strVal = String(val);
|
||||
} else {
|
||||
strVal = JSON.stringify(val);
|
||||
}
|
||||
valuesStore[vk] = strVal;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed: ParsedSettings = { meta: metaStore, values: valuesStore };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function metaCategory(metaStore: Record<string, SettingMetaRaw>, key: string): string {
|
||||
const m: SettingMetaRaw | undefined = metaStore[key];
|
||||
return m !== undefined && m.category.length > 0 ? m.category : '';
|
||||
}
|
||||
|
||||
export function categoryTitle(cat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'agent': '智能体',
|
||||
'daemon': '守护进程',
|
||||
'llm': '大模型',
|
||||
'sources': '数据源',
|
||||
'input': '输入',
|
||||
'paths': '路径',
|
||||
'resources': '资源',
|
||||
'defaults': '默认值',
|
||||
'snapshot': '快照',
|
||||
'rollback': '回滚',
|
||||
};
|
||||
const t: string | undefined = map[cat];
|
||||
return t !== undefined ? t : cat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 key 归到分区:
|
||||
* - core.* → 'core/<category>',展示在「核心」二级页下
|
||||
* - plugin.* → 'plugin/<name>',仅用于计数;实际编辑在插件详情页里,
|
||||
* 不在这里列出(否则同一批 key 会有两个入口)
|
||||
* - 其余 → 'other'
|
||||
*/
|
||||
export function buildSections(
|
||||
valuesStore: Record<string, string>, metaStore: Record<string, SettingMetaRaw>): SectionStats {
|
||||
const ids: string[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
const titles: Record<string, string> = {};
|
||||
let pluginKeys: number = 0;
|
||||
const plugNames: string[] = [];
|
||||
for (const key of Object.keys(valuesStore)) {
|
||||
if (key.startsWith('plugin.')) {
|
||||
// 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。
|
||||
// 这里只统计,用于提示有多少项在那边。
|
||||
pluginKeys = pluginKeys + 1;
|
||||
const rest: string = key.substring('plugin.'.length);
|
||||
const dot: number = rest.indexOf('.');
|
||||
const plugName: string = dot > 0 ? rest.substring(0, dot) : rest;
|
||||
if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) {
|
||||
plugNames.push(plugName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let secId: string;
|
||||
if (key.startsWith('core.')) {
|
||||
const cat: string = metaCategory(metaStore, key);
|
||||
secId = cat.length > 0 ? 'core/' + cat : 'core/misc';
|
||||
if (titles[secId] === undefined) {
|
||||
titles[secId] = cat.length > 0 ? categoryTitle(cat) : '未分类';
|
||||
}
|
||||
} else {
|
||||
secId = 'other';
|
||||
if (titles[secId] === undefined) {
|
||||
titles[secId] = '其他';
|
||||
}
|
||||
}
|
||||
if (counts[secId] === undefined) {
|
||||
counts[secId] = 0;
|
||||
ids.push(secId);
|
||||
}
|
||||
counts[secId] = counts[secId] + 1;
|
||||
}
|
||||
ids.sort((a: string, b: string): number => a.localeCompare(b));
|
||||
const secs: SettingsSection[] = [];
|
||||
for (const id of ids) {
|
||||
secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 });
|
||||
}
|
||||
const stats: SectionStats = {
|
||||
sections: secs,
|
||||
pluginKeyCount: pluginKeys,
|
||||
pluginConfigCount: plugNames.length,
|
||||
};
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function pickInitialSection(sections: SettingsSection[]): string {
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === 'core/agent') {
|
||||
return 'core/agent';
|
||||
}
|
||||
}
|
||||
return sections.length > 0 ? sections[0].id : 'core';
|
||||
}
|
||||
|
||||
export function keyInSection(
|
||||
metaStore: Record<string, SettingMetaRaw>, key: string, secId: string): boolean {
|
||||
if (secId === 'other') {
|
||||
return !key.startsWith('core.') && !key.startsWith('plugin.');
|
||||
}
|
||||
if (secId.startsWith('core/')) {
|
||||
if (!key.startsWith('core.')) {
|
||||
return false;
|
||||
}
|
||||
const cat: string = secId.substring('core/'.length);
|
||||
return cat === 'misc'
|
||||
? metaCategory(metaStore, key).length === 0
|
||||
: metaCategory(metaStore, key) === cat;
|
||||
}
|
||||
if (secId.startsWith('plugin/')) {
|
||||
const p: string = secId.substring('plugin/'.length);
|
||||
return key.startsWith('plugin.' + p + '.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function activeSectionTitle(sections: SettingsSection[], secId: string): string {
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].id === secId) {
|
||||
return sections[i].title;
|
||||
}
|
||||
}
|
||||
return '配置项';
|
||||
}
|
||||
|
||||
/** 某个分区的配置项(按 key 字典序,最多 PAGE_SIZE*4 项) */
|
||||
export function buildEntries(
|
||||
valuesStore: Record<string, string>,
|
||||
metaStore: Record<string, SettingMetaRaw>,
|
||||
secId: string): SettingEntry[] {
|
||||
const entries: SettingEntry[] = [];
|
||||
const keys: string[] = Object.keys(valuesStore).filter((k: string): boolean => {
|
||||
return keyInSection(metaStore, k, secId);
|
||||
});
|
||||
keys.sort((a: string, b: string): number => a.localeCompare(b));
|
||||
const limit: number = Math.min(keys.length, SETTINGS_PAGE_SIZE * 4);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const key: string = keys[i];
|
||||
const meta: SettingMetaRaw | undefined = metaStore[key];
|
||||
const entry: SettingEntry = {
|
||||
key: key,
|
||||
displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key,
|
||||
description: meta !== undefined ? meta.description : '',
|
||||
type: meta !== undefined ? meta.type : 'string',
|
||||
options: meta !== undefined ? meta.options : [],
|
||||
value: valuesStore[key] ?? '',
|
||||
dirty: false,
|
||||
};
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 覆盖某个 entry 的 value/dirty,返回新数组(保持 @State 数组替换语义) */
|
||||
export function withEntryMarked(
|
||||
entries: SettingEntry[], key: string, value: string, dirty: boolean): SettingEntry[] {
|
||||
const next: SettingEntry[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e: SettingEntry = entries[i];
|
||||
if (e.key === key) {
|
||||
const copy: SettingEntry = {
|
||||
key: e.key,
|
||||
displayName: e.displayName,
|
||||
description: e.description,
|
||||
type: e.type,
|
||||
options: e.options,
|
||||
value: value,
|
||||
dirty: dirty,
|
||||
};
|
||||
next.push(copy);
|
||||
} else {
|
||||
next.push(e);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 picker 返回的 URI 里取图片后缀(带点)。
|
||||
* Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败,
|
||||
* 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。
|
||||
*/
|
||||
export function imageExt(uri: string): string {
|
||||
let s: string = uri;
|
||||
const q: number = s.indexOf('?');
|
||||
if (q >= 0) {
|
||||
s = s.substring(0, q);
|
||||
}
|
||||
const dot: number = s.lastIndexOf('.');
|
||||
const slash: number = s.lastIndexOf('/');
|
||||
if (dot > slash && dot < s.length - 1) {
|
||||
const ext: string = s.substring(dot).toLowerCase();
|
||||
if (ext.length <= 5) {
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
180
cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets
Normal file
180
cmd/ohos/HomeAgent/entry/src/main/ets/common/StageTrail.ets
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
|
||||
*
|
||||
* 由 ChatSse 在收到 `stage` 事件时喂入,运行态面板读取。
|
||||
* 为什么不放进 StatusStore:轨迹来自 SSE 流、与 /status、/kernel 的轮询无关,
|
||||
* 两者的生命周期和失败模式都不一样,混在一个数据源里会互相拖累
|
||||
* (SSE 断连不该让状态卡变空,状态轮询失败也不该清掉轨迹)。
|
||||
*
|
||||
* 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
|
||||
* 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
|
||||
* 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
|
||||
*/
|
||||
|
||||
/** 一个阶段组 */
|
||||
export interface StageGroup {
|
||||
/** 0 输入 / 1 行动 / 2 工具 / 3 输出 / 4 结束 */
|
||||
group: number;
|
||||
label: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
/** 轨迹里的一条事件 */
|
||||
export interface StageEvent {
|
||||
group: number;
|
||||
/** 'stage' | 'tool' | 'output' */
|
||||
kind: string;
|
||||
label: string;
|
||||
short: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 阶段组定义,索引即 group */
|
||||
export const STAGE_GROUPS: StageGroup[] = [
|
||||
{ group: 0, label: '输入', en: 'in' },
|
||||
{ group: 1, label: '行动', en: 'act' },
|
||||
{ group: 2, label: '工具', en: 'tool' },
|
||||
{ group: 3, label: '输出', en: 'out' },
|
||||
{ group: 4, label: '结束', en: 'done' },
|
||||
];
|
||||
|
||||
/** 当前阶段(SSE 驱动)。空串 = 空闲。 */
|
||||
export const K_STAGE_PHASE: string = 'stagePhase';
|
||||
/** 轨迹版本号:数组不进 AppStorage,靠它触发订阅组件重取快照 */
|
||||
export const K_STAGE_REV: string = 'stageRev';
|
||||
|
||||
/** 阶段停留多久后回「空闲」——否则会留下一个永远停在 after_output 的假状态 */
|
||||
const IDLE_AFTER_MS: number = 2500;
|
||||
const MAX_ITEMS: number = 24;
|
||||
|
||||
/** rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名 */export function shortTool(name: string): string {
|
||||
let n: string = name;
|
||||
const i: number = n.lastIndexOf('__');
|
||||
if (i >= 0) {
|
||||
n = n.substring(i + 2);
|
||||
}
|
||||
return n.length > 14 ? n.substring(0, 13) + '…' : n;
|
||||
}
|
||||
|
||||
class StageTrail {
|
||||
private items: StageEvent[] = [];
|
||||
private timerId: number = -1;
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<string>(K_STAGE_PHASE, '');
|
||||
AppStorage.setOrCreate<number>(K_STAGE_REV, 0);
|
||||
}
|
||||
|
||||
/** 只给订阅组件读;调用方不要持有它 */
|
||||
snapshot(): StageEvent[] {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
currentPhase(): string {
|
||||
return AppStorage.get<string>(K_STAGE_PHASE) ?? '';
|
||||
}
|
||||
|
||||
/** 按阶段分组取条目 */
|
||||
eventsOf(group: number): StageEvent[] {
|
||||
const out: StageEvent[] = [];
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const e: StageEvent = this.items[i];
|
||||
if (e.group === group) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到一条 stage 事件。phase 取值与内核一致:
|
||||
* on_input / pre_action / post_action / before_toolcall / after_toolcall /
|
||||
* before_output / after_output。
|
||||
*/
|
||||
onStage(phase: string, tool: string): void {
|
||||
if (phase.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (phase === 'on_input') {
|
||||
// 新的一轮:清空上一轮的轨迹
|
||||
this.items = [];
|
||||
this.push(0, 'stage', '输入', '输入');
|
||||
} else if (phase === 'pre_action') {
|
||||
this.push(1, 'stage', '组装上下文并思考', '思考');
|
||||
} else if (phase === 'before_toolcall' && tool.length > 0) {
|
||||
// output_* 是输出通道工具,与普通工具用不同配色区分
|
||||
const kind: string = tool.indexOf('output_') === 0 ? 'output' : 'tool';
|
||||
this.push(2, kind, tool, shortTool(tool));
|
||||
} else if (phase === 'before_output') {
|
||||
this.push(3, 'stage', '生成回复', '生成');
|
||||
} else if (phase === 'after_output') {
|
||||
this.push(4, 'stage', '本轮完成', '完成');
|
||||
} else {
|
||||
// post_action / after_toolcall 不单独记:它们与相邻格重复,
|
||||
// 逐条记会把轨迹刷成噪音。
|
||||
AppStorage.set<string>(K_STAGE_PHASE, phase);
|
||||
this.armIdleTimer();
|
||||
return;
|
||||
}
|
||||
AppStorage.set<string>(K_STAGE_PHASE, phase);
|
||||
this.armIdleTimer();
|
||||
this.bump();
|
||||
}
|
||||
|
||||
private push(group: number, kind: string, label: string, short: string): void {
|
||||
// 同一阶段重复出现的同一条(如同一工具连调 3 次)只累加计数,不刷屏
|
||||
const n: number = this.items.length;
|
||||
if (n > 0) {
|
||||
const last: StageEvent = this.items[n - 1];
|
||||
if (last.group === group && last.kind === kind && last.short === short) {
|
||||
last.count = last.count + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.items.push({ group: group, kind: kind, label: label, short: short, count: 1 });
|
||||
if (this.items.length > MAX_ITEMS) {
|
||||
this.items.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private armIdleTimer(): void {
|
||||
if (this.timerId !== -1) {
|
||||
clearTimeout(this.timerId);
|
||||
}
|
||||
this.timerId = setTimeout(() => {
|
||||
this.timerId = -1;
|
||||
AppStorage.set<string>(K_STAGE_PHASE, '');
|
||||
this.bump();
|
||||
}, IDLE_AFTER_MS);
|
||||
}
|
||||
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_STAGE_REV) ?? 0;
|
||||
AppStorage.set<number>(K_STAGE_REV, cur + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const stageTrail: StageTrail = new StageTrail();
|
||||
|
||||
/**
|
||||
* 内核的七个阶段归并到五个展示格。顺序与 sdk.Stage 一致,
|
||||
* 所以「当前阶段」直接看返回值是不是当前格。空闲返回 -1。
|
||||
*/
|
||||
export function phaseGroup(phase: string): number {
|
||||
if (phase === 'on_input') {
|
||||
return 0;
|
||||
}
|
||||
if (phase === 'pre_action' || phase === 'post_action') {
|
||||
return 1;
|
||||
}
|
||||
if (phase === 'before_toolcall' || phase === 'after_toolcall') {
|
||||
return 2;
|
||||
}
|
||||
if (phase === 'before_output') {
|
||||
return 3;
|
||||
}
|
||||
if (phase === 'after_output') {
|
||||
return 4;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@ -27,6 +27,68 @@ export interface StatGroup {
|
||||
fields: StatField[];
|
||||
}
|
||||
|
||||
// ===== 运行态快照(/runtime)=====
|
||||
//
|
||||
// 与 StatGroup 的分工:明细卡回答「内核有哪些东西、多少」,
|
||||
// 运行态回答「现在在干什么」——四级中断队列积压多少、有几个驻留子。
|
||||
// 两者数据源不同(/kernel vs /runtime),所以分开取、分开存。
|
||||
|
||||
/** 一条队列(四级中断之一,或排队队列) */
|
||||
export interface RuntimeQueue {
|
||||
/** 4/3/2/1;0 表示排队队列(无级别) */
|
||||
lv: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
depth: number;
|
||||
registered: number;
|
||||
preempted: number;
|
||||
}
|
||||
|
||||
/** 一次刷新的运行态快照 */
|
||||
export interface RuntimeSnapshot {
|
||||
/** 排队队列深度(无级别,纯 FIFO) */
|
||||
ready: number;
|
||||
pending: number;
|
||||
stack: number;
|
||||
maxStack: number;
|
||||
subagents: number;
|
||||
/** 格槽数(按全场最大深度缩放,至少 5) */
|
||||
slots: number;
|
||||
/** 五条队列:L4/L3/L2/L1 + 排队 */
|
||||
queues: RuntimeQueue[];
|
||||
}
|
||||
|
||||
interface LevelDef {
|
||||
lv: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 四级中断的定义(顺序即 L4→L1) */
|
||||
const LEVEL_DEFS: LevelDef[] = [
|
||||
{ lv: 4, name: 'L4', desc: '内核独占' },
|
||||
{ lv: 3, name: 'L3', desc: '交互' },
|
||||
{ lv: 2, name: 'L2', desc: '消息' },
|
||||
{ lv: 1, name: 'L1', desc: '后台' },
|
||||
];
|
||||
|
||||
/** JSON 数组里按下标取数:越界/类型不符都当 0 */
|
||||
function numAt(arr: Object[] | undefined, i: number): number {
|
||||
if (arr === undefined || arr === null || i < 0 || i >= arr.length) {
|
||||
return 0;
|
||||
}
|
||||
const v: number = arr[i] as number;
|
||||
return isNaN(v) ? 0 : v;
|
||||
}
|
||||
|
||||
/** 格槽数:按全场最大深度缩放,至少 5 格(0 时也要有可见形状)、最多 16 格 */
|
||||
function clampSlots(maxQ: number): number {
|
||||
if (maxQ < 5) {
|
||||
return 5;
|
||||
}
|
||||
return maxQ > 16 ? 16 : maxQ;
|
||||
}
|
||||
|
||||
// ===== AppStorage 键:摘要卡与明细页共用 =====
|
||||
export const K_UP: string = 'statUp';
|
||||
export const K_VERSION: string = 'statVersion';
|
||||
@ -37,10 +99,14 @@ export const K_TOOLS: string = 'statTools';
|
||||
export const K_ERR: string = 'statErr';
|
||||
export const K_LOADING: string = 'statLoading';
|
||||
export const K_REV: string = 'statRev';
|
||||
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
|
||||
export const K_BUILD: string = 'statBuildSub';
|
||||
|
||||
class StatusStore {
|
||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||
private groups: StatGroup[] = [];
|
||||
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
|
||||
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
@ -52,12 +118,17 @@ class StatusStore {
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||
AppStorage.setOrCreate<string>(K_BUILD, '');
|
||||
}
|
||||
|
||||
getGroups(): StatGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
getRuntime(): RuntimeSnapshot | undefined {
|
||||
return this.runtimeSnapshot;
|
||||
}
|
||||
|
||||
isUp(): boolean {
|
||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||
}
|
||||
@ -101,6 +172,7 @@ class StatusStore {
|
||||
];
|
||||
|
||||
await this.collectKernel(groups);
|
||||
await this.collectRuntime();
|
||||
this.groups = groups;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
@ -133,8 +205,66 @@ class StatusStore {
|
||||
if (startTime.length > 0) {
|
||||
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
|
||||
}
|
||||
|
||||
// 构建身份:版本 / commit / SDK 兼容都取自 /kernel 的 build(-ldflags 注入)。
|
||||
// 为何不能只用 /status 的 version:那里只有一个版本号,分不清是哪个内核、
|
||||
// 哪次构建;许可标识更是完全没有。
|
||||
const build: Record<string, Object> | undefined = k['build'] as Record<string, Object>;
|
||||
let lic: string = '';
|
||||
let licURL: string = '';
|
||||
let srcURL: string = '';
|
||||
if (build !== undefined && build !== null) {
|
||||
const bVer: string = build['version'] as string ?? '';
|
||||
const bCommit: string = build['commit'] as string ?? '';
|
||||
const bName: string = build['kernel_name'] as string ?? 'HomeAgent';
|
||||
const bSdk: string = build['sdk_compatible'] as string ?? '';
|
||||
const bTime: string = build['build_time'] as string ?? '';
|
||||
lic = build['license'] as string ?? '';
|
||||
licURL = build['license_url'] as string ?? '';
|
||||
srcURL = build['source_url'] as string ?? '';
|
||||
if (bVer.length > 0) {
|
||||
AppStorage.set<string>(K_VERSION, 'v' + bVer);
|
||||
kernelFields.push({ label: '内核版本', value: 'v' + bVer });
|
||||
}
|
||||
let sub: string = bName;
|
||||
if (bCommit.length > 0 && bCommit !== 'unknown') {
|
||||
sub = sub + ' · ' + bCommit.substring(0, 7);
|
||||
}
|
||||
AppStorage.set<string>(K_BUILD, sub);
|
||||
if (bCommit.length > 0) {
|
||||
kernelFields.push({ label: 'Commit', value: bCommit });
|
||||
}
|
||||
if (bSdk.length > 0) {
|
||||
kernelFields.push({ label: 'SDK 兼容', value: bSdk });
|
||||
}
|
||||
if (bTime.length > 0 && bTime !== 'unknown') {
|
||||
kernelFields.push({ label: '构建时间', value: formatTime(bTime) });
|
||||
}
|
||||
}
|
||||
groups.push({ title: '内核', fields: kernelFields });
|
||||
|
||||
// 开源许可:只给一个源码链接、不写协议名,使用者看不出这受什么许可约束,
|
||||
// 也看不出网络服务场景下的 §13 义务。
|
||||
const legalFields: StatField[] = [];
|
||||
if (lic.length > 0) {
|
||||
legalFields.push({ label: '许可协议', value: lic });
|
||||
}
|
||||
if (licURL.length > 0) {
|
||||
legalFields.push({ label: '协议全文', value: licURL });
|
||||
}
|
||||
if (srcURL.length > 0) {
|
||||
legalFields.push({ label: '源码仓库', value: srcURL });
|
||||
}
|
||||
if (legalFields.length > 0) {
|
||||
if (lic.toUpperCase().indexOf('AGPL') >= 0) {
|
||||
legalFields.push({
|
||||
label: '网络条款',
|
||||
value: '把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径(§13)',
|
||||
});
|
||||
}
|
||||
groups.push({ title: '开源许可', fields: legalFields });
|
||||
}
|
||||
|
||||
// LLM:provider / 可用源 / 是否可用
|
||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||
if (llm !== undefined && llm !== null) {
|
||||
@ -221,11 +351,91 @@ class StatusStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取运行态快照(/runtime)。失败不影响已经取到的明细分组:
|
||||
* 旧后端可能没有这个端点,那时面板显示「运行态数据不可用」即可。
|
||||
*/
|
||||
private async collectRuntime(): Promise<void> {
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/runtime', 8000);
|
||||
const r: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const sc: Record<string, Object> | undefined = r['scheduler'] as Record<string, Object>;
|
||||
const residents: Object[] | undefined = r['residents'] as Object[];
|
||||
|
||||
let ready: number = 0;
|
||||
let pending: number = 0;
|
||||
let stack: number = 0;
|
||||
let maxStack: number = 4;
|
||||
let qArr: Object[] | undefined = undefined;
|
||||
let regArr: Object[] | undefined = undefined;
|
||||
let preArr: Object[] | undefined = undefined;
|
||||
if (sc !== undefined && sc !== null) {
|
||||
ready = sc['ready_queue_depth'] as number ?? 0;
|
||||
pending = sc['pending_interrupts'] as number ?? 0;
|
||||
stack = sc['suspend_stack'] as number ?? 0;
|
||||
maxStack = sc['max_suspend_depth'] as number ?? 4;
|
||||
qArr = sc['interrupt_queues'] as Object[];
|
||||
regArr = sc['interrupts_by_level'] as Object[];
|
||||
preArr = sc['preempts_by_level'] as Object[];
|
||||
}
|
||||
|
||||
// 四级语义引用内核的定义(internal/agent/core/scheduler.go),
|
||||
// 前端只负责把它们画出来,不自己起名字。
|
||||
const queues: RuntimeQueue[] = [];
|
||||
let maxQ: number = 1;
|
||||
for (let i = 0; i < LEVEL_DEFS.length; i++) {
|
||||
const d: LevelDef = LEVEL_DEFS[i];
|
||||
const depth: number = numAt(qArr, d.lv);
|
||||
if (depth > maxQ) {
|
||||
maxQ = depth;
|
||||
}
|
||||
queues.push({
|
||||
lv: d.lv,
|
||||
name: d.name,
|
||||
desc: d.desc,
|
||||
depth: depth,
|
||||
registered: numAt(regArr, d.lv),
|
||||
preempted: numAt(preArr, d.lv),
|
||||
});
|
||||
}
|
||||
if (ready > maxQ) {
|
||||
maxQ = ready;
|
||||
}
|
||||
// 第五条:排队队列。它不是优先级,而是另一**类别**(排队 vs 中断),
|
||||
// 所以 lv 用 0 标记「无级别」。
|
||||
queues.push({
|
||||
lv: 0,
|
||||
name: '排队',
|
||||
desc: 'FIFO',
|
||||
depth: ready,
|
||||
registered: 0,
|
||||
preempted: 0,
|
||||
});
|
||||
|
||||
const subagents: number = residents !== undefined && residents !== null
|
||||
? residents.length : 0;
|
||||
|
||||
this.runtimeSnapshot = {
|
||||
ready: ready,
|
||||
pending: pending,
|
||||
stack: stack,
|
||||
maxStack: maxStack,
|
||||
subagents: subagents,
|
||||
slots: clampSlots(maxQ),
|
||||
queues: queues,
|
||||
};
|
||||
} catch (e) {
|
||||
// /runtime 不可用(旧后端):保留上一次快照,不清空
|
||||
}
|
||||
}
|
||||
|
||||
private fail(msg: string): void {
|
||||
AppStorage.set<string>(K_ERR, msg);
|
||||
AppStorage.set<boolean>(K_UP, false);
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.runtimeSnapshot = undefined;
|
||||
this.bump();
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 「外观」二级页面:主题三选一 + 自定义背景图。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出。
|
||||
* themeMode / bgImage / bgOpacity 用 @Link 与一级页面共享(一级页的
|
||||
* 外观行要显示当前主题名,两边必须是同一份数据)。
|
||||
*/
|
||||
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { AppSettings, emptySettings } from '../model/Model';
|
||||
import { applyThemeMode } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { imageExt } from '../common/SettingsModel';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { SubPageLayer, PlainCard } from './SubPage';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { picker, fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@Component
|
||||
export struct AppearancePane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Link themeMode: string;
|
||||
@Link bgImage: string;
|
||||
@Link bgOpacity: number;
|
||||
onBack?: () => void;
|
||||
onToast?: (msg: string, isError: boolean) => void;
|
||||
@State pickingBg: boolean = false;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private toast(msg: string, isError: boolean): void {
|
||||
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
|
||||
if (cb !== undefined) {
|
||||
cb(msg, isError);
|
||||
}
|
||||
}
|
||||
|
||||
private applyTheme(mode: string): void {
|
||||
this.themeMode = mode;
|
||||
this.persistSettings();
|
||||
// 立即翻转全局主题标志,整个 UI 随之切换
|
||||
applyThemeMode(mode);
|
||||
}
|
||||
|
||||
private async pickBackgroundImage(): Promise<void> {
|
||||
if (this.pickingBg) {
|
||||
return;
|
||||
}
|
||||
this.pickingBg = true;
|
||||
try {
|
||||
const options = new picker.PhotoSelectOptions();
|
||||
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = 1;
|
||||
const photoPicker = new picker.PhotoViewPicker();
|
||||
const result = await photoPicker.select(options);
|
||||
if (result.photoUris.length === 0) {
|
||||
return;
|
||||
}
|
||||
const srcUri: string = result.photoUris[0];
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
// 文件名带时间戳:Image 组件按 src 字符串做内存缓存,
|
||||
// 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。
|
||||
// 后缀必须保留:Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败,
|
||||
// 表现就是"设置了背景图但没生效"(onError 里能看到 decode 失败)。
|
||||
const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri);
|
||||
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
|
||||
const destFile = fileIo.openSync(destPath,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
fileIo.copyFileSync(srcFile.fd, destFile.fd);
|
||||
fileIo.closeSync(srcFile);
|
||||
fileIo.closeSync(destFile);
|
||||
this.removeOldBgFile();
|
||||
// Image 只认带协议头的沙箱 URI,裸路径会被当成资源名而静默失败
|
||||
this.bgImage = 'file://' + destPath;
|
||||
this.persistSettings();
|
||||
this.toast('背景图已设置', false);
|
||||
} catch (e) {
|
||||
this.toast(userMessage('settings.pickBg', e), true);
|
||||
}
|
||||
this.pickingBg = false;
|
||||
}
|
||||
|
||||
/** 删除上一张背景图文件,避免沙箱里越攒越多 */
|
||||
private removeOldBgFile(): void {
|
||||
const old: string = this.bgImage;
|
||||
if (old.length === 0) {
|
||||
return;
|
||||
}
|
||||
const path: string = old.startsWith('file://') ? old.substring(7) : old;
|
||||
try {
|
||||
fileIo.unlinkSync(path);
|
||||
} catch (e) {
|
||||
// 文件可能已不存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
private clearBackgroundImage(): void {
|
||||
this.removeOldBgFile();
|
||||
this.bgImage = '';
|
||||
this.persistSettings();
|
||||
this.toast('已清除背景图', false);
|
||||
}
|
||||
|
||||
private onBgOpacityChange(value: number): void {
|
||||
this.bgOpacity = value / 100;
|
||||
this.persistSettings();
|
||||
}
|
||||
|
||||
private persistSettings(): void {
|
||||
const s: AppSettings = emptySettings();
|
||||
const old: AppSettings = connStore.getSettings();
|
||||
s.lang = old.lang;
|
||||
s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system');
|
||||
s.currentConnId = old.currentConnId;
|
||||
s.bgImage = this.bgImage;
|
||||
s.bgOpacity = this.bgOpacity;
|
||||
connStore.saveSettings(s);
|
||||
AppStorage.set<string>('bgImage', this.bgImage);
|
||||
AppStorage.set<number>('bgOpacity', this.bgOpacity);
|
||||
}
|
||||
|
||||
@Builder
|
||||
ThemeOption(label: string, mode: string) {
|
||||
// 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。
|
||||
MotionBase({ pressEnabled: true, flexWeight: 1 }) {
|
||||
Column() {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
|
||||
// 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.width('100%')
|
||||
.height(34)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
|
||||
// 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、
|
||||
// 覆盖它上面的所有状态驱动属性。
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
|
||||
})
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.applyTheme(mode);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '外观',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
PlainCard({ caption: '主题' }) {
|
||||
Row({ space: 8 }) {
|
||||
this.ThemeOption('跟随系统', 'system')
|
||||
this.ThemeOption('浅色', 'light')
|
||||
this.ThemeOption('深色', 'dark')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.themeMode === 'system'
|
||||
? '当前跟随系统,系统切换深浅色时自动跟随'
|
||||
: (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题'))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
|
||||
PlainCard({ caption: '背景图' }) {
|
||||
Row() {
|
||||
Column() {
|
||||
Text('自定义背景图')
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
if (this.bgImage.length > 0) {
|
||||
Button('更换')
|
||||
.height(28)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.pickBackgroundImage();
|
||||
})
|
||||
Button('清除')
|
||||
.height(28)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor('#E84026')
|
||||
.onClick(() => {
|
||||
this.clearBackgroundImage();
|
||||
})
|
||||
} else {
|
||||
Button(this.pickingBg ? '选择中...' : '选择图片')
|
||||
.height(28)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.pickBackgroundImage();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.bgImage.length > 0) {
|
||||
Row() {
|
||||
Text('透明度')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Slider({
|
||||
value: Math.round(this.bgOpacity * 100),
|
||||
min: 5,
|
||||
max: 60,
|
||||
step: 1,
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.selectedColor(this.palette().accent)
|
||||
.trackColor(this.palette().bgHover)
|
||||
.margin({ left: 8, right: 8 })
|
||||
.onChange((v: number, mode: SliderChangeMode) => {
|
||||
if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) {
|
||||
this.onBgOpacityChange(v);
|
||||
}
|
||||
})
|
||||
Text(Math.round(this.bgOpacity * 100).toString() + '%')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width(32)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.margin({ top: 12 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,107 +4,20 @@ import { common } from '@kit.AbilityKit';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { extLabel, formatBytes, sanitize } from '../common/AttachmentMeta';
|
||||
import { loadPixelMap } from '../common/AttachmentImage';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 附件解析与展示。
|
||||
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
||||
*
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
||||
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
||||
*
|
||||
* 字节走 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 : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
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
|
||||
@ -428,36 +341,3 @@ export struct AttachmentDetailContent {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出。
|
||||
* 取数/分区/落库都留在页面(它同时要显示"几个分类 · 几项"和错误态),
|
||||
* 这里只负责渲染与把用户动作转成回调。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { SettingsSection, SettingEntry, activeSectionTitle } from '../common/SettingsModel';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard } from './SubPage';
|
||||
import { SettingsEntryCard } from './SettingsEntryCard';
|
||||
|
||||
@Component
|
||||
export struct BackendSectionsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop sections: SettingsSection[] = [];
|
||||
@Prop pluginKeyCount: number = 0;
|
||||
@Prop pluginConfigCount: number = 0;
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
@Prop activeSection: string = '';
|
||||
/** 宽屏右栏正显示分区明细时高亮左侧对应行 */
|
||||
@Prop highlightRows: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onOpenSection?: (id: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '核心配置',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
if (this.errorText.length > 0) {
|
||||
PlainCard({ caption: '状态' }) {
|
||||
Text(this.errorText)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
}
|
||||
}
|
||||
|
||||
NavGroup({ caption: '核心' }) {
|
||||
ForEach(this.sections, (sec: SettingsSection, idx: number) => {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_tune'),
|
||||
title: sec.title,
|
||||
subtitle: sec.count.toString() + ' 项配置',
|
||||
showDivider: idx < this.sections.length - 1,
|
||||
selected: this.highlightRows && this.activeSection === sec.id,
|
||||
onTap: () => {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpenSection;
|
||||
if (cb !== undefined) {
|
||||
cb(sec.id);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (sec: SettingsSection) => sec.id + sec.count.toString())
|
||||
}
|
||||
|
||||
// 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口
|
||||
if (this.pluginKeyCount > 0) {
|
||||
Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' +
|
||||
this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4 })
|
||||
}
|
||||
|
||||
if (this.sections.length === 0 && !this.busy) {
|
||||
Text('未获取到配置分类。检查后端连接后点击刷新。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct SectionEntriesPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop sections: SettingsSection[] = [];
|
||||
@Prop activeSection: string = '';
|
||||
@Prop entries: SettingEntry[] = [];
|
||||
@Prop busy: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onSaveValue?: (key: string, value: string) => void;
|
||||
onSaveCurrent?: (key: string) => void;
|
||||
onEdit?: (key: string, value: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: activeSectionTitle(this.sections, this.activeSection),
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
ForEach(this.entries, (entry: SettingEntry) => {
|
||||
SettingsEntryCard({
|
||||
entry: entry,
|
||||
onSaveValue: (key: string, value: string) => {
|
||||
const cb: ((k: string, v: string) => void) | undefined = this.onSaveValue;
|
||||
if (cb !== undefined) {
|
||||
cb(key, value);
|
||||
}
|
||||
},
|
||||
onSaveCurrent: (key: string) => {
|
||||
const cb: ((k: string) => void) | undefined = this.onSaveCurrent;
|
||||
if (cb !== undefined) {
|
||||
cb(key);
|
||||
}
|
||||
},
|
||||
onEdit: (key: string, value: string) => {
|
||||
const cb: ((k: string, v: string) => void) | undefined = this.onEdit;
|
||||
if (cb !== undefined) {
|
||||
cb(key, value);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
|
||||
|
||||
if (!this.busy && this.entries.length === 0) {
|
||||
Text('该分区暂无配置项')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
|
||||
*
|
||||
* 从 components/ChatComposer.ets 拆出 —— 两者都是"输入区上方的独立浮层",
|
||||
* 数据与动作全部由输入区传入。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { formatBytes } from '../common/AttachmentMeta';
|
||||
|
||||
/** 加号菜单:两枚独立的玻璃胶囊,和输入区其他组件同一套视觉语言 */
|
||||
@Component
|
||||
export struct ChatAttachMenu {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
onPickImage?: () => void;
|
||||
onPickFile?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
@Builder
|
||||
AttachOption(icon: Resource, label: string, tap: () => void) {
|
||||
// 按压反馈交给 MotionBase:每个实例自带独立按压态,
|
||||
// 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Row({ space: 6 }) {
|
||||
Image(icon)
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
}
|
||||
.padding({ left: 12, right: 14, top: 8, bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.onClick(tap)
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 外层撑满并左对齐:菜单要出现在加号正上方,而不是跟着悬浮区右对齐
|
||||
Row() {
|
||||
Row({ space: 8 }) {
|
||||
this.AttachOption($r('app.media.ic_image'), '图片', () => {
|
||||
const cb: (() => void) | undefined = this.onPickImage;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
this.AttachOption($r('app.media.ic_file'), '文件', () => {
|
||||
const cb: (() => void) | undefined = this.onPickFile;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.margin({ bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
// 加号菜单由 if 控制,进出场只能靠 transition;配合 toggle 处的
|
||||
// animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
|
||||
/** 待发送附件预览条:缩略信息 + 一个移除按钮 */
|
||||
@Component
|
||||
export struct ChatPendingChip {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop name: string = '';
|
||||
@Prop byteSize: number = 0;
|
||||
@Prop isImage: boolean = false;
|
||||
@Prop uploading: boolean = false;
|
||||
onRemove?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 按压反馈交给 MotionBase(全宽预览条)
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row({ space: 8 }) {
|
||||
Image(this.isImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(this.name)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.uploading ? '上传中...' : formatBytes(this.byteSize))
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
if (this.uploading) {
|
||||
LoadingProgress()
|
||||
.width(14)
|
||||
.height(14)
|
||||
.color(this.palette().accent)
|
||||
} else {
|
||||
Image($r('app.media.ic_close'))
|
||||
.width(13)
|
||||
.height(13)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onRemove;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.margin({ bottom: 8 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
}
|
||||
}
|
||||
}
|
||||
247
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets
Normal file
247
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatBubble.ets
Normal file
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 Avatar / ChanAvatar / BubbleSlot /
|
||||
* MessageBubble / BubbleBody 五个 @Builder)。
|
||||
*
|
||||
* 传参约定:一切都在构造时快照进来。ForEach 的键(structSig)只在
|
||||
* 消息"结构"变化时改变(新增思考/工具/附件/来源、定稿),结构一变
|
||||
* 气泡就整条重建,因此结构类字段不需要二次更新;唯一会在键不变时
|
||||
* 持续变化的是正文 content,所以它单独用基本类型 @Prop 传(与
|
||||
* MarkdownView 的 @Prop content 走同一条响应式链路,流式渲染不变)。
|
||||
*/
|
||||
|
||||
import { ChatMessage, ToolCallInfo, ChatAttachment } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_PILL,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { bubbleMaxWidth, chanColor, chanLabel, chanLetter } from '../common/ChatFormat';
|
||||
import { AttachmentCard } from './Attachment';
|
||||
import { MarkdownView } from './MarkdownView';
|
||||
import { ChatReasoningCard, ChatToolCard } from './ChatToolCard';
|
||||
|
||||
@Component
|
||||
export struct ChatBubble {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop msgId: number = 0;
|
||||
@Prop role: string = '';
|
||||
/** 正文:唯一会在 ForEach 键不变时持续变化的字段 */
|
||||
@Prop content: string = '';
|
||||
@Prop isStreaming: boolean = false;
|
||||
@Prop isFinal: boolean = false;
|
||||
@Prop reasoningContent: string = '';
|
||||
@Prop reasoningOpen: boolean = false;
|
||||
@Prop toolCalls: ToolCallInfo[] = [];
|
||||
@Prop attachment: ChatAttachment | undefined = undefined;
|
||||
@Prop source: string = '';
|
||||
/** 是否"别处来的"消息(渠道/设备) */
|
||||
@Prop channel: boolean = false;
|
||||
/** 是否自己发的(右对齐、"我"头像) */
|
||||
@Prop mine: boolean = false;
|
||||
/** 入场动画阶段 */
|
||||
@Prop fresh: boolean = false;
|
||||
onOpenAttachment?: (att: ChatAttachment) => void;
|
||||
onToggleReasoning?: (msgId: number) => void;
|
||||
onToggleTool?: (msgId: number, index: number) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 思考卡/工具卡是否是"面板":气泡宽度要放宽,见 common/ChatFormat */
|
||||
private maxWidth(): string {
|
||||
const msg: ChatMessage = {
|
||||
id: this.msgId,
|
||||
role: this.role,
|
||||
content: this.content,
|
||||
toolCalls: this.toolCalls,
|
||||
};
|
||||
if (this.reasoningContent.length > 0) {
|
||||
msg.reasoningContent = this.reasoningContent;
|
||||
}
|
||||
return bubbleMaxWidth(msg);
|
||||
}
|
||||
|
||||
@Builder
|
||||
Avatar() {
|
||||
Text(this.role === 'user' ? '我' : 'AI')
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.role === 'user' ? this.palette().msgUserText : this.palette().accent)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.backgroundColor(this.role === 'user' ? this.palette().msgUserBg : this.palette().accentBg)
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ChanAvatar() {
|
||||
Text(chanLetter(this.source))
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(Color.White)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.borderRadius(RADIUS_PILL)
|
||||
.backgroundColor(chanColor(this.source))
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡占位槽 —— 分栏右侧被切掉的根因就在这里。
|
||||
*
|
||||
* 原来 BubbleBody 直接放进 Row,它的 constraintSize maxWidth 是百分比
|
||||
* (78% / 92%)。百分比是相对【父节点外框】解析的,而这个 Row 自带
|
||||
* 左右 8 的 padding、外层列表 Column 又有左右 14 的 padding,
|
||||
* 于是 92% 算出来的宽度里包含了这些 padding,再加上 28 的头像和 8 的
|
||||
* 间距,一行的总宽就超过了可用内容宽。窄屏因为整体够宽看不出来,
|
||||
* 分栏后左栏只有 420vp,溢出的十几 vp 直接被栏宽裁掉 —— 表现为
|
||||
* 消息右侧被切了一条(这与 MarkdownView 里 width('100%') 溢出 12vp
|
||||
* 被 clip 的问题是同一个成因)。
|
||||
*
|
||||
* 修法同 MarkdownView:用 layoutWeight(1) 拿"剩余空间"而不是百分比。
|
||||
* 槽自身无 padding,外框宽 == 内容宽 == 头像与间距之外的真实可用宽度,
|
||||
* 气泡的百分比再相对它解析,无论栏宽多少都不可能溢出。
|
||||
*/
|
||||
@Builder
|
||||
BubbleSlot() {
|
||||
Column() {
|
||||
this.BubbleBody()
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(this.mine ? HorizontalAlign.End : HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
BubbleBody() {
|
||||
Column() {
|
||||
// 渠道来源名(对齐 GUI 的 msg-chan-name):只有别处来的消息才显示
|
||||
if (this.channel) {
|
||||
Text(chanLabel(this.source))
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 4 })
|
||||
}
|
||||
|
||||
// Reasoning card (assistant only)
|
||||
if (this.role === 'assistant' && this.reasoningContent.length > 0) {
|
||||
ChatReasoningCard({
|
||||
content: this.reasoningContent,
|
||||
open: this.reasoningOpen,
|
||||
sweeping: this.isFinal !== true && this.isStreaming === true,
|
||||
fresh: this.fresh,
|
||||
onToggle: () => {
|
||||
const cb: ((id: number) => void) | undefined = this.onToggleReasoning;
|
||||
if (cb !== undefined) {
|
||||
cb(this.msgId);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 附件卡(图片缩略图 / 文件条),点击进入附件详情二级页
|
||||
if (this.attachment !== undefined) {
|
||||
AttachmentCard({
|
||||
att: this.attachment,
|
||||
mine: this.role === 'user',
|
||||
onTap: () => {
|
||||
const a: ChatAttachment | undefined = this.attachment;
|
||||
const cb: ((att: ChatAttachment) => void) | undefined = this.onOpenAttachment;
|
||||
if (a !== undefined && cb !== undefined) {
|
||||
cb(a);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Content bubble — 对齐 WebGUI bubbleGrow + textFadeIn
|
||||
if (this.content.length > 0) {
|
||||
if (this.role === 'assistant') {
|
||||
MarkdownView({
|
||||
content: this.content,
|
||||
isStreaming: this.isStreaming === true,
|
||||
isDark: this.isDark,
|
||||
})
|
||||
} else {
|
||||
Text(this.content)
|
||||
.fontSize(15)
|
||||
.lineHeight(24)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.constraintSize({ maxWidth: '100%' })
|
||||
.margin({ top: this.attachment !== undefined ? 8 : 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Tool cards
|
||||
if (this.toolCalls.length > 0) {
|
||||
Column() {
|
||||
ForEach(this.toolCalls, (tc: ToolCallInfo, tci: number) => {
|
||||
ChatToolCard({
|
||||
tc: tc,
|
||||
index: tci,
|
||||
onToggle: (index: number) => {
|
||||
const cb: ((id: number, i: number) => void) | undefined = this.onToggleTool;
|
||||
if (cb !== undefined) {
|
||||
cb(this.msgId, index);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (tc: ToolCallInfo, tci: number) => tci.toString() + tc.name)
|
||||
}
|
||||
// 不写 width('100%'):百分比会按气泡外框解析而溢出 12vp 被 clip。
|
||||
// 让它自适应,最大宽约束由气泡内容框向下传递,ChatToolCard 内部用 layoutWeight 取满。
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ top: this.content.length > 0 ? 6 : 0 })
|
||||
}
|
||||
}
|
||||
.constraintSize({ maxWidth: this.maxWidth() })
|
||||
.clip(true)
|
||||
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
|
||||
.backgroundColor(this.role === 'user' ? this.palette().msgUserBubbleBg : this.palette().msgAssistantBubbleBg)
|
||||
.borderRadius({
|
||||
topLeft: RADIUS_MD,
|
||||
topRight: RADIUS_MD,
|
||||
bottomLeft: this.role === 'assistant' ? 4 : RADIUS_MD,
|
||||
bottomRight: this.role === 'user' ? 4 : RADIUS_MD,
|
||||
})
|
||||
.border({ width: 1, color: this.role === 'user' ? this.palette().msgUserBubbleBorder : this.palette().msgAssistantBubbleBorder })
|
||||
.shadow({ radius: 8, color: this.palette().shadow, offsetY: 2 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// bubbleGrow: 气泡入场缩放效果
|
||||
.scale({
|
||||
x: this.fresh ? 0.95 : 1,
|
||||
y: this.fresh ? 0.95 : 1,
|
||||
})
|
||||
.animation({ duration: 200, curve: Curve.EaseOut })
|
||||
}
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
if (this.channel) {
|
||||
this.ChanAvatar()
|
||||
this.BubbleSlot()
|
||||
} else if (this.role === 'user') {
|
||||
this.BubbleSlot()
|
||||
this.Avatar()
|
||||
} else {
|
||||
this.Avatar()
|
||||
this.BubbleSlot()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
// 槽已经用 layoutWeight 吃掉了剩余宽度,这里的对齐实际不再参与分配,
|
||||
// 保留是为了兜底:若某处布局退化成非加权分配,方向也仍然正确。
|
||||
.justifyContent(this.mine ? FlexAlign.End : FlexAlign.Start)
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
// 入场动画:对齐 WebGUI viewIn (opacity 0 -> 1, translateY 6 -> 0)
|
||||
.opacity(this.fresh ? 0 : 1)
|
||||
.translate({ y: this.fresh ? 8 : 0 })
|
||||
.animation({ duration: 180, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 的层2 + AttachMenu /
|
||||
* AttachOption / PendingAttachmentChip 三个 @Builder,外加选图/选文件、
|
||||
* 沙箱落盘、带附件发送这一整套方法)。
|
||||
*
|
||||
* 输入区自己持有文本与附件状态;只有会影响【列表底部留白】的三项
|
||||
* (inputMultiLine / attachMenuOpen / pendingName)用 @Link 与页面共享。
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../model/Model';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
|
||||
import { mimeOf } from '../common/ChatFormat';
|
||||
import { chatStore, K_CHAT_LOADING } from '../common/ChatStore';
|
||||
import { sendChatText, sendChatFile, interruptChat } from '../common/ChatSession';
|
||||
import { fileNameOf } from '../common/AttachmentMeta';
|
||||
import { ChatAttachMenu, ChatPendingChip } from './ChatAttachBar';
|
||||
import { NavFloatOverlay, FloatIconButton } from './PageTopBar';
|
||||
import { picker, fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { MeasureOptions } from '@ohos.measure';
|
||||
|
||||
/** 输入框字号与内边距:文字测量必须和 TextArea 的实际排版参数一致 */
|
||||
const INPUT_FONT_SIZE: number = 14;
|
||||
const INPUT_INNER_PAD: number = 16;
|
||||
/** 单行态左右让位:左边加号 42+8,右边发送键 44+8 */
|
||||
const INPUT_LEFT_GAP: number = 50;
|
||||
const INPUT_RIGHT_GAP: number = 52;
|
||||
|
||||
@Component
|
||||
export struct ChatComposer {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
|
||||
/** 输入框是否已进入多行态(页面用它算列表底部留白) */
|
||||
@Link inputMultiLine: boolean;
|
||||
/** 加号菜单是否展开(同上) */
|
||||
@Link attachMenuOpen: boolean;
|
||||
/** 待发送附件的展示名(同上) */
|
||||
@Link pendingName: string;
|
||||
@State inputText: string = '';
|
||||
@State pendingSize: number = 0;
|
||||
@State pendingIsImage: boolean = false;
|
||||
@State uploading: boolean = false;
|
||||
private pendingPath: string = '';
|
||||
private pendingMime: string = '';
|
||||
/** 底部固定行的实测宽度:用于文字测量,判断是否需要换行 */
|
||||
private inputRowWidth: number = 0;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 输入框底:高不透明度 + blur,保证背景内容不会透过输入文字 */
|
||||
private inputSolidBg(): string {
|
||||
return this.isDark ? 'rgba(28, 28, 30, 0.94)' : 'rgba(245, 245, 247, 0.92)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 由【文本本身】判断输入框是否需要换行,而不是回读控件高度。
|
||||
*
|
||||
* 用 MeasureUtils 在单行态可用宽度下测量文字:宽度超了就是多行。
|
||||
* 测量宽度恒定取单行态(窄)宽度,与控件当前实际宽度无关,
|
||||
* 所以"多行时变宽"不会反过来改变判定结果 —— 没有反馈环,也就不抖。
|
||||
*/
|
||||
private recomputeMultiLine(text: string): void {
|
||||
const avail: number = this.inputRowWidth - INPUT_LEFT_GAP - INPUT_RIGHT_GAP
|
||||
- INPUT_INNER_PAD * 2;
|
||||
if (avail <= 0) {
|
||||
return;
|
||||
}
|
||||
let multi: boolean = text.indexOf('\n') >= 0;
|
||||
if (!multi && text.length > 0) {
|
||||
const opt: MeasureOptions = {
|
||||
textContent: text,
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
};
|
||||
const size: SizeOptions = this.getUIContext().getMeasureUtils().measureTextSize(opt);
|
||||
// measureTextSize 返回 px,可用宽度是 vp,换算后再比
|
||||
const widthVp: number = this.getUIContext().px2vp(size.width as number);
|
||||
multi = widthVp > avail;
|
||||
}
|
||||
if (multi !== this.inputMultiLine) {
|
||||
this.getUIContext().animateTo({ duration: 260, curve: Curve.Friction }, () => {
|
||||
this.inputMultiLine = multi;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 附件:选择与上传 =====================
|
||||
|
||||
/** 从图库挑一张图 */
|
||||
private async pickImage(): Promise<void> {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = false;
|
||||
});
|
||||
try {
|
||||
const options = new picker.PhotoSelectOptions();
|
||||
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
|
||||
options.maxSelectNumber = 1;
|
||||
const photoPicker = new picker.PhotoViewPicker();
|
||||
const result = await photoPicker.select(options);
|
||||
if (result.photoUris.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.stagePickedFile(result.photoUris[0], true);
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.pickImage', e));
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/** 从文件管理器挑一个文件 */
|
||||
private async pickFile(): Promise<void> {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = false;
|
||||
});
|
||||
try {
|
||||
const options = new picker.DocumentSelectOptions();
|
||||
options.maxSelectNumber = 1;
|
||||
const docPicker = new picker.DocumentViewPicker();
|
||||
const uris: string[] = await docPicker.select(options);
|
||||
if (uris.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.stagePickedFile(uris[0], false);
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.pickFile', e));
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 picker 给的 URI 复制到应用沙箱。
|
||||
* http 的 multiFormDataList.filePath 只能读应用自己的沙箱路径,
|
||||
* 直接把 picker 的 media:// URI 交过去会读不到内容。
|
||||
*/
|
||||
private stagePickedFile(srcUri: string, isImage: boolean): void {
|
||||
try {
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
const name: string = fileNameOf(srcUri);
|
||||
const destPath: string = ctx.filesDir + '/up_' + Date.now().toString(36) + '_' + name;
|
||||
const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY);
|
||||
const destFile = fileIo.openSync(destPath,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
fileIo.copyFileSync(srcFile.fd, destFile.fd);
|
||||
fileIo.closeSync(srcFile);
|
||||
fileIo.closeSync(destFile);
|
||||
const stat = fileIo.statSync(destPath);
|
||||
this.clearPendingFile();
|
||||
this.pendingPath = destPath;
|
||||
this.pendingName = name;
|
||||
this.pendingSize = stat.size;
|
||||
this.pendingIsImage = isImage;
|
||||
this.pendingMime = mimeOf(name, isImage);
|
||||
} catch (e) {
|
||||
chatStore.setStage(userMessage('chat.stageFile', e));
|
||||
}
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
|
||||
/** 丢弃待发送附件,并删掉沙箱里的临时副本 */
|
||||
private clearPendingFile(): void {
|
||||
if (this.pendingPath.length > 0) {
|
||||
try {
|
||||
fileIo.unlinkSync(this.pendingPath);
|
||||
} catch (e) {
|
||||
// 已不存在,忽略
|
||||
}
|
||||
}
|
||||
this.pendingPath = '';
|
||||
this.pendingName = '';
|
||||
this.pendingSize = 0;
|
||||
this.pendingIsImage = false;
|
||||
this.pendingMime = '';
|
||||
}
|
||||
|
||||
/** 发送:有附件走 multipart(POST /chat/file),否则走 POST /chat */
|
||||
private async send(): Promise<void> {
|
||||
if (this.loading || this.uploading) {
|
||||
return;
|
||||
}
|
||||
// 没连后端时直接返回、且不清输入/不动附件:与拆分前 sendChat 的守卫顺序一致
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
const text: string = this.inputText.trim();
|
||||
if (this.pendingPath.length > 0) {
|
||||
const path: string = this.pendingPath;
|
||||
const name: string = this.pendingName;
|
||||
const size: number = this.pendingSize;
|
||||
const isImage: boolean = this.pendingIsImage;
|
||||
const mime: string = this.pendingMime;
|
||||
this.inputText = '';
|
||||
this.inputMultiLine = false;
|
||||
this.uploading = true;
|
||||
await sendChatFile(text, path, name, size, isImage, mime);
|
||||
this.uploading = false;
|
||||
this.clearPendingFile();
|
||||
chatStore.forceRefresh();
|
||||
chatStore.requestScroll();
|
||||
return;
|
||||
}
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.inputText = '';
|
||||
this.inputMultiLine = false;
|
||||
await sendChatText(text);
|
||||
}
|
||||
|
||||
// ===================== UI =====================
|
||||
|
||||
build() {
|
||||
NavFloatOverlay({ tab: 0 }) {
|
||||
// 加号展开的两个选项(图片 / 文件),点一次收起
|
||||
if (this.attachMenuOpen) {
|
||||
ChatAttachMenu({
|
||||
onPickImage: () => {
|
||||
this.pickImage();
|
||||
},
|
||||
onPickFile: () => {
|
||||
this.pickFile();
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 待发送附件预览(选好图片/文件、还没点发送时显示)
|
||||
if (this.pendingName.length > 0) {
|
||||
ChatPendingChip({
|
||||
name: this.pendingName,
|
||||
byteSize: this.pendingSize,
|
||||
isImage: this.pendingIsImage,
|
||||
uploading: this.uploading,
|
||||
onRemove: () => {
|
||||
this.clearPendingFile();
|
||||
chatStore.forceRefresh();
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Stack 而不是 Column:加号与发送按钮钉死在底部这一行不动,
|
||||
// 输入框是浮在它们上面的独立层,超过一行就往上长并展开到整行宽度。
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 底层:固定不动的一行 —— 左加号(图片/文件)、右发送/中断按钮
|
||||
Row() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_plus'),
|
||||
onTap: () => {
|
||||
// 菜单展开会同时改变列表底部留白,用 animateTo 把
|
||||
// 列表内边距和菜单进出场拉到同一个时钟上。
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.attachMenuOpen = !this.attachMenuOpen;
|
||||
});
|
||||
},
|
||||
})
|
||||
Blank()
|
||||
if (this.loading) {
|
||||
Button() {
|
||||
Image($r('app.media.ic_stop'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(Color.White)
|
||||
}
|
||||
.width(44)
|
||||
.height(44)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor('#77809A')
|
||||
// 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
.onClick(() => {
|
||||
interruptChat();
|
||||
})
|
||||
} else {
|
||||
Button() {
|
||||
Image($r('app.media.ic_send'))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(Color.White)
|
||||
}
|
||||
.width(44)
|
||||
.height(44)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
.onClick(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onAreaChange((_o: Area, n: Area) => {
|
||||
// 这一行高度恒为 44、宽度恒为 100%,测量它不会形成反馈环。
|
||||
const w: number = n.width as number;
|
||||
if (Math.abs(w - this.inputRowWidth) > 0.5) {
|
||||
this.inputRowWidth = w;
|
||||
this.recomputeMultiLine(this.inputText);
|
||||
}
|
||||
})
|
||||
|
||||
// 上层:输入框。
|
||||
// 单行时左右让出加号(42+8)与发送键(44+8)的位置,与它们同处一行;
|
||||
// 多行时整体上移 52 抬到那一行之上,并铺满整行宽度。
|
||||
//
|
||||
// 之前"只上移不变宽"是因为宽度被钉死了:让宽度跟着实测高度变会形成
|
||||
// 布局反馈环(变宽→文字回落成一行→变窄→又折行),卡在半弹出态抖动。
|
||||
// 现在改用 MeasureUtils 直接量文字:始终按【窄宽度】测量是否需要换行,
|
||||
// 判定输入只依赖文本内容,与控件实际宽度无关,所以变宽也不会自激。
|
||||
Row() {
|
||||
TextArea({
|
||||
placeholder: '输入消息...',
|
||||
text: this.inputText,
|
||||
})
|
||||
.layoutWeight(1)
|
||||
// 不写死高度:单行 44,随文字换行自动增高,最多约 5 行后内部滚动
|
||||
.constraintSize({ minHeight: 44, maxHeight: 168 })
|
||||
.fontSize(INPUT_FONT_SIZE)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderFont({ size: 13 })
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.inputSolidBg())
|
||||
.backdropBlur(24)
|
||||
.borderRadius(22)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.padding({
|
||||
left: INPUT_INNER_PAD,
|
||||
right: INPUT_INNER_PAD,
|
||||
top: 11,
|
||||
bottom: 11,
|
||||
})
|
||||
.enterKeyType(EnterKeyType.Send)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value;
|
||||
this.recomputeMultiLine(value);
|
||||
})
|
||||
.onSubmit(() => {
|
||||
this.send();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
// 多行时必须显式写 0:给 .padding() 传 undefined 在增量更新时会被当作
|
||||
// "不修改该属性",旧的左右 50/52 留在原地 —— 这就是"只上移不变宽"。
|
||||
.padding(this.inputMultiLine
|
||||
? { left: 0, right: 0 }
|
||||
: { left: INPUT_LEFT_GAP, right: INPUT_RIGHT_GAP })
|
||||
.margin({ bottom: this.inputMultiLine ? 52 : 0 })
|
||||
// 关键:这层 Row 铺满整宽,它的左右 padding 正好压在加号与发送键上方。
|
||||
// 不设 None 的话 padding 区域仍属于 Row,会把点击吞掉 —— 发送键点不动。
|
||||
// None = 自身不响应、子节点(TextArea)照常响应,触摸落到下层那一行。
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
.animation({ duration: 260, curve: Curve.Friction })
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
285
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets
Normal file
285
cmd/ohos/HomeAgent/entry/src/main/ets/components/ChatStream.ets
Normal file
@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来是 ChatBody 里除悬浮输入区之外的三层)。
|
||||
* 消息数组来自 common/ChatStore.ets:用版本号 K_CHAT_REV 订阅,
|
||||
* 版本变化时重取一次快照(数组引用每次都是新的,ForEach 的渲染语义
|
||||
* 与拆分前 this.messages = this.messages.slice() 完全一致)。
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
|
||||
import { SUB_CONNECTIONS } from '../common/SettingsModel';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
|
||||
import { isChannelMsg, isSelfMsg, structSig } from '../common/ChatFormat';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { ChatBubble } from './ChatBubble';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
|
||||
@Component
|
||||
export struct ChatStream {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
|
||||
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
|
||||
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
|
||||
@StorageProp(K_CHAT_STAGE) private stage: string = '';
|
||||
/** 数组快照的订阅信号 */
|
||||
@StorageProp(K_CHAT_REV) @Watch('onChatRev') private rev: number = 0;
|
||||
/** "滚到底"请求信号 */
|
||||
@StorageProp(K_CHAT_SCROLL_REV) @Watch('onScrollReq') private scrollRev: number = 0;
|
||||
@State messages: ChatMessage[] = [];
|
||||
/** 列表底部留白:随输入区展开/加号菜单/待发送附件变化 */
|
||||
@Prop bottomPad: number = 210;
|
||||
onOpenAttachment?: (att: ChatAttachment) => void;
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
private autoScrolling: boolean = false;
|
||||
/** 滚动世代号:scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */
|
||||
private scrollGen: number = 0;
|
||||
private navHidden: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.messages = chatStore.messages();
|
||||
// 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。
|
||||
//
|
||||
// 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。
|
||||
// ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream
|
||||
// 构造之前就完成了,requestScroll 递增的 chatScrollRev 就成了“挂载前
|
||||
// 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是
|
||||
// “消息加载好了却停在顶部/中间,不滚到最新”。
|
||||
if (this.messages.length > 0) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private onChatRev(): void {
|
||||
this.messages = chatStore.messages();
|
||||
}
|
||||
|
||||
private onScrollReq(): void {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部淡出遮罩的两个端色:背景底色的全不透明 / 全透明版本。
|
||||
* bgPrimary 是 6 位十六进制,这里手拼 8 位 ARGB —— 与 PageTopBar
|
||||
* 顶部淡出用的是同一手法,保证上下两端的融入观感一致。
|
||||
*/
|
||||
private opaqueBottomBg(): string {
|
||||
return '#FF' + this.palette().bgPrimary.substring(1);
|
||||
}
|
||||
|
||||
private transparentBottomBg(): string {
|
||||
return '#00' + this.palette().bgPrimary.substring(1);
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
this.autoScrolling = true;
|
||||
// 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的,
|
||||
// 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms,
|
||||
// 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。
|
||||
// 用递增间隔重试到 ~1s,让后几帧的布局增长也跟得上。
|
||||
//
|
||||
// scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。
|
||||
const gen: number = ++this.scrollGen;
|
||||
const delays: number[] = [50, 120, 220, 360, 550, 800];
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
setTimeout(() => {
|
||||
if (gen !== this.scrollGen) {
|
||||
return;
|
||||
}
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, delays[i]);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (gen !== this.scrollGen) {
|
||||
return;
|
||||
}
|
||||
this.autoScrolling = false;
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动回调:只设置普通标志位,仅在状态翻转时通知 navBar,
|
||||
* 不在回调里做任何耗时操作。navBar.setVisible 内部已去重,
|
||||
* 而布局(padding)不再依赖 navVisible,故翻转只触发 GPU 变换,
|
||||
* 不会引起布局回流——这是滑动流畅的关键。
|
||||
*/
|
||||
private handleScrollDirection(yOffset: number, state: ScrollState): void {
|
||||
if (this.autoScrolling) {
|
||||
return;
|
||||
}
|
||||
// 触顶(近顶部 60vp)且服务端还有更早历史 → 向上懒加载下一页
|
||||
if (yOffset < 60 && chatStore.hasMore()) {
|
||||
chatStore.loadOlder();
|
||||
}
|
||||
if (state === ScrollState.Idle) {
|
||||
if (this.navHidden) {
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}
|
||||
} else {
|
||||
// Scroll / Fling:向下/惯性滚动时隐藏导航与输入栏
|
||||
if (!this.navHidden) {
|
||||
this.navHidden = true;
|
||||
navBar.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 层1:消息列表(铺满全屏,内容从顶栏遮罩下方穿过时逐渐淡出)
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column() {
|
||||
ForEach(this.messages, (msg: ChatMessage, idx: number) => {
|
||||
ChatBubble({
|
||||
msgId: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
isStreaming: msg.isStreaming === true,
|
||||
isFinal: msg.isFinal === true,
|
||||
reasoningContent: msg.reasoningContent ?? '',
|
||||
reasoningOpen: msg.reasoningOpen === true,
|
||||
toolCalls: msg.toolCalls ?? [],
|
||||
attachment: msg.attachment,
|
||||
source: msg.source ?? '',
|
||||
channel: isChannelMsg(msg.source ?? '', connStore.ensureDeviceId()),
|
||||
mine: isSelfMsg(msg.role, isChannelMsg(msg.source ?? '', connStore.ensureDeviceId())),
|
||||
fresh: chatStore.isFresh(msg.id),
|
||||
onOpenAttachment: (att: ChatAttachment) => {
|
||||
const cb: ((a: ChatAttachment) => void) | undefined = this.onOpenAttachment;
|
||||
if (cb !== undefined) {
|
||||
cb(att);
|
||||
}
|
||||
},
|
||||
onToggleReasoning: (id: number) => {
|
||||
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation,
|
||||
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
|
||||
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
|
||||
chatStore.setReasoningOpen(id, !chatStore.reasoningOpen(id));
|
||||
chatStore.forceRefresh();
|
||||
});
|
||||
},
|
||||
onToggleTool: (id: number, index: number) => {
|
||||
this.getUIContext().animateTo({ duration: 220, curve: Curve.EaseOut }, () => {
|
||||
chatStore.setToolOpen(id, index, !chatStore.toolOpen(id, index));
|
||||
chatStore.forceRefresh();
|
||||
});
|
||||
},
|
||||
})
|
||||
}, (msg: ChatMessage, idx: number) => structSig(msg))
|
||||
|
||||
if (this.loading) {
|
||||
Row({ space: 8 }) {
|
||||
LoadingProgress()
|
||||
.width(16)
|
||||
.height(16)
|
||||
.color(this.palette().accent)
|
||||
Text(this.stage.length > 0 ? this.stage : '处理中...')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.padding({ left: 52, top: 6, bottom: 6 })
|
||||
// if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
|
||||
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({
|
||||
left: 14,
|
||||
right: 14,
|
||||
top: 76,
|
||||
bottom: this.bottomPad,
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
this.handleScrollDirection(yOffset, state);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
|
||||
//
|
||||
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
|
||||
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
|
||||
if (this.messages.length === 0 && !this.loading) {
|
||||
Column({ space: 10 }) {
|
||||
Image($r('app.media.ic_link'))
|
||||
.width(34)
|
||||
.height(34)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.hasConn
|
||||
? '在下方输入框发送第一条消息'
|
||||
: '请先在“后端连接”里填写服务地址与 API Key')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.textAlign(TextAlign.Center)
|
||||
if (!this.hasConn) {
|
||||
Button('去设置连接')
|
||||
.height(34)
|
||||
.fontSize(13)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ top: 4 })
|
||||
.onClick(() => {
|
||||
// 跨页信号:切到设置 Tab,并让设置页直接打开连接二级页
|
||||
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
|
||||
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ left: 44, right: 44 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
|
||||
PageTopBar({ title: '聊天' })
|
||||
|
||||
// 层1.6:底部淡出遮罩 —— 滚动内容接近悬浮输入区/底部导航时逐渐隐入背景,
|
||||
// 而不是在玻璃后面清晰可见(PageTopBar 顶部淡出手法的镜像,方向相反)。
|
||||
Column()
|
||||
.width('100%')
|
||||
.height(170)
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Bottom,
|
||||
colors: [
|
||||
[this.transparentBottomBg(), 0.0],
|
||||
[this.opaqueBottomBg(), 0.6],
|
||||
[this.opaqueBottomBg(), 1.0],
|
||||
],
|
||||
})
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
|
||||
*
|
||||
* 从 pages/ChatPage.ets 抽出(原来分别是 ReasoningCard / ToolCard 两个 @Builder)。
|
||||
* 折叠状态与开关动作都交回调用方(状态在 chatStore 里,且展开/收起要在
|
||||
* animateTo 帧内完成 —— 那需要组件上下文)。
|
||||
*/
|
||||
|
||||
import { ToolCallInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
|
||||
ANIM_NORMAL } from '../common/Constants';
|
||||
import { tcRunning, tcError, tcLeftColor, tcIcoColor, tcStateLabel, tcStateColor } from '../common/ChatFormat';
|
||||
|
||||
@Component
|
||||
export struct ChatReasoningCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 思考正文(已折叠时也带着,展开不再请求) */
|
||||
@Prop content: string = '';
|
||||
@Prop open: boolean = false;
|
||||
/** 流式中:显示转圈 + 扫光条 */
|
||||
@Prop sweeping: boolean = false;
|
||||
/** 入场动画阶段:扫光条起始偏移靠它切换 */
|
||||
@Prop fresh: boolean = false;
|
||||
onToggle?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 两层结构,原因见 ChatBubble 的布局说明:
|
||||
// 外层 Row 是"外观壳"(虚线边框 / 底色 / 圆角),不设百分比宽度,
|
||||
// 靠内层 layoutWeight(1) 把气泡内容框的剩余宽度吃满;
|
||||
// 内层 holder Column 自身无 padding,所以它的子节点写 width('100%')
|
||||
// 才有正确的解析基准,不会再溢出到气泡外被 clip 切掉。
|
||||
Row() {
|
||||
Column() {
|
||||
Row({ space: 7 }) {
|
||||
Image($r('app.media.ic_sparkle'))
|
||||
.width(12)
|
||||
.height(12)
|
||||
.fillColor(this.palette().accent)
|
||||
Text('思考过程')
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
// 流式思考时给出明确进度指示,而不是一张看不出在动的折叠卡
|
||||
if (this.sweeping) {
|
||||
LoadingProgress()
|
||||
.width(11)
|
||||
.height(11)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
Blank()
|
||||
Text(this.content.length > 0 ? this.content.length.toString() + ' 字' : '')
|
||||
.fontSize(9.5)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Image($r('app.media.ic_chevron_down'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.rotate({ angle: this.open ? 180 : 0 })
|
||||
.animation({ duration: 200, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onToggle;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
|
||||
if (this.open) {
|
||||
Text(this.content)
|
||||
.fontSize(11.5)
|
||||
.lineHeight(17)
|
||||
.fontColor(this.palette().textTertiary)
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, bottom: 8 })
|
||||
.maxLines(24)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
// 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
// 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep)
|
||||
if (this.sweeping) {
|
||||
Stack() {
|
||||
Row()
|
||||
.height(2)
|
||||
.borderRadius(2)
|
||||
.width('200%')
|
||||
.linearGradient({
|
||||
angle: 90,
|
||||
colors: [
|
||||
['rgba(255,255,255,0.01)', 0],
|
||||
[this.palette().accent, 0.35],
|
||||
['rgba(255,255,255,0.01)', 0.5],
|
||||
[this.palette().accent, 0.65],
|
||||
['rgba(255,255,255,0.01)', 1],
|
||||
],
|
||||
})
|
||||
.opacity(0.7)
|
||||
.translate({ x: this.fresh ? '0%' : '-50%' })
|
||||
.animation({ duration: 1200, curve: Curve.Linear })
|
||||
}
|
||||
.width('100%')
|
||||
.clip(true)
|
||||
.height(2)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 6 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().kvBorder, style: BorderStyle.Dashed })
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ChatToolCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop tc: ToolCallInfo;
|
||||
/** 在所属消息 toolCalls 里的下标:开关动作要交回调用方按 (msgId, index) 定位 */
|
||||
@Prop index: number = 0;
|
||||
onToggle?: (index: number) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 同 ChatReasoningCard:外层 Row 只做外观,内层 layoutWeight(1) 取真实内容宽
|
||||
Row() {
|
||||
Column() {
|
||||
Row({ space: 6 }) {
|
||||
if (tcError(this.tc)) {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(13).height(13)
|
||||
.fillColor(tcIcoColor(this.tc, this.palette().accent))
|
||||
} else if (tcRunning(this.tc)) {
|
||||
LoadingProgress()
|
||||
.width(12).height(12)
|
||||
.color(tcIcoColor(this.tc, this.palette().accent))
|
||||
} else {
|
||||
Image($r('app.media.ic_check'))
|
||||
.width(13).height(13)
|
||||
.fillColor(tcIcoColor(this.tc, this.palette().accent))
|
||||
}
|
||||
Text(this.tc.name)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
// 名字可长可短,必须让它占剩余宽度并可收缩,
|
||||
// 否则右侧状态文字会被挤出气泡(78% 宽度 + clip 直接切掉)
|
||||
.layoutWeight(1)
|
||||
if (this.tc.plugin !== undefined && this.tc.plugin.length > 0) {
|
||||
Text(this.tc.plugin)
|
||||
.fontSize(9.5)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.flexShrink(0)
|
||||
}
|
||||
// 状态徽标去掉,只留一行小字(用户要求"去掉所有状态徽标")
|
||||
Text(tcStateLabel(this.tc))
|
||||
.fontSize(9.5)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(tcStateColor(this.tc))
|
||||
.flexShrink(0)
|
||||
Image($r('app.media.ic_chevron_down'))
|
||||
.width(13).height(13)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.flexShrink(0)
|
||||
.rotate({ angle: this.tc.open === true ? 180 : 0 })
|
||||
.animation({ duration: 150, curve: Curve.EaseOut })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
const cb: ((index: number) => void) | undefined = this.onToggle;
|
||||
if (cb !== undefined) {
|
||||
cb(this.index);
|
||||
}
|
||||
})
|
||||
|
||||
if (this.tc.open === true) {
|
||||
Column() {
|
||||
if (this.tc.args.length > 0 && this.tc.args !== '{}') {
|
||||
Text('参数')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(this.tc.args)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}
|
||||
if (this.tc.result !== undefined && this.tc.result.length > 0) {
|
||||
Text('结果')
|
||||
.fontSize(9.5).fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 6, bottom: 2 })
|
||||
Text(this.tc.result)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().preText)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 7, right: 7, top: 5, bottom: 5 })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.maxLines(8)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
// 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
|
||||
.margin({ bottom: 4 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3, top: 1, right: 1, bottom: 1 },
|
||||
color: {
|
||||
left: tcLeftColor(this.tc, this.palette().accent),
|
||||
top: this.palette().kvBorder,
|
||||
right: this.palette().kvBorder,
|
||||
bottom: this.palette().kvBorder,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 「后端连接」二级页面。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:连接列表 UI、增删改表单与其状态、
|
||||
* 以及切换连接后必须做的连带动作(刷新 ApiClient / 重启前台桥)
|
||||
* 都属于这一个功能域,收在一个组件里。
|
||||
*
|
||||
* connections / currentId 用 @Link 与一级页面共享:一级页的入口行
|
||||
* 要显示"几个连接配置"和当前连接名,两边必须是同一份数据。
|
||||
*/
|
||||
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants';
|
||||
import { SubPageLayer, PlainCard } from './SubPage';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@Component
|
||||
export struct ConnectionsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Link connections: ConnectionConfig[];
|
||||
@Link currentId: string;
|
||||
onBack?: () => void;
|
||||
onToast?: (msg: string, isError: boolean) => void;
|
||||
@State showAddForm: boolean = false;
|
||||
@State addFormVisible: boolean = false;
|
||||
/**
|
||||
* 表单当前在编辑哪条连接:空串表示新建。
|
||||
*
|
||||
* 之前只有"添加"入口,ConnStore.updateConnection 写好了却没有任何调用者,
|
||||
* 于是地址填错的连接只能删掉重建(API Key 也得重敲)。同一套表单
|
||||
* 靠这个 id 区分保存走 add 还是 update。
|
||||
*/
|
||||
@State editingId: string = '';
|
||||
@State editUrl: string = '';
|
||||
@State editApiKey: string = '';
|
||||
@State editName: string = '';
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private toast(msg: string, isError: boolean): void {
|
||||
const cb: ((m: string, e: boolean) => void) | undefined = this.onToast;
|
||||
if (cb !== undefined) {
|
||||
cb(msg, isError);
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */
|
||||
private syncConnFlag(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
||||
}
|
||||
|
||||
private currentConnName(): string {
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
if (this.connections[i].id === this.currentId) {
|
||||
return this.connections[i].name;
|
||||
}
|
||||
}
|
||||
return '未配置';
|
||||
}
|
||||
|
||||
private selectConnection(id: string): void {
|
||||
connStore.setCurrent(id).then(() => {
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.syncConnFlag();
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('已切换连接', false);
|
||||
});
|
||||
}
|
||||
|
||||
private addConnection(): void {
|
||||
const name: string = this.editName.trim();
|
||||
const url: string = this.editUrl.trim();
|
||||
const apiKey: string = this.editApiKey.trim();
|
||||
if (name.length === 0 || url.length === 0) {
|
||||
this.toast('名称和地址不能为空', true);
|
||||
return;
|
||||
}
|
||||
if (this.editingId.length > 0) {
|
||||
this.updateConnection(this.editingId, name, url, apiKey);
|
||||
return;
|
||||
}
|
||||
connStore.addConnection(name, url, apiKey).then(() => {
|
||||
this.closeConnForm();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.syncConnFlag();
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已添加', false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对已有连接的修改。
|
||||
*
|
||||
* 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是
|
||||
* ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。
|
||||
*/
|
||||
private updateConnection(id: string, name: string, url: string, apiKey: string): void {
|
||||
connStore.updateConnection(id, name, url, apiKey).then(() => {
|
||||
this.closeConnForm();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.syncConnFlag();
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已更新', false);
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开表单:id 为空是新建,非空是编辑并回填原值(API Key 一并带出,避免用户重敲)。 */
|
||||
private openConnForm(conn: ConnectionConfig | null): void {
|
||||
this.showAddForm = true;
|
||||
this.addFormVisible = false;
|
||||
if (conn === null) {
|
||||
this.editingId = '';
|
||||
this.editName = '';
|
||||
this.editUrl = '';
|
||||
this.editApiKey = '';
|
||||
} else {
|
||||
this.editingId = conn.id;
|
||||
this.editName = conn.name;
|
||||
this.editUrl = conn.url;
|
||||
this.editApiKey = conn.apiKey;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.addFormVisible = true;
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private closeConnForm(): void {
|
||||
this.showAddForm = false;
|
||||
this.addFormVisible = false;
|
||||
this.editingId = '';
|
||||
this.editName = '';
|
||||
this.editUrl = '';
|
||||
this.editApiKey = '';
|
||||
}
|
||||
|
||||
private deleteConnection(id: string): void {
|
||||
connStore.deleteConnection(id).then(() => {
|
||||
this.connections = connStore.getConnections();
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
} else {
|
||||
apiClient.clearConnection();
|
||||
}
|
||||
this.syncConnFlag();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已删除', false);
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '后端连接',
|
||||
tab: 3,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
Column() {
|
||||
PlainCard({ caption: '当前连接' }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Text(this.currentConnName())
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Button('+ 添加')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.openConnForm(null);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
if (this.showAddForm) {
|
||||
Column() {
|
||||
Text(this.editingId.length > 0 ? '编辑连接' : '新建连接')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ bottom: 10 })
|
||||
TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName })
|
||||
.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 })
|
||||
.margin({ bottom: 10 })
|
||||
.onChange((v: string) => {
|
||||
this.editName = v;
|
||||
})
|
||||
TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl })
|
||||
.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 })
|
||||
.margin({ bottom: 10 })
|
||||
.onChange((v: string) => {
|
||||
this.editUrl = v;
|
||||
})
|
||||
TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey })
|
||||
.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(InputType.Password).margin({ bottom: 12 })
|
||||
.onChange((v: string) => {
|
||||
this.editApiKey = v;
|
||||
})
|
||||
|
||||
Row() {
|
||||
Button('取消')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.closeConnForm();
|
||||
})
|
||||
Blank()
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.addConnection();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.margin({ top: 12 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.opacity(this.addFormVisible ? 1 : 0)
|
||||
.translate({ y: this.addFormVisible ? 0 : 12 })
|
||||
.animation({ duration: 220, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
|
||||
PlainCard({ caption: '全部连接' }) {
|
||||
if (this.connections.length === 0) {
|
||||
Text('暂无连接。点击上方“添加”配置后端地址。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
ForEach(this.connections, (conn: ConnectionConfig) => {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(conn.id === this.currentId ? this.palette().accent : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(conn.name)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(conn.url)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (conn.id !== this.currentId) {
|
||||
Button('切换')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.selectConnection(conn.id);
|
||||
})
|
||||
} else {
|
||||
Text('使用中')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().accentBg)
|
||||
.margin({ right: 6 })
|
||||
}
|
||||
Button('编辑')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.openConnForm(conn);
|
||||
})
|
||||
Button('删除')
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor('#E84026')
|
||||
.onClick(() => {
|
||||
this.deleteConnection(conn.id);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3 },
|
||||
color: conn.id === this.currentId ? this.palette().accent : Color.Transparent,
|
||||
})
|
||||
.margin({ bottom: 6 })
|
||||
// 键里带上 name/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体,
|
||||
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput,
|
||||
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
|
||||
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
316
cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets
Normal file
316
cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets
Normal file
@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
|
||||
*
|
||||
* 从 pages/DevicePage.ets 抽出(原来是 LocalDeviceContent / CapsContent /
|
||||
* GatewayContent / OnlineDevicesContent 四个 @Builder + KvRow)。
|
||||
* 每个面板自带 SubPageLayer 外壳(标题、所属 Tab、返回、刷新),
|
||||
* 页面只保留路由分发 —— 与 SettingsPage 拆出的三个 Pane 同一套做法。
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL,
|
||||
ANIM_ENTER } from '../common/Constants';
|
||||
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { PlainCard, SubPageLayer } from './SubPage';
|
||||
|
||||
/** 通用 KV 行(面板之间共用) */
|
||||
@Component
|
||||
export struct DeviceKvRow {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop label: string = '';
|
||||
@Prop value: string = '';
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
Text(this.label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
Text(this.value)
|
||||
.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 })
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:本机设备(基本信息 + 远程控制授权) */
|
||||
@Component
|
||||
export struct DeviceLocalPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop deviceId: string = '';
|
||||
@Prop authorized: boolean = false;
|
||||
onBack?: () => void;
|
||||
onToggleAuth?: (on: boolean) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
PlainCard({ caption: '基本信息' }) {
|
||||
DeviceKvRow({ label: '设备 ID', value: this.deviceId.length > 0 ? this.deviceId : '未注册' })
|
||||
DeviceKvRow({ label: '名称', value: 'HomeAgent OHOS' })
|
||||
DeviceKvRow({ label: '类型', value: '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) => {
|
||||
const cb: ((on: boolean) => void) | undefined = this.onToggleAuth;
|
||||
if (cb !== undefined) {
|
||||
cb(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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:设备能力(本机能被 agent 调用的能力清单) */
|
||||
@Component
|
||||
export struct DeviceCapsPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
onBack?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
PlainCard({ caption: '能力清单' }) {
|
||||
Text('agent 通过设备桥可调用的本机能力:')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LOCAL_DEVICE_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%')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:设备通道(网关地址 / Token / 连接状态) */
|
||||
@Component
|
||||
export struct DeviceGatewayPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop bridgeUrl: string = '';
|
||||
@Prop bridgeToken: string = '';
|
||||
@Prop bridgeConnected: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
PlainCard({ caption: '连接信息' }) {
|
||||
DeviceKvRow({ label: '网关地址', value: this.bridgeUrl.length > 0 ? this.bridgeUrl : '-' })
|
||||
DeviceKvRow({ label: 'Token', value: this.bridgeToken.length > 0 ? '已从连接继承' : '未配置' })
|
||||
DeviceKvRow({ label: '状态', value: this.bridgeConnected ? '已连接' : '未连接' })
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
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(() => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级:接入的设备(网关侧在线设备列表) */
|
||||
@Component
|
||||
export struct DeviceListPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop devices: DeviceInfo[] = [];
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
showRefresh: true,
|
||||
onBack: () => {
|
||||
const cb: (() => void) | undefined = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
onRefresh: () => {
|
||||
const cb: (() => void) | undefined = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
}) {
|
||||
DeviceOnlineList({ devices: this.devices })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线设备列表本体(抽出来只是为了让 DeviceListPane 的 build 更短) */
|
||||
@Component
|
||||
struct DeviceOnlineList {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop devices: DeviceInfo[] = [];
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
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())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 设备页一级入口列表(本机 / 通道两组)。
|
||||
*
|
||||
* 从 pages/DevicePage.ets 抽出(原来是 RootEntries 一个 @Builder)。
|
||||
* 宽屏高亮由组件自己读 AppStorage 的 isWideScreen 决定,
|
||||
* 页面只负责给 activeSub 与四个打开动作。
|
||||
*/
|
||||
|
||||
import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession';
|
||||
import { SUB_CAPS, SUB_GATEWAY, SUB_LIST, SUB_LOCAL } from '../common/DeviceModel';
|
||||
import { NavGroup, NavRow } from './SubPage';
|
||||
|
||||
@Component
|
||||
export struct DeviceRootEntries {
|
||||
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||
@Prop activeSub: string = '';
|
||||
@Prop deviceId: string = '';
|
||||
@Prop bridgeConnected: boolean = false;
|
||||
@Prop bridgeUrl: string = '';
|
||||
@Prop loadingDevices: boolean = false;
|
||||
@Prop deviceCount: number = 0;
|
||||
onOpen?: (id: string) => void;
|
||||
|
||||
private capsCount(): number {
|
||||
return LOCAL_DEVICE_CAPS.length;
|
||||
}
|
||||
|
||||
private open(id: string): void {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpen;
|
||||
if (cb !== undefined) {
|
||||
cb(id);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 一级入口列表整体作为一个容器根节点:@Component 的 build() 只允许一个根,
|
||||
// 页面侧仍是 `.padding(...)` 的 Column,逐项布局与拆分前一致。
|
||||
Column() {
|
||||
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.open(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.open(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.open(SUB_GATEWAY);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_devices_multi'),
|
||||
title: '接入的设备',
|
||||
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
|
||||
value: this.deviceCount.toString() + ' 台',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_LIST,
|
||||
onTap: () => {
|
||||
this.open(SUB_LIST);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 插件详情(二级页面内容)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出。
|
||||
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
|
||||
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 配置 / 操作。
|
||||
*/
|
||||
|
||||
import { PluginRow, PluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_SM,
|
||||
ANIM_FAST, COLOR_ERROR } from '../common/Constants';
|
||||
import { pluginDetailStatusLine, pluginStatusColor } from '../common/PluginStatus';
|
||||
import { PlainCard } from './SubPage';
|
||||
import { SettingsEditor } from './SettingsEditor';
|
||||
|
||||
@Component
|
||||
export struct PluginDetailPane {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop detail: PluginDetail;
|
||||
/** 当前查看详情的插件名(可能还没拿到 detail.name) */
|
||||
@Prop activeName: string = '';
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
/** 一级列表里对应的那一行;取不到详情时用它兜底,也为操作按钮提供状态 */
|
||||
@Prop row: PluginRow | undefined = undefined;
|
||||
onToggle?: () => void;
|
||||
onRemove?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
// ---- 取值助手: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 isBuiltin(): boolean {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? !r.external : false;
|
||||
}
|
||||
|
||||
private isDisabled(): boolean {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? r.disabled : false;
|
||||
}
|
||||
|
||||
private tools(): string[] {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
if (r === undefined) {
|
||||
return [];
|
||||
}
|
||||
return r.tools ?? [];
|
||||
}
|
||||
|
||||
private statusLine(): string {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? pluginDetailStatusLine(r, this.detail.deprecated) : '未加载';
|
||||
}
|
||||
|
||||
private statusColor(): string {
|
||||
const r: PluginRow | undefined = this.row;
|
||||
return r !== undefined ? pluginStatusColor(r, this.palette().textMuted) : 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
|
||||
Badges() {
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 7, height: 7 })
|
||||
.fill(this.statusColor())
|
||||
Text(this.statusLine())
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
|
||||
@Builder
|
||||
ToolsCard() {
|
||||
if (this.tools().length > 0) {
|
||||
PlainCard({ caption: '注册的工具 (' + this.tools().length.toString() + ')' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.tools(), (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
|
||||
ActionsCard() {
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.isDisabled() ? '启用' : '禁用')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.isDisabled()
|
||||
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
|
||||
})
|
||||
.fontColor(this.isDisabled()
|
||||
? this.palette().textSecondary : '#D99A2B')
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onToggle;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
if (!this.isBuiltin()) {
|
||||
Button('卸载')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor(COLOR_ERROR)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onRemove;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
// 单一根节点:与原来作为 @Builder 直接铺在 SubPageLayer 的 Column 里同构,
|
||||
// 保持 alignItems Start,避免文本被默认居中对齐。
|
||||
Column() {
|
||||
if (this.busy) {
|
||||
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.errorText.length > 0) {
|
||||
Text(this.errorText)
|
||||
.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.Badges()
|
||||
|
||||
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.ToolsCard()
|
||||
|
||||
// 插件配置: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.ActionsCard()
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 插件一级列表(列表卡 + 三种占位态)。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出:列表只负责"选谁",
|
||||
* 描述/工具/启停全部下沉到详情页。
|
||||
*/
|
||||
|
||||
import { PluginRow } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD,
|
||||
ANIM_FAST, ANIM_ENTER } from '../common/Constants';
|
||||
import { pluginRowSubtitle, pluginStatusColor } from '../common/PluginStatus';
|
||||
import { noConnectionMessage } from '../common/UserError';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* 单个插件行卡。
|
||||
*
|
||||
* 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
|
||||
* 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
|
||||
* 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
|
||||
*/
|
||||
@Component
|
||||
export struct PluginListCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop plugin: PluginRow;
|
||||
/** 是否当前选中(宽屏高亮左侧列表项)。单独用基本类型传,选中态变更才能触发更新 */
|
||||
@Prop active: boolean = false;
|
||||
onTap?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
MotionBase({ pressEnabled: true }) {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.active ? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
if (this.plugin.version !== undefined && this.plugin.version.length > 0) {
|
||||
Text('v' + this.plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(pluginStatusColor(this.plugin, this.palette().textMuted))
|
||||
Text(pluginRowSubtitle(this.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.active ? this.palette().accent : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.active ? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.active ? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
// 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.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 }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级列表整体:占位态 + 行卡 ForEach。
|
||||
* 取数留在页面里,这里只吃数据。
|
||||
*/
|
||||
@Component
|
||||
export struct PluginListView {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop plugins: PluginRow[] = [];
|
||||
@Prop busy: boolean = false;
|
||||
@Prop activeName: string = '';
|
||||
@Prop hasConn: boolean = false;
|
||||
onSelect?: (name: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// ── 加载中 / 未配置 / 空列表三种占位态 ──
|
||||
if (this.busy && 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 (!this.hasConn) {
|
||||
Text(noConnectionMessage())
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
if (!this.busy && this.plugins.length === 0 && this.hasConn) {
|
||||
Text('暂无已加载插件')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
ForEach(this.plugins, (plugin: PluginRow) => {
|
||||
PluginListCard({
|
||||
plugin: plugin,
|
||||
active: this.activeName === plugin.name,
|
||||
onTap: () => {
|
||||
const cb: ((name: string) => void) | undefined = this.onSelect;
|
||||
if (cb !== undefined) {
|
||||
cb(plugin.name);
|
||||
}
|
||||
},
|
||||
})
|
||||
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 安装表单悬浮卡。
|
||||
*
|
||||
* 从 pages/PluginsPage.ets 抽出(浮在内容之上的独立图层,与列表/详情无关)。
|
||||
* 轻提示条与设置页共用,见 components/ToastBar.ets。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
|
||||
ANIM_ENTER } from '../common/Constants';
|
||||
|
||||
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
|
||||
@Component
|
||||
export struct PluginInstallForm {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop url: string = '';
|
||||
onUrlChange?: (v: string) => void;
|
||||
onInstall?: () => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
TextInput({ placeholder: '.hmap 包下载 URL', text: this.url })
|
||||
.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) => {
|
||||
const cb: ((v: string) => void) | undefined = this.onUrlChange;
|
||||
if (cb !== undefined) {
|
||||
cb(v);
|
||||
}
|
||||
})
|
||||
Button('安装')
|
||||
.height(36)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.margin({ left: 6 })
|
||||
.onClick(() => {
|
||||
const cb: (() => void) | undefined = this.onInstall;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
.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 }))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,385 @@
|
||||
import {
|
||||
ThemePalette, DARK_PALETTE, LIGHT_PALETTE,
|
||||
RADIUS_SM, RADIUS_MD, ANIM_FAST,
|
||||
COLOR_ACCENT, COLOR_EMERALD, COLOR_FROST_300,
|
||||
} from '../common/Constants';
|
||||
import { statusStore, RuntimeSnapshot, RuntimeQueue, K_REV } from '../common/StatusStore';
|
||||
import {
|
||||
stageTrail, StageEvent, StageGroup, STAGE_GROUPS, phaseGroup,
|
||||
K_STAGE_PHASE, K_STAGE_REV,
|
||||
} from '../common/StageTrail';
|
||||
|
||||
/** 四级中断 + 排队的配色:与 WebUI 总览同一套(级别色贯穿框头、槽位、描边) */
|
||||
const LV_COLORS: string[] = ['#A3BE8C', '#4A90D9', '#0A59F7', '#FFA657', '#FF5C7A'];
|
||||
/** 阶段管道里的「工具」格(循环格):一轮内可能调几十次,只露最新一条 */
|
||||
const TOOL_GROUP: number = 2;
|
||||
/** 索引 = lv(0 排队 / 1 L1 / 2 L2 / 3 L3 / 4 L4) */
|
||||
function lvColor(lv: number): string {
|
||||
if (lv < 0 || lv > 4) {
|
||||
return COLOR_ACCENT;
|
||||
}
|
||||
return LV_COLORS[lv];
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行态面板:阶段管道 + 中断队列。
|
||||
*
|
||||
* 与 WebUI / 桌面版同一套设计语言:**等大表框**。
|
||||
* 此前鸿蒙端完全没有运行态展示(「状态」Tab 已并入设置页 + 二级明细),
|
||||
* 这里补在二级明细页顶部。
|
||||
*
|
||||
* 两块数据来源不同:
|
||||
* - 队列 / 计数:StatusStore 轮询 /runtime,靠 K_REV 通知;
|
||||
* - 阶段 + 本轮轨迹:StageTrail 由 SSE 的 stage 事件喂入,靠 K_STAGE_REV 通知。
|
||||
*/
|
||||
@Component
|
||||
export struct RuntimePanel {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 宽屏(平板/折叠)5 框一行;手机 3+2。窄屏硬塞 5 框会把标签挤成省略号。 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@StorageProp(K_REV) @Watch('onStatusRev') private statusRev: number = 0;
|
||||
@StorageProp(K_STAGE_REV) @Watch('onStageRev') private stageRev: number = 0;
|
||||
@StorageProp(K_STAGE_PHASE) private phase: string = '';
|
||||
@State private snap: RuntimeSnapshot | undefined = undefined;
|
||||
/** 格槽下标:ArkUI 的 ForEach 遍历的是数组,所以把「几格」摊成下标数组 */
|
||||
@State private slotIdx: number[] = [];
|
||||
@State private trail: StageEvent[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.pull();
|
||||
}
|
||||
|
||||
private onStatusRev(): void {
|
||||
this.pull();
|
||||
}
|
||||
|
||||
private onStageRev(): void {
|
||||
this.trail = stageTrail.snapshot();
|
||||
}
|
||||
|
||||
private pull(): void {
|
||||
const s: RuntimeSnapshot | undefined = statusStore.getRuntime();
|
||||
this.snap = s;
|
||||
if (s !== undefined) {
|
||||
const idx: number[] = [];
|
||||
for (let i = 0; i < s.slots; i++) {
|
||||
idx.push(i);
|
||||
}
|
||||
this.slotIdx = idx;
|
||||
} else {
|
||||
this.slotIdx = [];
|
||||
}
|
||||
this.trail = stageTrail.snapshot();
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 该阶段本轮发生的事件 */
|
||||
private eventsOf(group: number): StageEvent[] {
|
||||
const out: StageEvent[] = [];
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const e: StageEvent = this.trail[i];
|
||||
if (e.group === group) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该阶段本轮事件的**最后一条**(最新)。没有则 undefined。 */
|
||||
private latestEvent(group: number): StageEvent | undefined {
|
||||
let out: StageEvent | undefined = undefined;
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const e: StageEvent = this.trail[i];
|
||||
if (e.group === group) {
|
||||
out = e;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该阶段本轮事件条数合计(同一工具连调会累加到 count 上) */
|
||||
private totalCount(group: number): number {
|
||||
let n: number = 0;
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const e: StageEvent = this.trail[i];
|
||||
if (e.group === group) {
|
||||
n += e.count;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Builder
|
||||
tile(label: string, value: string, warn: boolean) {
|
||||
Column({ space: 2 }) {
|
||||
Text(value)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(warn ? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
Text(label)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
}
|
||||
|
||||
/** 阶段管道的一格 */
|
||||
@Builder
|
||||
stageCell(g: StageGroup, active: boolean) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 5 }) {
|
||||
// 用一个几何圆点而非图标:本项目没有为「阶段」准备的图形资源,
|
||||
// 而猜 sys.media.* 名称会直接编译不过;也不允许用 emoji 充当图标。
|
||||
// 不用 Circle().fill():那是 SDK 26 起的 API,本工程兼容版本是 6.1.1(24)。
|
||||
Row()
|
||||
.width(7)
|
||||
.height(7)
|
||||
.borderRadius(4)
|
||||
.backgroundColor(active ? this.palette().accent : this.palette().textMuted)
|
||||
Text(g.label)
|
||||
.fontSize(11)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(active ? this.palette().accent : this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 本阶段本轮发生的事件;没有就给一个弱化的「无」,让框不空着。
|
||||
//
|
||||
// 「工具」格是循环格:一轮里可能调几十次工具/输出通道。把每一次都追加成
|
||||
// 一行,这格会被撑成长条,反而看不出「现在在调什么」。所以它只保留**最新
|
||||
// 一条**,右侧给本轮累计次数(与 WebUI/桌面版同一口径)。
|
||||
Column({ space: 3 }) {
|
||||
if (g.group === TOOL_GROUP) {
|
||||
if (this.eventsOf(g.group).length > 0) {
|
||||
Row({ space: 3 }) {
|
||||
Text(this.latestEvent(g.group)?.short ?? '')
|
||||
.fontSize(9)
|
||||
.fontColor(this.latestEvent(g.group)?.kind === 'output' ? this.palette().accent
|
||||
: (this.latestEvent(g.group)?.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text('x' + this.totalCount(g.group).toString())
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
} else {
|
||||
Text('无')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.opacity(0.5)
|
||||
}
|
||||
} else {
|
||||
ForEach(this.eventsOf(g.group), (e: StageEvent) => {
|
||||
Row({ space: 3 }) {
|
||||
Text(e.short)
|
||||
.fontSize(9)
|
||||
.fontColor(e.kind === 'output' ? this.palette().accent
|
||||
: (e.kind === 'tool' ? COLOR_FROST_300 : this.palette().textSecondary))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (e.count > 1) {
|
||||
Text('x' + e.count.toString())
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}, (e: StageEvent, i: number) => i.toString() + ':' + e.short)
|
||||
|
||||
if (this.eventsOf(g.group).length === 0) {
|
||||
Text('无')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.opacity(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(active ? this.palette().accentBg : this.palette().bgHover)
|
||||
.border({ width: 1, color: active ? this.palette().accent : this.palette().glassBorder })
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
}
|
||||
|
||||
/** 中断队列的一格 */
|
||||
@Builder
|
||||
queueCell(q: RuntimeQueue) {
|
||||
Column({ space: 5 }) {
|
||||
Row({ space: 4 }) {
|
||||
Text(q.name)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(lvColor(q.lv))
|
||||
.maxLines(1)
|
||||
Text(q.desc)
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
|
||||
Text(q.depth.toString())
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
|
||||
// 格槽:固定可见的「车位」。用进度条时深度为 0 宽度就是 0,
|
||||
// 整格只剩文字,看上去就是「这块空着」。
|
||||
Row({ space: 2 }) {
|
||||
ForEach(this.slotIdx, (i: number) => {
|
||||
Row()
|
||||
.layoutWeight(1)
|
||||
.height(11)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(i < q.depth ? lvColor(q.lv) : this.palette().bgHover)
|
||||
}, (i: number) => q.lv.toString() + '-' + i.toString())
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (q.lv > 0) {
|
||||
Text(q.registered.toString() + ' 登记 · ' + q.preempted.toString() + ' 抢占')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
} else {
|
||||
Text('无级别')
|
||||
.fontSize(9)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 8, right: 8, top: 9, bottom: 9 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: 1,
|
||||
color: q.depth > 0 ? lvColor(q.lv) : this.palette().glassBorder,
|
||||
style: q.lv === 0 ? BorderStyle.Dashed : BorderStyle.Solid,
|
||||
})
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Text('运行态')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.margin({ bottom: 8 })
|
||||
|
||||
if (this.snap === undefined) {
|
||||
Text('运行态数据不可用')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
} else {
|
||||
// 四个数字块
|
||||
Row({ space: 8 }) {
|
||||
this.tile('排队', this.snap.ready.toString(), this.snap.ready > 0)
|
||||
this.tile('中断', this.snap.pending.toString(), this.snap.pending > 0)
|
||||
this.tile('栈', this.snap.stack.toString() + '/' + this.snap.maxStack.toString(),
|
||||
this.snap.stack > 0)
|
||||
this.tile('子代理', this.snap.subagents.toString(), false)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 阶段管道:等大表框,事件落在所属阶段那一格
|
||||
Text('阶段管道')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ top: 14, bottom: 6 })
|
||||
if (this.isWide) {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(STAGE_GROUPS, (g: StageGroup) => {
|
||||
this.stageCell(g, phaseGroup(this.phase) === g.group)
|
||||
}, (g: StageGroup) => 'w' + g.group.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
} else {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(STAGE_GROUPS.slice(0, 3), (g: StageGroup) => {
|
||||
this.stageCell(g, phaseGroup(this.phase) === g.group)
|
||||
}, (g: StageGroup) => 'n0' + g.group.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
Row({ space: 6 }) {
|
||||
ForEach(STAGE_GROUPS.slice(3), (g: StageGroup) => {
|
||||
this.stageCell(g, phaseGroup(this.phase) === g.group)
|
||||
}, (g: StageGroup) => 'n1' + g.group.toString())
|
||||
// 占位:第二行只有 2 格,补一格位置让框宽与第一行对齐
|
||||
Row().layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
|
||||
// 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)
|
||||
Text('队列')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ top: 14, bottom: 6 })
|
||||
if (this.isWide) {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(this.snap.queues, (q: RuntimeQueue) => {
|
||||
this.queueCell(q)
|
||||
}, (q: RuntimeQueue) => 'wq' + q.lv.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
} else {
|
||||
Row({ space: 6 }) {
|
||||
ForEach(this.snap.queues.slice(0, 3), (q: RuntimeQueue) => {
|
||||
this.queueCell(q)
|
||||
}, (q: RuntimeQueue) => 'nq0' + q.lv.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
Row({ space: 6 }) {
|
||||
ForEach(this.snap.queues.slice(3), (q: RuntimeQueue) => {
|
||||
this.queueCell(q)
|
||||
}, (q: RuntimeQueue) => 'nq1' + q.lv.toString())
|
||||
Row().layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding(16)
|
||||
.margin({ bottom: 14 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,24 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
|
||||
import { webview } from '@kit.ArkWeb';
|
||||
|
||||
/**
|
||||
* agent 主动推送的前台内容页。
|
||||
*
|
||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||
*
|
||||
* 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 ScreensueHtml)。
|
||||
*
|
||||
* HTML 走 **Web 组件**(用户明确要求):agent 推的常是完整文档 —— 带 <style>
|
||||
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
|
||||
* 对这些一律不渲染,实测只能看到满屏源码。
|
||||
*
|
||||
* 安全:内容来自 agent(第三方),所以显式关掉 JS 与本地文件访问 ——
|
||||
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
|
||||
* 纯文本仍走 Text(无需开销,也不该把文本塞进 Web)。
|
||||
*/
|
||||
@Component
|
||||
export struct ScreensuePage {
|
||||
@ -17,6 +29,16 @@ export struct ScreensuePage {
|
||||
onClose: () => void = () => {
|
||||
};
|
||||
|
||||
/** 内容是不是 HTML(决定走 Web 还是 Text)。 */
|
||||
private htmlMode(): boolean {
|
||||
return looksLikeHtml(this.pushedText);
|
||||
}
|
||||
|
||||
/** HTML 的 base64 载荷(空串表示不是 HTML)。 */
|
||||
private webData(): string {
|
||||
return screensueWebData(this.pushedText, this.isDark);
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
GradientBackground()
|
||||
@ -44,13 +66,22 @@ export struct ScreensuePage {
|
||||
.width('100%')
|
||||
|
||||
Column() {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
if (this.htmlMode()) {
|
||||
// HTML:整篇交给 Web 渲染(base64 loadData,见 screensueWebData)。
|
||||
// 高度固定 420vp:Web 不参与父级自适应测量,给 height('100%')
|
||||
// 会在 Scroll 里塌成 0。内容区本身可滚。
|
||||
ScreenWebView({ data: this.webData(), isDark: this.isDark })
|
||||
.width('100%')
|
||||
.height(420)
|
||||
} else {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
@ -101,3 +132,66 @@ export struct ScreensuePage {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
|
||||
*
|
||||
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web:
|
||||
* `WebviewController` 与 Web 组件是一对一绑定的,必须等组件挂载(onControllerAttached)
|
||||
* 才能真正 loadData;把它隔离在这里,ScreensuePage 只管布局与倒计时。
|
||||
*/
|
||||
@Component
|
||||
struct ScreenWebView {
|
||||
// @Watch 挂在这里是必须的:ScreensuePage 在 `if (screensueVisible)` 里常驻,
|
||||
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
|
||||
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
|
||||
// 实测:连推两条不同 HTML,倒计时变了、Web 里还是旧画面。
|
||||
@Prop @Watch('onDataChanged') data: string = '';
|
||||
@Prop isDark: boolean = true;
|
||||
private controller: webview.WebviewController = new webview.WebviewController();
|
||||
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001)。 */
|
||||
private attached: boolean = false;
|
||||
|
||||
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
|
||||
onDataChanged(): void {
|
||||
if (this.attached) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Web({ src: '', controller: this.controller })
|
||||
// ★ 内容来自 agent(第三方):显式关闭脚本与本地文件访问。
|
||||
// javaScriptAccess 的默认值是 true,不写这一行等于放任远端内容执行脚本。
|
||||
.javaScriptAccess(false)
|
||||
.fileAccess(false)
|
||||
.domStorageAccess(false)
|
||||
.onlineImageAccess(false)
|
||||
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
|
||||
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
|
||||
.horizontalScrollBarAccess(false)
|
||||
.verticalScrollBarAccess(false)
|
||||
.darkMode(WebDarkMode.Off)
|
||||
.backgroundColor(Color.Transparent)
|
||||
// 控制器挂载完才 loadData:过早调用会抛 17100001(控制器未与组件关联)。
|
||||
.onControllerAttached(() => {
|
||||
// 挂载完成才允许 loadData;此前的变更由 onDataChanged 记着,这里补一次。
|
||||
this.attached = true;
|
||||
this.load();
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
|
||||
private load(): void {
|
||||
if (this.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.controller.loadData(this.data, 'text/html', 'base64');
|
||||
} catch (e) {
|
||||
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 单个配置项卡片。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:按 type 分发控件(bool/select/password/text/其他),
|
||||
* 值的保存与「未保存」标记交回页面(页面上持有 valuesStore 与 entries)。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM,
|
||||
ANIM_FAST, ANIM_NORMAL } from '../common/Constants';
|
||||
import { SettingEntry } from '../common/SettingsModel';
|
||||
|
||||
@Component
|
||||
export struct SettingsEntryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop entry: SettingEntry;
|
||||
/** bool 开关 / select 选项:值已知,直接落库 */
|
||||
onSaveValue?: (key: string, value: string) => void;
|
||||
/** 输入类控件的保存:由页面取该 key 的最新编辑值再落库 */
|
||||
onSaveCurrent?: (key: string) => void;
|
||||
/** 输入框内容变化:只更新本地标记,不请求 */
|
||||
onEdit?: (key: string, value: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private saveValue(value: string): void {
|
||||
const cb: ((key: string, value: string) => void) | undefined = this.onSaveValue;
|
||||
if (cb !== undefined) {
|
||||
cb(this.entry.key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private saveCurrent(): void {
|
||||
const cb: ((key: string) => void) | undefined = this.onSaveCurrent;
|
||||
if (cb !== undefined) {
|
||||
cb(this.entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
private editValue(value: string): void {
|
||||
const cb: ((key: string, value: string) => void) | undefined = this.onEdit;
|
||||
if (cb !== undefined) {
|
||||
cb(this.entry.key, value);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(this.entry.displayName)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text(this.entry.dirty ? '未保存' : this.entry.type)
|
||||
.fontSize(10)
|
||||
.fontColor(this.entry.dirty ? '#D99A2B' : this.palette().textMuted)
|
||||
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(this.entry.key)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 1 })
|
||||
|
||||
if (this.entry.description.length > 0) {
|
||||
Text(this.entry.description)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 3 })
|
||||
}
|
||||
|
||||
// control row per type
|
||||
if (this.entry.type === 'bool') {
|
||||
Row() {
|
||||
Text(this.entry.value === 'true' ? 'true' : 'false')
|
||||
.fontSize(12)
|
||||
.fontColor(this.entry.value === 'true' ? '#17A964' : this.palette().textMuted)
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.entry.value === 'true' })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.saveValue(on ? 'true' : 'false');
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else if (this.entry.type === 'select' && this.entry.options.length > 0) {
|
||||
Flex({
|
||||
direction: FlexDirection.Row,
|
||||
justifyContent: FlexAlign.Start,
|
||||
alignItems: ItemAlign.Center,
|
||||
wrap: FlexWrap.Wrap,
|
||||
}) {
|
||||
ForEach(this.entry.options, (opt: string) => {
|
||||
Button(opt)
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.backgroundColor(this.entry.value === opt ? this.palette().accent : this.palette().bgHover)
|
||||
.fontColor(this.entry.value === opt ? Color.White : this.palette().textSecondary)
|
||||
// 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.onClick(() => {
|
||||
this.saveValue(opt);
|
||||
})
|
||||
}, (opt: string) => opt)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else if (this.entry.type === 'password') {
|
||||
Row() {
|
||||
TextInput({ text: this.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(InputType.Password)
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => {
|
||||
this.editValue(v);
|
||||
})
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.saveCurrent();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else if (this.entry.type === 'text') {
|
||||
TextArea({ text: this.entry.value })
|
||||
.width('100%')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.constraintSize({ minHeight: 60, maxHeight: 200 })
|
||||
.margin({ top: 8 })
|
||||
.onChange((v: string) => {
|
||||
this.editValue(v);
|
||||
})
|
||||
Row() {
|
||||
Blank()
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.onClick(() => {
|
||||
this.saveCurrent();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
} else {
|
||||
// string / int / duration
|
||||
Row() {
|
||||
TextInput({ text: this.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 })
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => {
|
||||
this.editValue(v);
|
||||
})
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.saveCurrent();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.entry.dirty ? '#D99A2B' : this.palette().glassBorder,
|
||||
})
|
||||
.margin({ bottom: 10 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:页面本身只剩数据流与导航表,
|
||||
* 这一层是纯布局 —— 所有计数/文案都由页面算好传进来。
|
||||
*/
|
||||
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow } from './PageTopBar';
|
||||
import { SettingsRootEntries } from './SettingsRootEntries';
|
||||
import { ToastBar } from './ToastBar';
|
||||
|
||||
@Component
|
||||
export struct SettingsHome {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 一级入口列表所需的计数/文案(见 SettingsRootEntries) */
|
||||
@Prop connCount: number = 0;
|
||||
@Prop connName: string = '';
|
||||
@Prop sectionCount: number = 0;
|
||||
@Prop coreKeys: number = 0;
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
@Prop themeLabel: string = '';
|
||||
@Prop activeSub: string = '';
|
||||
@Prop isWide: boolean = false;
|
||||
/** 保存进行中:悬浮区显示转圈 */
|
||||
@Prop savingCount: number = 0;
|
||||
@Prop toastMsg: string = '';
|
||||
@Prop toastIsError: boolean = false;
|
||||
onOpen?: (id: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
SettingsRootEntries({
|
||||
connCount: this.connCount,
|
||||
connName: this.connName,
|
||||
sectionCount: this.sectionCount,
|
||||
coreKeys: this.coreKeys,
|
||||
busy: this.busy,
|
||||
errorText: this.errorText,
|
||||
themeLabel: this.themeLabel,
|
||||
activeSub: this.activeSub,
|
||||
isWide: this.isWide,
|
||||
onOpen: (id: string) => {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpen;
|
||||
if (cb !== undefined) {
|
||||
cb(id);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
.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%')
|
||||
|
||||
PageTopBar({ title: '设置' })
|
||||
|
||||
// 一级悬浮区:仅在保存进行中显示一个转圈;
|
||||
// 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌)
|
||||
NavFloatOverlay({ tab: 3 }) {
|
||||
NavFloatRow() {
|
||||
if (this.savingCount > 0) {
|
||||
LoadingProgress()
|
||||
.width(14)
|
||||
.height(14)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 设置一级页的入口列表。
|
||||
*
|
||||
* 从 pages/SettingsPage.ets 抽出:纯展示 + 跳转回调,
|
||||
* 所有计数/文案由页面算好传进来(页面才是这些状态的持有者)。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { SUB_STATUS, SUB_CONNECTIONS, SUB_BACKEND, SUB_SECTION, SUB_APPEARANCE } from '../common/SettingsModel';
|
||||
import { StatusSummaryCard } from './StatusCards';
|
||||
import { NavGroup, NavRow } from './SubPage';
|
||||
|
||||
@Component
|
||||
export struct SettingsRootEntries {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 连接配置条数 */
|
||||
@Prop connCount: number = 0;
|
||||
/** 当前生效连接的展示名 */
|
||||
@Prop connName: string = '';
|
||||
/** 后端配置:分类数 / 核心项数 / 是否加载中 / 错误文案 */
|
||||
@Prop sectionCount: number = 0;
|
||||
@Prop coreKeys: number = 0;
|
||||
@Prop busy: boolean = false;
|
||||
@Prop errorText: string = '';
|
||||
/** 当前主题的中文名(一级行右侧摘要值) */
|
||||
@Prop themeLabel: string = '';
|
||||
/** 宽屏分栏时用来高亮右栏对应的入口行 */
|
||||
@Prop activeSub: string = '';
|
||||
@Prop isWide: boolean = false;
|
||||
onOpen?: (id: string) => void;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private open(id: string): void {
|
||||
const cb: ((id: string) => void) | undefined = this.onOpen;
|
||||
if (cb !== undefined) {
|
||||
cb(id);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 运行状态摘要(原「状态」Tab):整卡可点,进入明细二级页
|
||||
StatusSummaryCard({
|
||||
onTap: () => {
|
||||
this.open(SUB_STATUS);
|
||||
},
|
||||
})
|
||||
|
||||
NavGroup({ caption: '连接' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_link'),
|
||||
title: '后端连接',
|
||||
subtitle: this.connCount.toString() + ' 个连接配置',
|
||||
value: this.connName,
|
||||
selected: this.isWide && this.activeSub === SUB_CONNECTIONS,
|
||||
onTap: () => {
|
||||
this.open(SUB_CONNECTIONS);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_tune'),
|
||||
title: '核心配置',
|
||||
subtitle: this.sectionCount.toString() + ' 个分类 · ' + this.coreKeys.toString() + ' 项',
|
||||
value: this.busy ? '加载中' : (this.errorText.length > 0 ? '不可用' : ''),
|
||||
showDivider: false,
|
||||
selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION),
|
||||
onTap: () => {
|
||||
this.open(SUB_BACKEND);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
NavGroup({ caption: '个性化' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_theme'),
|
||||
title: '外观',
|
||||
subtitle: '主题与背景图',
|
||||
value: this.themeLabel,
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_APPEARANCE,
|
||||
onTap: () => {
|
||||
this.open(SUB_APPEARANCE);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (this.errorText.length > 0) {
|
||||
Text(this.errorText)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4, right: 4 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@ -4,285 +4,13 @@
|
||||
* 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.
|
||||
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
|
||||
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
|
||||
*/
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
import { MdBlock, MdSpan, parseBlocks, parseInline } from '../common/MarkdownParser';
|
||||
|
||||
@Component
|
||||
export struct StaticMarkdownView {
|
||||
|
||||
@ -2,9 +2,10 @@ 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 { RuntimePanel } from './RuntimePanel';
|
||||
import {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
|
||||
} from '../common/StatusStore';
|
||||
|
||||
/**
|
||||
@ -19,6 +20,8 @@ export struct StatusSummaryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_VERSION) private version: string = '-';
|
||||
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
|
||||
@StorageProp(K_BUILD) private buildSub: string = '';
|
||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@ -117,10 +120,15 @@ export struct StatusSummaryCard {
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)。
|
||||
//
|
||||
// 必须是**子组件**(@Prop 单向下发)而不是本组件里的 @Builder:
|
||||
// ArkUI 的 @Builder 按值传参是“快照”语义,父组件重渲染时
|
||||
// 不会用新值重跑 builder —— 实测:K_PLUGINS 已经是 35,
|
||||
// 但 @Builder 里画的还是首次的 0,永远显示 '-'。
|
||||
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)
|
||||
KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
|
||||
KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 14 })
|
||||
@ -130,14 +138,24 @@ export struct StatusSummaryCard {
|
||||
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 })
|
||||
Column() {
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
|
||||
Text(this.buildSub)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.visibility(this.buildSub.length > 0 ? Visibility.Visible : Visibility.None)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
Text('明细')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
@ -201,6 +219,51 @@ export struct StatusSummaryCard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力计数小卡(插件 / 工具)。
|
||||
*
|
||||
* 单独成组件而非 @Builder:@Builder 的按值参数不会随父组件重渲染而刷新,
|
||||
* 数值会永远停在首次渲染的 0。@Prop 是单向下发,父组件因 @StorageProp
|
||||
* 变化重渲染时,子组件拿到新值并重绘。
|
||||
*/
|
||||
@Component
|
||||
struct KpiTile {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop icon: Resource = $r('app.media.ic_plug');
|
||||
@Prop label: string = '';
|
||||
@Prop value: number = 0;
|
||||
@Prop tint: string = '';
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
Image(this.icon)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(this.tint)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(this.value > 0 ? this.value.toString() : '-')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
||||
*
|
||||
@ -271,6 +334,11 @@ export struct StatusDetailContent {
|
||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||
|
||||
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
|
||||
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
|
||||
// 后者是进这个页面最先想看的。
|
||||
RuntimePanel()
|
||||
|
||||
ForEach(this.groups, (g: StatGroup) => {
|
||||
Column() {
|
||||
Text(g.title)
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 右下角轻提示条(设置页与插件页共用)。
|
||||
*
|
||||
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
|
||||
* 用 bordered 表达这个差异(width 0 的边框不占位、不可见)。
|
||||
*
|
||||
* 配色由调用方决定:颜色标志必须在调用方落 animateTo 之前先写好,
|
||||
* 否则第一帧会用上一条 toast 的配色。
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, ANIM_NORMAL } from '../common/Constants';
|
||||
|
||||
@Component
|
||||
export struct ToastBar {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop msg: string = '';
|
||||
@Prop isError: boolean = false;
|
||||
/** 是否带一圈语义色描边(插件页用,设置页不用) */
|
||||
@Prop bordered: boolean = false;
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
build() {
|
||||
if (this.msg.length > 0) {
|
||||
Row() {
|
||||
Text(this.msg)
|
||||
.fontSize(13)
|
||||
.fontColor(this.isError ? this.palette().toastErrorText : this.palette().toastText)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.isError ? this.palette().toastErrorBg : this.palette().toastBg)
|
||||
.border({
|
||||
width: this.bordered ? 1 : 0,
|
||||
color: this.isError ? '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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,14 @@ export interface ChatMessage {
|
||||
toolCalls?: ToolCallInfo[];
|
||||
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
||||
source?: string;
|
||||
/**
|
||||
* 服务端单调递增序号(后端 ChatMsg.seq)。
|
||||
*
|
||||
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
|
||||
* 本地乐观消息没有 seq,服务端回显后靠 seq 认领并去重。
|
||||
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
|
||||
*/
|
||||
seq?: number;
|
||||
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
||||
attachment?: ChatAttachment;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,19 +4,25 @@ import { apiClient } from '../common/ApiClient';
|
||||
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 { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
|
||||
WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
import { DeviceRootEntries } from '../components/DeviceRootEntries';
|
||||
import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** 二级页面标识 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_LOCAL: string = 'local';
|
||||
const SUB_CAPS: string = 'caps';
|
||||
const SUB_GATEWAY: string = 'gateway';
|
||||
const SUB_LIST: string = 'list';
|
||||
import { markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
|
||||
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
|
||||
|
||||
/**
|
||||
* 设备页:只做"页面壳"。
|
||||
*
|
||||
* 拆分后的分工(拆分前这里是 560 行的单文件):
|
||||
* - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets
|
||||
* - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets
|
||||
* - device_id 兜底与在线设备解析 → common/DeviceModel.ets(路由 id 也在那)
|
||||
* 本文件保留:导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。
|
||||
*/
|
||||
@Component
|
||||
export struct DevicePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@ -39,18 +45,8 @@ export struct DevicePage {
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
|
||||
this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
|
||||
this.authorized = connStore.getDeviceAuth();
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
@ -133,31 +129,7 @@ export struct DevicePage {
|
||||
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);
|
||||
}
|
||||
}
|
||||
const list: DeviceInfo[] = parseOnlineDevices(parsed);
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.devices = list;
|
||||
});
|
||||
@ -177,7 +149,17 @@ export struct DevicePage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.RootEntries()
|
||||
DeviceRootEntries({
|
||||
activeSub: this.activeSub,
|
||||
deviceId: this.deviceId,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
loadingDevices: this.loadingDevices,
|
||||
deviceCount: this.devices.length,
|
||||
onOpen: (id: string) => {
|
||||
this.openSub(id);
|
||||
},
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
@ -248,61 +230,58 @@ export struct DevicePage {
|
||||
})
|
||||
}
|
||||
|
||||
/** 二级页面路由表 */
|
||||
/**
|
||||
* 二级页面路由表。
|
||||
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
|
||||
*/
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_LOCAL) {
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
DeviceLocalPane({
|
||||
deviceId: this.deviceId,
|
||||
authorized: this.authorized,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.LocalDeviceContent()
|
||||
}
|
||||
onToggleAuth: (on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
},
|
||||
})
|
||||
} else if (name === SUB_CAPS) {
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
DeviceCapsPane({
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.CapsContent()
|
||||
}
|
||||
})
|
||||
} else if (name === SUB_GATEWAY) {
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
DeviceGatewayPane({
|
||||
bridgeUrl: this.bridgeUrl,
|
||||
bridgeToken: this.bridgeToken,
|
||||
bridgeConnected: this.bridgeConnected,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.GatewayContent()
|
||||
}
|
||||
} else if (name === SUB_LIST) {
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
}) {
|
||||
this.OnlineDevicesContent()
|
||||
}
|
||||
})
|
||||
} else if (name === SUB_LIST) {
|
||||
DeviceListPane({
|
||||
devices: this.devices,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
@ -323,238 +302,6 @@ export struct DevicePage {
|
||||
.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_DEVICE_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_DEVICE_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 ? '已连接' : '未连接')
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
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('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 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;
|
||||
|
||||
@ -7,14 +7,15 @@ 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 { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
|
||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { ScreensuePage } from '../components/ScreensuePage';
|
||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { ScreensuePayload } from '../common/ScreensueHtml';
|
||||
import { window, display } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@ -83,6 +84,8 @@ struct Index {
|
||||
/** 底部手势条高度(vp) */
|
||||
@State bottomGesture: number = 16;
|
||||
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
||||
/** 外部请求切换主 Tab(未连接时聊天空态的“去设置连接”用) */
|
||||
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
|
||||
private swiper: SwiperController = new SwiperController();
|
||||
private screensueTimer: number = -1;
|
||||
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
||||
@ -101,6 +104,12 @@ struct Index {
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
|
||||
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
|
||||
// 所以此处读到的连接状态就是真实的启动态。
|
||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
||||
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
|
||||
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
|
||||
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
||||
const st = connStore.getSettings();
|
||||
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
||||
@ -191,6 +200,23 @@ struct Index {
|
||||
AppStorage.set<number>('currentTab', this.currentTab);
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
|
||||
*
|
||||
* 为什么不能直接改 AppStorage 的 currentTab:Index 的 currentTab 是
|
||||
* @State,Swiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
|
||||
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
|
||||
*/
|
||||
private onRequestedTab(): void {
|
||||
const t: number = this.requestedTab;
|
||||
AppStorage.set<number>(K_REQUESTED_TAB, -1);
|
||||
if (t < 0 || t >= this.tabs.length) {
|
||||
return;
|
||||
}
|
||||
this.currentTab = t;
|
||||
navBar.setVisible(true);
|
||||
}
|
||||
|
||||
private syncSystemBar(): void {
|
||||
const dark: boolean = this.isDark;
|
||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||
|
||||
@ -1,24 +1,26 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { userMessage } 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;
|
||||
}
|
||||
import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { PluginDetailPane } from '../components/PluginDetailPane';
|
||||
import { PluginListView } from '../components/PluginListView';
|
||||
import { PluginInstallForm } from '../components/PluginsOverlays';
|
||||
import { ToastBar } from '../components/ToastBar';
|
||||
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
|
||||
interface InstallBody {
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct PluginsPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@ -58,134 +60,14 @@ export struct PluginsPage {
|
||||
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,...}]}
|
||||
* 三方按名称合并去重排序。
|
||||
*/
|
||||
/** 取数口径见 common/PluginApi.ets;这里只负责把结果落到 @State。 */
|
||||
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);
|
||||
}
|
||||
const rows: PluginRow[] = await fetchPluginRows();
|
||||
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
@ -199,20 +81,6 @@ export struct PluginsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/** 徽标状态:与 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';
|
||||
@ -280,29 +148,14 @@ export struct PluginsPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']);
|
||||
const d: PluginDetail = await fetchPluginDetail(name);
|
||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||
this.detail = d;
|
||||
@ -327,21 +180,6 @@ export struct PluginsPage {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -387,8 +225,15 @@ export struct PluginsPage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.ListStates()
|
||||
this.PluginList()
|
||||
PluginListView({
|
||||
plugins: this.plugins,
|
||||
busy: this.loading,
|
||||
activeName: this.activeName,
|
||||
hasConn: apiClient.hasConnection(),
|
||||
onSelect: (name: string) => {
|
||||
this.openDetail(name);
|
||||
},
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
@ -411,7 +256,15 @@ export struct PluginsPage {
|
||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||
NavFloatOverlay({ tab: 1 }) {
|
||||
if (this.showInstallForm) {
|
||||
this.InstallForm()
|
||||
PluginInstallForm({
|
||||
url: this.installUrl,
|
||||
onUrlChange: (v: string) => {
|
||||
this.installUrl = v;
|
||||
},
|
||||
onInstall: () => {
|
||||
this.installPlugin();
|
||||
},
|
||||
})
|
||||
}
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
@ -441,7 +294,7 @@ export struct PluginsPage {
|
||||
}
|
||||
}
|
||||
|
||||
this.Toast()
|
||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@ -493,503 +346,29 @@ export struct PluginsPage {
|
||||
this.loadDetail(this.activeName);
|
||||
},
|
||||
}) {
|
||||
this.DetailContent()
|
||||
PluginDetailPane({
|
||||
detail: this.detail,
|
||||
activeName: this.activeName,
|
||||
busy: this.detailLoading,
|
||||
errorText: this.detailError,
|
||||
row: this.activeRow() ?? undefined,
|
||||
onToggle: () => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.togglePlugin(r);
|
||||
}
|
||||
},
|
||||
onRemove: () => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.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
@ -9,30 +9,56 @@ HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebV
|
||||
```
|
||||
HomeAgent/
|
||||
├── AppScope/ 应用级配置与图标
|
||||
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
|
||||
├── 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 错误转人类可读文案
|
||||
│ │ ├── common/ 通信、状态与纯逻辑(无 UI)
|
||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||
│ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
|
||||
│ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
|
||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||
│ │ │ ├── UserError.ets 错误转人类可读文案
|
||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
|
||||
│ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
|
||||
│ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译(ChatStreamSink 接口)
|
||||
│ │ │ ├── ChatSession.ets 发送/中断(POST /chat、/chat/file)
|
||||
│ │ │ ├── ChatHistory.ets 历史载荷与 tool_calls 解析
|
||||
│ │ │ ├── ChatFormat.ets ForEach 键、工具卡状态/配色、渠道判定
|
||||
│ │ │ ├── AttachmentMeta.ets 附件解析与格式化(纯函数)
|
||||
│ │ │ ├── AttachmentImage.ets 附件字节获取与解码(沙箱/远端)
|
||||
│ │ │ ├── DeviceBridge.ets 设备桥客户端(socket 生命周期与命令分发)
|
||||
│ │ │ ├── BridgeProtocol.ets 设备桥协议消息与帧构造
|
||||
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||
│ │ │ ├── DeviceBridgeSession.ets 前台桥生命周期、网关地址推导
|
||||
│ │ │ ├── DeviceModel.ets 设备页纯逻辑(device_id 兜底、在线设备解析)
|
||||
│ │ │ ├── PluginApi.ets 插件列表/详情接口
|
||||
│ │ │ ├── PluginStatus.ets 插件状态判定与配色
|
||||
│ │ │ ├── SettingsModel.ets 设置载荷解析、分类归并、分页、路由 id
|
||||
│ │ │ └── MarkdownParser.ets Markdown 解析(块/行内/表格)
|
||||
│ │ ├── components/ 可复用组件
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡片 + 详情
|
||||
│ │ │ ├── StatusCards.ets 状态卡片
|
||||
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||
│ │ │ ├── SubPage.ets 二级页容器
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
|
||||
│ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
|
||||
│ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
|
||||
│ │ │ ├── ChatToolCard.ets 思考过程卡 + 工具调用卡
|
||||
│ │ │ ├── ChatComposer.ets 悬浮输入区(选图/选文件/上传/发送)
|
||||
│ │ │ ├── ChatAttachBar.ets 加号菜单 + 待发送附件条
|
||||
│ │ │ ├── SettingsHome.ets / SettingsRootEntries.ets / SettingsEntryCard.ets 设置一级页
|
||||
│ │ │ ├── ConnectionsPane.ets / AppearancePane.ets / BackendSettingsPane.ets 设置二级页
|
||||
│ │ │ ├── PluginListView.ets / PluginDetailPane.ets / PluginsOverlays.ets 插件页
|
||||
│ │ │ ├── DeviceRootEntries.ets / DevicePanes.ets 设备页
|
||||
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||
│ │ │ ├── StatusCards.ets 状态卡片
|
||||
│ │ │ ├── ToastBar.ets 统一提示条(插件页与设置页共用)
|
||||
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||
│ │ │ ├── SubPage.ets 二级页容器 / NavGroup / NavRow / PlainCard
|
||||
│ │ │ ├── MotionBase.ets 统一按压反馈与入场动画
|
||||
│ │ │ └── GradientBackground.ets
|
||||
│ │ ├── model/Model.ets 共享类型定义
|
||||
│ │ ├── pages/ 页面
|
||||
│ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
|
||||
│ │ │ ├── Index.ets Tab 容器(入口)
|
||||
│ │ │ ├── ChatPage.ets 对话
|
||||
│ │ │ ├── DevicePage.ets 设备
|
||||
@ -45,11 +71,30 @@ HomeAgent/
|
||||
└── oh-package.json5 依赖
|
||||
```
|
||||
|
||||
约定:**单个 `.ets` 不超过 400 行**,页面只做页面壳(导航栈 + 数据编排),
|
||||
可复用结构进 `components/`,无 UI 的逻辑进 `common/`。
|
||||
|
||||
这条约定有一个边界,别用反了:
|
||||
|
||||
> **不到 400 行的文件不要为了拆分而拆分。** `@Component` 的 `build()` 只允许一个根节点,
|
||||
> 把原来多节点的 `@Builder` 改成组件时会多出一层 `Column` 包裹 —— 布局等价是**推理**出来的、
|
||||
> 不是看出来的,每拆一次都要付一次"未上机验证"的账。所以拆分只用来解决真实的可读性/维护性
|
||||
> 问题(超长文件、职责混杂),而不是凑行数。`pages/Index.ets` 目前 396 行就属于"不动"的一类:
|
||||
> 没越线,余量本身也是有用的缓冲;等它真越线了再拆,并且优先看是不是又长出了大 `@Builder`。
|
||||
|
||||
## 编译
|
||||
|
||||
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
|
||||
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`。
|
||||
|
||||
0. **前置条件:`oh_modules/` 必须存在**(`ohpm install` 的产物)。
|
||||
|
||||
它被 `.gitignore` 忽略,所以干净 clone 后没有;而 hvigor **不会**自动补齐它 ——
|
||||
实测把 `oh_modules/` 移走后构建不会触发 `ohpm install`,而是直接报一堆
|
||||
`arkts-no-untyped-obj-literals`(依赖类型声明缺失),且不会重建该目录。
|
||||
所以:clone 后先 `ohpm install`,之后别把这个目录当垃圾清掉。
|
||||
`entry/build/`、`.hvigor/` 是纯构建产物,可以随时删除(冷构建 ~8s)。
|
||||
|
||||
1. **准备签名配置**(`build-profile.json5` 含密码明文,未入库):
|
||||
|
||||
```bash
|
||||
@ -64,19 +109,27 @@ HomeAgent/
|
||||
2. **构建 HAP**:
|
||||
|
||||
```bash
|
||||
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
|
||||
/path/to/command-line-tools/bin/hvigorw \
|
||||
--mode module -p module=entry@default assembleHap --no-daemon
|
||||
cd cmd/ohos/HomeAgent
|
||||
# ⚠️ 不要用仓库里的 ./hvigorw:它是符号链接,启动脚本按 $(dirname $0) 定位,
|
||||
# 会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw。
|
||||
# 一律用 command-line-tools 里的绝对路径(本机为 /opt/huawei/command-line-tools/bin/hvigorw):
|
||||
/opt/huawei/command-line-tools/bin/hvigorw \
|
||||
assembleHap --mode module -p product=default --no-daemon
|
||||
```
|
||||
|
||||
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
||||
|
||||
3. **安装到设备**:
|
||||
3. **安装到设备**(`entry/build/` 是纯构建产物、不入库,所以**必须先跑完第 2 步**,
|
||||
否则下面这个路径不存在):
|
||||
|
||||
```bash
|
||||
hdc install entry/build/default/outputs/default/entry-default-signed.hap
|
||||
```
|
||||
|
||||
路径里的目录名随构建模式而变:默认是 `default/`,若用 `-p product=<名字>` 则是该产品名。
|
||||
拿不准就先 `find entry/build -name '*.hap'` 找一下。同目录还有 `entry-default-unsigned.hap`,
|
||||
`hdc install` 要用带 `-signed` 的那个。
|
||||
|
||||
## 连接 homed
|
||||
|
||||
首次启动在「设置」里填:
|
||||
@ -97,3 +150,19 @@ HomeAgent/
|
||||
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
||||
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
||||
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
||||
|
||||
## 改动后的运行时验证清单
|
||||
|
||||
构建通过只能证明编译期没问题;ArkUI 的状态绑定、过渡动画与手势行为
|
||||
必须上设备/模拟器点一遍。UI 相关改动(尤其拆分、状态搬家)请至少走完:
|
||||
|
||||
- [ ] 发一条消息,确认流式输出、滚动到底、"AI 思考中/工具调用"状态条正常
|
||||
- [ ] 点开思考过程卡与工具调用卡,确认能展开/收起且有过渡动画
|
||||
- [ ] 传一张图片与一个文件,确认预览条、上传进度、发送后附件卡正常
|
||||
- [ ] 进设置的四个二级页(状态/连接/外观/后端),确认进出场与保存生效
|
||||
- [ ] 进插件列表与插件详情,确认状态色、开关与卸载正常
|
||||
- [ ] 进出设备页四个二级页,确认授权开关与在线设备列表正常
|
||||
- [ ] 宽屏(>=600vp)下确认左右分栏、返回手势与返回键行为
|
||||
|
||||
模拟器在无图形/无提权环境里可能起不来(需要写 `~/.Huawei` 等宿主目录),
|
||||
此时请在真机或有权限的机器上补这轮验证,并在提交信息里注明"未做运行时验证"。
|
||||
|
||||
@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
|
||||
/conn use <name> switch to saved connection
|
||||
/conn del <name> delete saved connection
|
||||
|
||||
Server commands (sent to agent):
|
||||
Server commands (local 与 remote 行为一致):
|
||||
/status system status
|
||||
/kernel kernel status
|
||||
/settings [prefix] list settings
|
||||
@ -32,10 +32,27 @@ Server commands (sent to agent):
|
||||
/plugin list list installed plugins
|
||||
/plugin install <url> install plugin
|
||||
/plugin remove <name> remove plugin
|
||||
/plugin disable <name> disable plugin
|
||||
/plugin enable <name> enable plugin
|
||||
/plugin info <name> plugin details
|
||||
/memory query <text> query graph memory
|
||||
/knowledge list knowledge base
|
||||
/memory graph dump full graph memory snapshot
|
||||
/memory text [n] recent text-memory events + stats
|
||||
/memory context [q] assembled memory context (what gets injected)
|
||||
/memory tools memory tool definitions + tool prompt
|
||||
/knowledge list knowledge base (+stats)
|
||||
/knowledge delete <name> delete knowledge item
|
||||
/config dump kernel config (JSON)
|
||||
/tracker change-tracking stats
|
||||
/tracker rollback roll back this session's file changes
|
||||
/adapters list loaded Lua adapters
|
||||
/adapters remove <name> remove a Lua adapter
|
||||
/network network status + LLM endpoints
|
||||
/runtime scheduler / residents / channel topology
|
||||
/terminals list terminal sessions
|
||||
/cmd/history command execution history
|
||||
/terminal create|write|read|close … (local mode; calls agentcli tools)
|
||||
/persona show persona (/persona set default|custom|later [text])
|
||||
/agents list agents
|
||||
/chat <text> send to agent
|
||||
|
||||
@ -54,6 +71,26 @@ Any other text is sent to the agent directly.`)
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
// /stop 与 /interrupt:取消当前生成(可附带一句新指令)。
|
||||
// 之前 /help 里写着这条命令,但 handleBuiltin 根本没有对应 case,
|
||||
// 于是它像普通文本一样被发给了 Agent。
|
||||
// 本地交给 CLI 插件(内核优先级 L3),远端走 WebUI 的 chat/interrupt
|
||||
// (内核优先级 L4)。两条路都是“真中断”,不是发一句话。
|
||||
case cmd == "/stop" || cmd == "/interrupt" ||
|
||||
strings.HasPrefix(cmd, "/stop ") || strings.HasPrefix(cmd, "/interrupt "):
|
||||
msg := stopMessage(cmd)
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
body := fmt.Sprintf(`{"message":%q}`, msg)
|
||||
if _, err := rc.DoAPI("POST", "/api/v1/chat/interrupt", body); err != nil {
|
||||
fmt.Fprintf(out, "interrupt failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Fprintln(out, "interrupt sent")
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/connect "):
|
||||
cfg.Socket = strings.TrimSpace(cmd[9:])
|
||||
cfg.Remote = ""
|
||||
@ -143,7 +180,7 @@ Any other text is sent to the agent directly.`)
|
||||
rc.DoAPI("PUT", "/api/v1/settings", body)
|
||||
fmt.Fprintln(out, "ok")
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -152,7 +189,7 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -172,7 +209,7 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -182,7 +219,7 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -192,17 +229,56 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/memory query "):
|
||||
q := strings.TrimSpace(cmd[14:])
|
||||
// disable/enable:本地由 CLI 插件处理,远端走插件管理 REST 动作接口。
|
||||
case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "):
|
||||
verb := "disable"
|
||||
name := strings.TrimSpace(cmd[16:])
|
||||
if strings.HasPrefix(cmd, "/plugin enable ") {
|
||||
verb = "enable"
|
||||
name = strings.TrimSpace(cmd[15:])
|
||||
}
|
||||
if name == "" {
|
||||
fmt.Fprintln(out, "usage: /plugin disable|enable <name>")
|
||||
return true
|
||||
}
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/memory "):
|
||||
sub := strings.TrimSpace(cmd[8:])
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
switch {
|
||||
case strings.HasPrefix(sub, "query "):
|
||||
q := strings.TrimSpace(strings.TrimPrefix(sub, "query "))
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||
printJSON(out, d)
|
||||
case sub == "graph":
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/graph", "")
|
||||
printJSON(out, d)
|
||||
case sub == "text" || strings.HasPrefix(sub, "text "):
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/text", "")
|
||||
printJSON(out, d)
|
||||
case sub == "context" || strings.HasPrefix(sub, "context "):
|
||||
q := strings.TrimSpace(strings.TrimPrefix(sub, "context"))
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/context?q="+q, "")
|
||||
printJSON(out, d)
|
||||
case sub == "tools":
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/tools", "")
|
||||
printJSON(out, d)
|
||||
default:
|
||||
fmt.Fprintln(out, "usage: /memory query <text> | /memory graph | /memory text [n] | /memory context [q] | /memory tools")
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -212,16 +288,126 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/knowledge":
|
||||
case cmd == "/knowledge" || cmd == "/knowledge list" || cmd == "/knowledge stats":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/knowledge")
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/config":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/config", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/config")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/tracker":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/tracker", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/tracker")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/tracker rollback":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/tracker/rollback", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/tracker rollback")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/adapters" || strings.HasPrefix(cmd, "/adapters remove "):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
if strings.HasPrefix(cmd, "/adapters remove ") {
|
||||
name := strings.TrimSpace(cmd[17:])
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/adapters/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/adapters", "")
|
||||
printJSON(out, d)
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/persona" || strings.HasPrefix(cmd, "/persona set "):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
if strings.HasPrefix(cmd, "/persona set ") {
|
||||
rest := strings.TrimSpace(cmd[13:])
|
||||
mode := rest
|
||||
content := ""
|
||||
if idx := strings.IndexByte(rest, ' '); idx > 0 {
|
||||
mode = rest[:idx]
|
||||
content = strings.TrimSpace(rest[idx+1:])
|
||||
}
|
||||
body := fmt.Sprintf(`{"mode":%q,"content":%q}`, mode, content)
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/persona", body)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/persona", "")
|
||||
printJSON(out, d)
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/terminals":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/terminals", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/terminals")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/cmd/history":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/cmd/history", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/cmd/history")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/terminal" || strings.HasPrefix(cmd, "/terminal "):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
// 远端 WebUI 没有“开终端”的 REST 端点(终端由 agentcli 工具创建),
|
||||
// 不静默当聊天发出去,直接说明。
|
||||
fmt.Fprintln(out, "remote 模式暂不支持终端操作;请在 local 模式或让 agent 调 terminal_* 工具")
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/runtime":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/runtime", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/runtime")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/network":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/network", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/network")
|
||||
}
|
||||
return true
|
||||
|
||||
@ -239,6 +425,16 @@ Any other text is sent to the agent directly.`)
|
||||
}
|
||||
}
|
||||
|
||||
// stopMessage 从 /stop 或 /interrupt 行里取出可选的中断附带消息(空串=纯取消)。
|
||||
func stopMessage(cmd string) string {
|
||||
for _, prefix := range []string{"/interrupt", "/stop"} {
|
||||
if strings.HasPrefix(cmd, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(cmd, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func printJSON(out io.Writer, d map[string]interface{}) {
|
||||
if d == nil {
|
||||
fmt.Fprintln(out, "(no data)")
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
)
|
||||
|
||||
// ===== 设备桥管理 =====
|
||||
@ -46,6 +47,9 @@ func startDeviceBridge(addr, token string) error {
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"cpus": runtime.NumCPU(),
|
||||
// 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本
|
||||
// 因此与 homed 一致,不再是一个空缺字段。
|
||||
"version": meta.Version,
|
||||
}
|
||||
|
||||
// 确保 gateway URL 格式正确
|
||||
|
||||
@ -11,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -129,8 +131,15 @@ func main() {
|
||||
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", "", "测试能力的参数")
|
||||
showVersion := flag.Bool("version", false, "打印版本并退出")
|
||||
flag.Parse()
|
||||
|
||||
// 版本号直接来自 internal/meta(与 homed 同一事实源,不可能各写一个)。
|
||||
if *showVersion {
|
||||
fmt.Printf("waiter %s (commit %s, built %s)\n", meta.Version, meta.Commit, meta.BuildTime)
|
||||
return
|
||||
}
|
||||
|
||||
// 本地能力测试模式(无需连接服务器)
|
||||
if *testCap != "" {
|
||||
runCapTest(*testCap, *testCapArgs)
|
||||
@ -270,9 +279,9 @@ func runLineMode(state *State, cfg *Config, history *History) {
|
||||
addrLabel = cfg.Remote
|
||||
}
|
||||
if colors {
|
||||
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
|
||||
fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset)
|
||||
} else {
|
||||
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
|
||||
fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel)
|
||||
}
|
||||
fmt.Println("Type /help for commands.")
|
||||
|
||||
|
||||
364
demo.md
364
demo.md
@ -1,364 +0,0 @@
|
||||
# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md)
|
||||
|
||||
> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程,
|
||||
> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。
|
||||
|
||||
---
|
||||
|
||||
## 0. 背景与目标
|
||||
|
||||
框架目标:**内核零 IO、插件承载所有 IO**(`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。
|
||||
|
||||
**Failback 的定位:所有错误的一层兜底(Safe-Mode 式),而非针对单一场景。**
|
||||
|
||||
- 主 agent(全量 LLM agent)是一切骚操作的执行者,可能把自己搞到无法自愈的任意状态:
|
||||
LLM 源改坏 / 系统网络(proxy、DNS、host)破坏 / 配置文件损坏 / OOM / panic / 崩溃循环……
|
||||
这些错误无法在**同一个被污染环境内**用自身操作自救。
|
||||
- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**——
|
||||
类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP),
|
||||
单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。**
|
||||
- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状盘点(代码勘查结论)
|
||||
|
||||
### 1.1 守护进程(`internal/supervisor/daemon.go`)
|
||||
|
||||
- 主 agent in-process 常驻,`agent.Start()` 在 `cmd/homed/main.go:468` 直接启动,**无独立进程边界**。
|
||||
- `healthLoop` → `checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor` 的 `AggregateResult().LLMAPIReachable`**(`daemon.go:132`)。
|
||||
- `failCount >= MaxRetries`(默认 3)→ `handleFailure`:
|
||||
- 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`。
|
||||
- `restartAgent`(`daemon.go:166`)**只改内存状态再重新 Register,不真重启任何进程**,几乎空转。
|
||||
|
||||
关键问题:
|
||||
- 探测是系统级(HTTP/DNS/TCP),回滚只作用于 `<data>/agentfs` overlay 的 upper,**二者对象错位**。
|
||||
- `AgregateResult` 在无 LLM endpoint 时恒 healthy,机制形同虚设。
|
||||
- `lastHB` 每轮都置 `time.Now()`,`Uptime` 无意义。
|
||||
- `RollbackPolicy` 的 `HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`。
|
||||
|
||||
### 1.2 文件追踪(`internal/tracker`)
|
||||
|
||||
- overlayfs 三层:`lower/upper/work → merged`(`tracker.go:156`)。
|
||||
- `captureFSState(upperDir)` 递归遍历 upper 并 sha256(`changeset.go:56`);`PreAction/PostAction` 前后 diff(`toolcall.go:86-94`)。
|
||||
- **lower 恒空**(`Init` 只 `MkdirAll`,从不填充)→ 无 canonical 基线可回滚。
|
||||
- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets;`FileChange.Content`(本应存回滚原文)**从未回填**。
|
||||
|
||||
结论:overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。
|
||||
|
||||
### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`)
|
||||
|
||||
- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**:
|
||||
`qq.napcat_url`、`qq.listen`、`qq.files_dir`、`qq.remote_dir`、`dm/group_policy`(`plugin.go:120-130`)。
|
||||
- 插件在 `Start()` 里 `getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**。
|
||||
- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。
|
||||
|
||||
### 1.4 LLM 源与"恢复即生效"
|
||||
|
||||
`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**:
|
||||
- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`)
|
||||
- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。
|
||||
|
||||
即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现实环境:日志记录模式内参
|
||||
|
||||
> 看真实 systemd 托管的 HomeAgent(`/home/newqqagent`)日志,**目的是弄清现有的日志模型**
|
||||
> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。
|
||||
|
||||
### 2.1 systemd 托管现状(样例)
|
||||
|
||||
```
|
||||
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
|
||||
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
|
||||
```
|
||||
|
||||
- 实测数据区:`/home/newqqagent/` 下有 `log/`、`config.db`、`agentfs/`(overlay merged)、`snapshots/`、`changesets/`、`knowledge/`、`memory/`、`plugins/`、
|
||||
`cli.sock`、`adapters/`、`homed.log`、`memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。
|
||||
- 启动段确认:`[files] started, sandbox: /`(**files 沙箱=全主机 `/` 实锤**);`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`(LLM 走本地 mock)。
|
||||
|
||||
### 2.2 日志目录与格式(核心)
|
||||
|
||||
- **目录配置**:`core.log.path`,默认 `<dataDir>/log`(`internal/config/registry.go:415,508`)。
|
||||
- **单次运行文件**:每次启动新建 `homed_<YYYY-MM-DD_HH-MM-SS>.log`(`cmd/homed/main.go:75`),
|
||||
写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))`(`main.go:80`)——
|
||||
**同时进 stderr(systemd 捕获到 journald/`journalctl -u`)与文件**。
|
||||
- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`。
|
||||
用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。
|
||||
- **层级压缩 + 保留**(`internal/log/manager.go:26-28` + `compressor.go`):
|
||||
- 周度压缩 → `week_<year>-W<ww>.tar.gz`;月度 → `month_<yyyy-mm>.tar.gz`;年度 `year_*.tar.gz`;
|
||||
raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$`(`compressor.go:16`)。
|
||||
- 保留策略:`core.log.retention`(default forever)、`core.log.retention_months`(default 3),
|
||||
`applyRetention` 只留当前周 + 近 N 月(`retention.go`)。
|
||||
实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。
|
||||
|
||||
### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`)
|
||||
|
||||
| 位置 | 日志行内容 | 备注 |
|
||||
|---|---|---|
|
||||
| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 |
|
||||
| `process.go:196` | `[agent] executing tool: <name> (plugin=<p>, id=<id>)` | **只记工具名/插件/id,不记 args** |
|
||||
| `process.go:226` | `[agent] tool <name> result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`)|
|
||||
| `process.go:218` | `[agent] skip tool <name>: plugin <name> unhealthy` | 插件崩溃态跳过 |
|
||||
| `process.go:89/109/121/125` | LLM fallback:`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 |
|
||||
| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 |
|
||||
| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 |
|
||||
| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 |
|
||||
| 插件侧 | Lua 插件 `sdk.log` → `print("[lua-plugin] <level>: <msg>")` | 模板见 `cmd_debug.go:74`/`templates.go` |
|
||||
|
||||
- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall`(`{tool, plugin, args, result, status}`,`process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。
|
||||
- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里),
|
||||
需要的话由 `diag_log_scan` 对 `executing tool: cmd_*` 前缀做签名匹配即可。
|
||||
|
||||
### 2.4 崩溃 / 重启观察
|
||||
|
||||
- `NRestarts=0`;MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**(pid 3310036):
|
||||
`[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`(03:23 / 05:23 / 07:23,约每 2h),
|
||||
被 `stages.go:139` 的 `RunStage` recover 吞掉 → **进程未真崩**,systemd 未见重启。
|
||||
- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。
|
||||
- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督;
|
||||
且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环,
|
||||
systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计演进(讨论全过程)
|
||||
|
||||
### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪"
|
||||
|
||||
- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。
|
||||
- 意图:LLM 不可达 → 回滚 agent 文件改动 → 自愈。
|
||||
|
||||
### 3.1 第一次纠正:回滚对象错位
|
||||
|
||||
- 回滚只作用于 overlayfs upper;而真正能改坏网络的路径(files 默认 `/`,可写 `/etc/resolv.conf`、`/etc/hosts`、代理配置)与 LLM 配置(config.db、adapters 目录)**都不在 overlay 内**。
|
||||
- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。
|
||||
|
||||
### 3.2 第二次纠正:授权不能靠外部插件主动配合
|
||||
|
||||
- 阶段管道是被动通知(`RunStage` 收集 error,不主动拒绝),拒绝依赖各插件 handler。
|
||||
- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。
|
||||
- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。
|
||||
- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置;IO 组件崩溃是正交偶发轴,不并进来。
|
||||
|
||||
### 3.3 第三次纠正:LLM 源才是核心(config.db + ReloadFromConfig 已具钩子)
|
||||
|
||||
- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`。
|
||||
- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测(QuickChat)+ 在 handleFailure 里触发 RFO 复检**。
|
||||
- 但用户进一步点明:**重点是 agent 改了系统网络配置文件(proxy/DNS)** —— 这类连救援 LLM 都连不上。
|
||||
|
||||
### 3.4 定稿架构一:lastFailback(独立进程,第一道防线)
|
||||
|
||||
- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd)+ 单一任务。
|
||||
- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。
|
||||
- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。
|
||||
- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。
|
||||
|
||||
### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程
|
||||
|
||||
- systemd 只绑定**静态、极小、永驻的 guard**;guard 作为父进程拉起 worker(`homed` 全量 agent)。
|
||||
- guard 通过 IPC(unix socket / 匿名 pipe)收 worker 心跳;
|
||||
父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。
|
||||
- worker 可崩可换,systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**。
|
||||
- guard 抗 OOM:`oom_score_adj` 拉低,优先杀 worker 而非 guard。
|
||||
|
||||
### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role,非独立二进制
|
||||
|
||||
- **不新建二进制**。`homed` 拆两个入口:
|
||||
- `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。
|
||||
- `homed --role=agent`:主 agent(工作进程,guard 的子进程)。
|
||||
- `homed --role=agent --boot=failback`:恢复用 agent(受限 bootstrap)。
|
||||
- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent,只加载
|
||||
**webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件** 与 **常见错误检测插件**,
|
||||
复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。
|
||||
- guard 配置**独立 YAML**,不复用 config.db(逃生舱知识必须脱离 agent 可达区)。
|
||||
|
||||
### 3.7 recoveryDiag:崩溃取证 / 根因定位插件(省 token 关键)
|
||||
|
||||
- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。
|
||||
- 工具集:
|
||||
| 工具 | 作用 |
|
||||
|---|---|
|
||||
| `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" |
|
||||
| `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail |
|
||||
| `diag_log_scan` | 时间窗内命中已知错误签名(panic/provider failed/unreachable/sql/OOM)|
|
||||
| `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff("改了什么")|
|
||||
| `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 |
|
||||
|
||||
- 崩溃类别 → 恢复分支决策表:
|
||||
| 结论类 | 走分支 |
|
||||
|---|---|
|
||||
| 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent(无需 agent 推理)|
|
||||
| 系统网络类 | 还原 DNS/proxy → 重载主 agent(第一步小修命中即停)|
|
||||
| 进程失稳类(OOM/panic)| 不还原配置,检查内存/泄漏 → 重建 worker |
|
||||
| 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 |
|
||||
|
||||
- 只有"未知/混合"消耗 token,前几类近乎 0 token。
|
||||
- 结论落盘 `recovery_kb/diag_<ts>.json`,回流知识库,同类崩溃下次直接命中,越用越省。
|
||||
|
||||
### 3.8 三层防御总览(最终)
|
||||
|
||||
```
|
||||
L0 平时:核心只读探活 + 写前快照(config_set 写 core.llm.* 前、files 写 /etc 前自动留档)
|
||||
L1 failback(guard 拉起,Safe-Mode 式兜底):按诊断分支逐类恢复——
|
||||
还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent
|
||||
L2 最后手段:agentfs 回滚最近快照 + 重启(应用快照前系统配置)
|
||||
```
|
||||
|
||||
- failback 是**所有错误(LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**,
|
||||
并非只针对某一条;`diag_*` 决定它走哪条恢复路径。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终架构(定稿)
|
||||
|
||||
### 4.1 进程拓扑(同一二进制,两个 role)
|
||||
|
||||
```
|
||||
systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小
|
||||
├─ exec ──▶ homed --role=agent # 主 agent(可崩)
|
||||
└─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent
|
||||
```
|
||||
|
||||
- guard:先起,持有恢复知识(锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。
|
||||
- worker:guard 子进程,心跳经 IPC,崩溃由 guard reap + 判型。
|
||||
- failback agent = 受限启动(webfetch+files+cmd + 恢复知识库 + recoveryDiag),单一任务"恢复主 agent",N 轮有界。
|
||||
|
||||
### 4.2 guard 独立 YAML 示例
|
||||
|
||||
> 现状实现(§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。
|
||||
|
||||
```yaml
|
||||
role: guard
|
||||
|
||||
llm:
|
||||
sources:
|
||||
- name: rescue
|
||||
base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理
|
||||
api_key: ${GUARD_RESCUE_KEY}
|
||||
adapter: ... # 锚定/SNI 型适配器
|
||||
|
||||
recovery:
|
||||
max_attempts: 4 # 可配置尝试轮次
|
||||
attempt_timeout: 120s
|
||||
knowledge_base: /opt/homeagent/recovery/
|
||||
trigger_prompt: "你是恢复 agent,唯一任务:让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…"
|
||||
plugins: [webfetch, files, cmd]
|
||||
|
||||
last_resort:
|
||||
action: reboot # restart_app | reboot
|
||||
snapshot_before: true
|
||||
```
|
||||
|
||||
### 4.3 guard 恢复状态机(N 轮有界)
|
||||
|
||||
```
|
||||
guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 )
|
||||
1. 固化追溯:exit/信号、panic、journal、OOM 上下文 → 永久区
|
||||
2. 拉起 failback agent(受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag)
|
||||
for attempt in 1..N:
|
||||
(可选先 diag_triage/diag_loc 判型)
|
||||
failback 尝试恢复
|
||||
guard 每轮复检主 agent 是否可达/存活
|
||||
├─ 成功 → 结束,交回主 agent
|
||||
└─ 超时/失败 → kill 重建,进入下一轮
|
||||
3. N 轮未成 → 取消 failback agent
|
||||
→ agentfs 回滚崩溃前最近快照
|
||||
→ 依 yaml 执行最后手段:restart_app 或 reboot
|
||||
```
|
||||
|
||||
### 4.4 systemd 绑定(极简,杜绝风暴)
|
||||
|
||||
```
|
||||
[Unit] # guard
|
||||
OnFailure=... # 备用,通常不触发(guard 稳定)
|
||||
|
||||
[Service] # guard
|
||||
Restart=always # guard 静态稳定 → 几乎不重启
|
||||
ExecStart=/usr/local/bin/homed --role=guard ...
|
||||
# No StartLimit needed for loop 情况;guard 不崩
|
||||
```
|
||||
|
||||
- 主 agent 崩 → 只触发 guard 内部 failback;systemd 仅看 guard,看不到 worker 崩溃循环。
|
||||
- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。
|
||||
- guard 抗 OOM:`oom_score_adj` 拉低。
|
||||
|
||||
---
|
||||
|
||||
## 5. 尚未落地的接口 / 下一步
|
||||
|
||||
**已完成**:
|
||||
|
||||
`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。
|
||||
- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`(config.db integrity_check + LLM 源字段校验,sqlite3 CLI 优先、缺失回退内核 Settings)、`diag_log_scan`(日志签名按类计数)、`diag_delta`(baseline vs 现状 diff)、`diag_loc`(四项结论正交排序 + 推荐恢复动作)。
|
||||
- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`。
|
||||
- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通:3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal),并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。
|
||||
- **结论落盘 + 知识库回流**:`diag_loc` 增 `persist`(缺省 true)→ 写 `<data_dir>/recovery_kb/diag_<ts>.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add` 以 `diag:<cause>:<ts>` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`。
|
||||
- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev`,`go build -o ... .`。
|
||||
- 注意:本环境 `snapshots/`、`changesets/` 均为空(direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir;未来接 guard 时由快照解包目录提供。
|
||||
|
||||
`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。
|
||||
- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。
|
||||
- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件(guard 恢复的外部基线);homed 启动即挂 `<data>/llm_snapshot.json`。
|
||||
- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM`、`TestLLMSnapshotFile`、`TestSetLLMSnapshotFile`。
|
||||
|
||||
`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。
|
||||
- `--role=guard` 父守护(永驻):读独立 `<data>/guard.yaml`(避开被改坏的 config.db),拉起 worker(`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。
|
||||
- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。
|
||||
- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`(restart_app / reboot)。
|
||||
- 心跳:worker 每 5s 触碰 `<data>/heartbeat`(agent 角色 goroutine),guard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。
|
||||
- 插件注册表加 `SetLoadAllowlist(names)`:白名单外插件(含已注册工厂)一律跳过,failback 40 工具 → 4 工具实测通过。
|
||||
|
||||
现状 bug 修复(supervisor/network/tracker)。
|
||||
|
||||
- **monitor 无 endpoint 恒 healthy**(`internal/network/monitor.go` + `pkg/types`):`NetworkCheckResult` 增 `EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error),`NewMonitor` 初始化空切片消除启动竞态;daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url),生产从此健康检查有真实目标。
|
||||
- **lastHB 恒置 now**(`internal/supervisor/daemon.go`):`checkAgent` 接入真实存活源 `SetHeartbeatSource`(homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。
|
||||
- **restartAgent 只改内存空转**:增 `SetRestartHandler`(homed 注册为"清理后以 `exitRestartRequested=42` 退出"),不再假装成功;guard 把 42 识别为"请求重建"(`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。
|
||||
- **tracker 三缺陷**(`internal/tracker/`):`captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB)→ `diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset;`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。
|
||||
|
||||
剩余:
|
||||
|
||||
- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。
|
||||
- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。
|
||||
- 生产实例迁移:编译新 homed、改 systemd 只托管 guard(`--role=guard`),确认 failback 插件(recoverydiag)就位。
|
||||
|
||||
---
|
||||
|
||||
## 附录:真实日志节选(systemd 托管示例,`/home/newqqagent`)
|
||||
|
||||
```
|
||||
# systemd unit
|
||||
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
|
||||
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
|
||||
|
||||
# 日志文件与格式(log.Printf 标准格式)
|
||||
2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log
|
||||
2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully
|
||||
2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs)
|
||||
|
||||
# 工具调用摘要(process.go)
|
||||
2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589
|
||||
... process.go:196: [agent] executing tool: <name> (plugin=<p>, id=<id>)
|
||||
... process.go:226: [agent] tool <name> result: <截断100字符>
|
||||
|
||||
# 启动 & 沙箱
|
||||
homed[3330844]: [files] started, sandbox: /
|
||||
homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8
|
||||
|
||||
# 重复 in-process panic(被 RunStage recover 吞掉,未进程级崩溃)
|
||||
homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23
|
||||
|
||||
# 一次性干净重启(systemd 手动/触发 Started,pid 3310036 → 3330844)
|
||||
homed[3310036]: [homed] stopped
|
||||
systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler.
|
||||
systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler.
|
||||
|
||||
# 运行状态
|
||||
systemctl show homeagent.service -p NRestarts → 0
|
||||
systemctl show homeagent.service -p MainPID → 3330844(自 08-03 起稳定)
|
||||
|
||||
# 归档
|
||||
/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz
|
||||
```
|
||||
94
deploy/packaging/package-windows.sh
Normal file
94
deploy/packaging/package-windows.sh
Normal file
@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建 Windows 安装器(NSIS)。
|
||||
#
|
||||
# ❗安装器**不往 Windows 装 homed**:homed 依赖 fd 继承与统一共享内存区的段内偏移
|
||||
# 解引用,Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go)。所以安装器的
|
||||
# 职责是**引导 WSL2,并把 Linux 包送进发行版里按 Linux 方式安装**
|
||||
# (deploy/packaging/windows/install-via-wsl.ps1)。
|
||||
#
|
||||
# 用法: VERSION=1.3.10 bash deploy/packaging/package-windows.sh <server|client|full> [arch]
|
||||
# 前置: 先产出对应的 Linux 包(VERSION=x bash deploy/packaging/package-linux.sh amd64)
|
||||
#
|
||||
# 为什么不复用 build.sh 的 stage_linux_payload:那一段把 dist/linux 下**所有** deb+tar
|
||||
# 都塞进 payload,而 server/full 的 deb 各带 ~719MB 的 Chinese-CLIP 模型 ⇒ 任何变体的
|
||||
# 安装器都会膨胀到 ~2.4GB。WSL 侧脚本只取 payload 里的**第一个** .deb
|
||||
# (install-via-wsl.ps1:141),所以这里按变体只放对应的那一个包。
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
# 三个目录都可覆盖:发布件常在 tag 的干净 worktree 里构建,而这个脚本本身
|
||||
# 可能只存在于 main(例如刚补的驱动脚本还没进 tag)——那种情况下用主仓的脚本 +
|
||||
# DIST_LINUX/BUILD_DIR/DIST_RELEASE 指向 worktree,避免"脚本不存在"或产物错位。
|
||||
BUILD_DIR="${BUILD_DIR:-$PROJECT_ROOT/build}"
|
||||
DIST_LINUX="${DIST_LINUX:-$PROJECT_ROOT/dist/linux}"
|
||||
DIST_RELEASE="${DIST_RELEASE:-$PROJECT_ROOT/dist/release}"
|
||||
# ❗NSIS 的 `File` 路径是**相对 .nsi 所在目录**解析的:在 tag 的 worktree 里构建时,
|
||||
# 必须用**该 tag 里的** installer.nsi,否则它会去主仓的 build/linux-payload 找载荷
|
||||
# (实测报 `File: "..\..\build\linux-payload\*.*" -> no files found`)。
|
||||
# 用 tag 里的 .nsi 也正是"发布件与当时的脚本同源"的正确做法。
|
||||
NSI="${NSI:-$PROJECT_ROOT/deploy/packaging/installer.nsi}"
|
||||
if [ ! -f "$NSI" ]; then
|
||||
echo "[FAIL] 找不到 NSIS 脚本: $NSI" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VARIANT="${1:-server}"
|
||||
ARCH="${2:-amd64}"
|
||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags 2>/dev/null || echo 0.0.0)}"
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
case "$VARIANT" in
|
||||
server) DEB_GLOB="homeagent-server_${VERSION}_${ARCH}.deb"; SUFFIX="Server"; WANT_GUI=0; WANT_WAITER=0 ;;
|
||||
full) DEB_GLOB="homeagent-full_${VERSION}_${ARCH}.deb"; SUFFIX="Full"; WANT_GUI=1; WANT_WAITER=1 ;;
|
||||
client) DEB_GLOB="homeagent-client_${VERSION}_${ARCH}.deb"; SUFFIX="Client"; WANT_GUI=1; WANT_WAITER=1 ;;
|
||||
*) echo "用法: $0 <server|client|full> [arch]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
DEB="$(ls -1 "$DIST_LINUX/deb/$DEB_GLOB" "$DIST_LINUX/$DEB_GLOB" 2>/dev/null | head -1 || true)"
|
||||
if [ -z "$DEB" ]; then
|
||||
echo "[FAIL] 找不到 $DEB_GLOB" >&2
|
||||
echo " 先产出 Linux 包:VERSION=$VERSION bash deploy/packaging/package-linux.sh $ARCH" >&2
|
||||
echo " (安装器的作用是把 Linux 包送进 WSL2,所以必须先有 Linux 包)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# client/full 还要带 Windows GUI(HAS_GUI=1)。本机缺 electron-builder,若 build/ 下
|
||||
# 没有可用的 win32-x64 payload 就**明确失败**,不产出"装完没有界面"的半残包。
|
||||
if [ "$WANT_GUI" = 1 ]; then
|
||||
if [ -z "$(ls -1 "$BUILD_DIR"/homeagent-gui-win32-x64/*.exe 2>/dev/null | head -1 || true)" ]; then
|
||||
echo "[FAIL] 变体 $VARIANT 需要 Windows GUI payload(build/homeagent-gui-win32-x64/*.exe)" >&2
|
||||
echo " 本机无 electron-builder:npm i -g electron-builder &&" >&2
|
||||
echo " bash deploy/packaging/build.sh windows/amd64 gui" >&2
|
||||
echo " (只装内核+CLI 的 WSL 场景请用 server 变体)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$WANT_WAITER" = 1 ]; then
|
||||
echo "[BUILD] waiter.exe(Windows 侧 CLI;CGO 关闭,跨平台安全)"
|
||||
( cd "$PROJECT_ROOT" && GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \
|
||||
go build -buildvcs=false -trimpath -o "$BUILD_DIR/waiter.exe" ./cmd/waiter )
|
||||
fi
|
||||
|
||||
# ---- 变体定向 payload:只放本变体那一个 Linux 包 ----
|
||||
rm -rf "$BUILD_DIR/linux-payload"
|
||||
mkdir -p "$BUILD_DIR/linux-payload"
|
||||
cp "$DEB" "$BUILD_DIR/linux-payload/"
|
||||
echo "[STAGE] payload ← $(basename "$DEB")($(du -h "$DEB" | cut -f1))"
|
||||
|
||||
if [ -z "$(ls -1 "$BUILD_DIR/linux-payload" 2>/dev/null | head -1 || true)" ]; then
|
||||
echo "[FAIL] payload 为空:$BUILD_DIR/linux-payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[BUILD] makensis -DVARIANT=$VARIANT -DPRODUCT_VERSION=$VERSION(nsi: $NSI)"
|
||||
makensis -V2 -DVARIANT="$VARIANT" -DPRODUCT_VERSION="$VERSION" "$NSI"
|
||||
|
||||
OUT="$BUILD_DIR/HomeAgent_v${VERSION}_${SUFFIX}_win64.exe"
|
||||
if [ ! -f "$OUT" ]; then
|
||||
echo "[FAIL] 未找到产物 $OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$DIST_RELEASE"
|
||||
cp "$OUT" "$DIST_RELEASE/"
|
||||
echo "[OK] $(basename "$OUT")($(du -h "$OUT" | cut -f1))→ $DIST_RELEASE/"
|
||||
echo " 它会在 Windows 侧引导 WSL2,并把 $(basename "$DEB") 送进去安装(homed 跑在 WSL 里)。"
|
||||
104
deploy/scripts/sync-client-versions.sh
Executable file
104
deploy/scripts/sync-client-versions.sh
Executable file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 客户端版本与内核版本同步。
|
||||
#
|
||||
# 为什么要有这个脚本:内核的 internal/meta/meta.go Version 是唯一事实源,
|
||||
# 而各客户端各有各的版本字段——GUI 在 package.json、鸿蒙在 AppScope/app.json5、
|
||||
# waiter 走编译期注入。手工各改各的必然漂移(写这个脚本时的现状:内核 1.4.0、
|
||||
# GUI 1.0.0、鸿蒙 1.1.1,三个号互不相干)。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/scripts/sync-client-versions.sh # 同步到内核当前版本
|
||||
# bash deploy/scripts/sync-client-versions.sh 1.4.0 # 同步到指定版本
|
||||
# bash deploy/scripts/sync-client-versions.sh --check # 只校验,漂移则退出 1
|
||||
#
|
||||
# 同步目标:
|
||||
# cmd/gui/package.json version
|
||||
# cmd/ohos/HomeAgent/AppScope/app.json5 versionName + versionCode
|
||||
#
|
||||
# waiter 不在此列:它直接引用 internal/meta.Version(同一进程内编译),
|
||||
# 没有第二份版本字段可漂。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
CHECK=0
|
||||
VERSION=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check) CHECK=1 ;;
|
||||
*) VERSION="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 未显式给版本时,从内核唯一事实源读。
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(grep -oE 'Version = "[^"]+"' "$ROOT/internal/meta/meta.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
fi
|
||||
[ -n "$VERSION" ] || { echo "sync-client-versions: 无法确定版本号(internal/meta/meta.go 里没找到 Version)" >&2; exit 1; }
|
||||
|
||||
# versionCode 规则:X*1e6 + Y*1e3 + Z。鸿蒙要求 versionCode 单调递增的整数,
|
||||
# 直接搬 semver 会丢信息,所以用主/次/补丁三段编码(1.4.0 → 1004000)。
|
||||
CODE="$(python3 - "$VERSION" <<'PY'
|
||||
import re, sys
|
||||
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', sys.argv[1])
|
||||
if not m:
|
||||
sys.exit("sync-client-versions: 版本号必须是 X.Y.Z 形态,得到 %r" % sys.argv[1])
|
||||
print(int(m.group(1)) * 1000000 + int(m.group(2)) * 1000 + int(m.group(3)))
|
||||
PY
|
||||
)"
|
||||
|
||||
GUI_PKG="$ROOT/cmd/gui/package.json"
|
||||
OHOS_APP="$ROOT/cmd/ohos/HomeAgent/AppScope/app.json5"
|
||||
|
||||
DRIFT=0
|
||||
note() { printf ' %-52s %s\n' "$1" "$2"; }
|
||||
|
||||
# ── GUI ──
|
||||
gui_cur="$(python3 - "$GUI_PKG" <<'PY'
|
||||
import json, sys
|
||||
print(json.load(open(sys.argv[1]))["version"])
|
||||
PY
|
||||
)"
|
||||
if [ "$gui_cur" != "$VERSION" ]; then
|
||||
DRIFT=1
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
note "cmd/gui/package.json" "$gui_cur → 应为 $VERSION"
|
||||
else
|
||||
python3 - "$GUI_PKG" "$VERSION" <<'PY'
|
||||
import json, sys
|
||||
p, v = sys.argv[1], sys.argv[2]
|
||||
d = json.load(open(p))
|
||||
d["version"] = v
|
||||
# indent=2 保留原格式;末尾补换行,避免 diff 噪声
|
||||
with open(p, "w") as f:
|
||||
json.dump(d, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
PY
|
||||
note "cmd/gui/package.json" "$gui_cur → $VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 鸿蒙 ──
|
||||
ohos_name="$(grep -oE '"versionName"[[:space:]]*:[[:space:]]*"[^"]+"' "$OHOS_APP" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
|
||||
ohos_code="$(grep -oE '"versionCode"[[:space:]]*:[[:space:]]*[0-9]+' "$OHOS_APP" | head -1 | grep -oE '[0-9]+$')"
|
||||
if [ "$ohos_name" != "$VERSION" ] || [ "$ohos_code" != "$CODE" ]; then
|
||||
DRIFT=1
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → 应为 $VERSION/$CODE"
|
||||
else
|
||||
# app.json5 带注释,不是严格 JSON,用 sed 定点替换两个字段。
|
||||
sed -i -E "s/(\"versionCode\"[[:space:]]*:[[:space:]]*)[0-9]+/\1$CODE/" "$OHOS_APP"
|
||||
sed -i -E "s/(\"versionName\"[[:space:]]*:[[:space:]]*\")[^\"]+/\1$VERSION/" "$OHOS_APP"
|
||||
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → $VERSION/$CODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "内核版本: $VERSION (versionCode $CODE)"
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
if [ "$DRIFT" -eq 1 ]; then
|
||||
echo "sync-client-versions: 客户端版本与内核不一致(见上);跑 bash deploy/scripts/sync-client-versions.sh 同步" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "sync-client-versions: OK,客户端与内核版本一致"
|
||||
fi
|
||||
@ -32,13 +32,17 @@ ARTIFACT_SUFFIXES = (
|
||||
".rpm",
|
||||
".pkg",
|
||||
"_win64.exe",
|
||||
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
|
||||
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh)。
|
||||
".hmap",
|
||||
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
|
||||
"SHA256SUMS.plugins",
|
||||
)
|
||||
|
||||
|
||||
def is_artifact(name: str) -> bool:
|
||||
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
|
||||
|
||||
|
||||
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
|
||||
q = urllib.parse.urlencode({"file_name": filename})
|
||||
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
# 检索方案对比报告(2026-09-09)
|
||||
## 测试数据
|
||||
- 文档库:492 篇生产文档(过滤 108 条健康检查测试文档)
|
||||
- 媒体库:3 张生产图片(验证码、新闻截图、深色模式备忘录)
|
||||
- 文本查询:10 组(精确匹配、语义、跨语言、模糊表达)
|
||||
- 媒体查询:6 组(中文/英文查图片,3 张图片各 2 条)
|
||||
|
||||
---
|
||||
|
||||
## 一、文本检索对比(文档库)
|
||||
|
||||
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|
||||
|------|-------|-------|-----|----------|
|
||||
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
|
||||
| fastText(200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
|
||||
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
|
||||
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **Jina 的优势来自"短语语义"能力**:
|
||||
- "邮件代理是否已经成功接入" → TF-IDF rank 5,Jina rank 1
|
||||
- "升级安装 QQ 插件包" → fastText rank 169,Jina rank 1(margin +0.30)
|
||||
- "我所在城市的天气预报" → fastText rank 44,Jina rank 1
|
||||
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1,Jina rank 1(margin +0.33)
|
||||
|
||||
2. **TF-IDF 在精确匹配上不可替代**:
|
||||
- "长期文档记忆功能是否健康" → TF-IDF rank 3,Jina rank 1
|
||||
- "重新加载全部扩展组件" → TF-IDF rank 0(完全未命中),Jina rank 2
|
||||
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
|
||||
|
||||
3. **RRF 融合反而变差**:
|
||||
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
|
||||
- 原因:两种稀疏向量的排序在语义查询上高度重叠,RRF 无法弥补各自短板
|
||||
|
||||
---
|
||||
|
||||
## 二、图片检索对比(同 3 张图片,6 条查询)
|
||||
|
||||
| 方案 | Hit@1 | MRR | 平均 margin |
|
||||
|------|-------|-----|-------------|
|
||||
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
|
||||
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
|
||||
|
||||
### 逐条对比
|
||||
|
||||
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|
||||
|------|-----------|-------------|-----------|-------------|
|
||||
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
|
||||
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
|
||||
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
|
||||
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
|
||||
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
|
||||
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **中文文本→图片**:Jina 明显优于 CLIP(MRR 0.833 vs 0.611)
|
||||
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
|
||||
- Jina 最差也是 +0.045,正样本始终高于负样本
|
||||
|
||||
2. **新闻截图是共同弱点**:
|
||||
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
|
||||
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
|
||||
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
|
||||
|
||||
3. **margin 的实际意义**:
|
||||
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
|
||||
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
|
||||
- 但两者的 margin 都很小(< 0.1),生产环境仍需阈值校准
|
||||
|
||||
---
|
||||
|
||||
## 三、延迟与资源
|
||||
|
||||
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|
||||
|------|-------------|----------|------|
|
||||
| TF-IDF | 0.3ms | <1s | ~50MB |
|
||||
| fastText | 8.3ms | <1s | ~200MB |
|
||||
| CLIP ONNX | 26ms | N/A | ~600MB |
|
||||
| Jina v5-omni CPU | 39.9ms | 78s(492篇) | ~4GB |
|
||||
|
||||
---
|
||||
|
||||
## 四、结论与建议
|
||||
|
||||
### 核心判断
|
||||
|
||||
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|
||||
|------|-----------------|------|--------------|
|
||||
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
|
||||
| 文本语义检索 | ★★ | N/A | ★★★★★ |
|
||||
| 中文文本→图片 | 无能力 | ★ | ★★★★ |
|
||||
| 英文文本→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 图片→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
|
||||
| 延迟 | ★★★★★ | ★★★ | ★★ |
|
||||
|
||||
### 架构建议
|
||||
|
||||
1. **保留 TF-IDF 作为精确召回的一级通道**:
|
||||
- 0.3ms 延迟不可替代
|
||||
- Hit@5 70% 证明在关键词匹配场景仍有价值
|
||||
- 特别是"插件安装"、"设备查询"这类精确操作指令
|
||||
|
||||
2. **用 Jina 替换 fastText + CLIP 的稠密通道**:
|
||||
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
|
||||
- 统一空间消除三条通道的维护成本
|
||||
- 中文文本→图片从"无法检索"提升到"可检索"
|
||||
|
||||
3. **两路融合:TF-IDF + Jina RRF**(而非 TF-IDF + fastText RRF):
|
||||
- TF-IDF 精确匹配 + Jina 语义覆盖
|
||||
- RRF 避免跨空间分数归一化问题
|
||||
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
|
||||
|
||||
4. **图片检索仍需阈值校准**:
|
||||
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
|
||||
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
|
||||
|
||||
### 下一步
|
||||
|
||||
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
|
||||
- 用更多生产图片标定 Jina 的图片检索阈值
|
||||
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代(L0 相关性计算)
|
||||
@ -1,6 +1,6 @@
|
||||
# Git 分支管理规范
|
||||
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七)。
|
||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七),2026-09-13 修订(发布线路牌随 patch 推进 + 版本号不得固化落库)。
|
||||
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
||||
|
||||
@ -63,6 +63,11 @@ main ──────────────── E ────────
|
||||
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
||||
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.0.x main`。
|
||||
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
||||
- **发布线的 `meta.Version` 必须跟着该线已发的最后一个 patch 走**(`release/v1.1.x` 末态
|
||||
`1.1.1`、`release/v1.2.x` 末态 `1.2.2`、`release/v1.3.x` 末态 `1.3.6`)。
|
||||
⚠️ **不要只用构建参数(`-ldflags -X ...meta.Version`)打版本号而不改源码**:
|
||||
二进制自称 1.3.4、源码路牌还停在 1.3.0,追溯时对不上账(2026-09-13 真实踩过,
|
||||
1.3.1–1.3.4 四个 patch 都是这么打的,`release/v1.3.x` 的路牌一直没动)。
|
||||
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||
|
||||
### 4. 三级发布通道(alpha / beta / 正式)
|
||||
@ -162,36 +167,50 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
||||
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
||||
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
||||
| 人格文本在**播种时**就把 `meta.Version` 插值写进配置库 | 装机那天即冻住版本号:内核 1.3.x 的实例仍向用户自称 `v1.0.3`(2026-09-13 用户当场发现) |
|
||||
| 发布线只用 `-ldflags -X` 打版本、源码 `meta.Version` 不动 | 二进制自称 1.3.4、源码路牌仍是 1.3.0,溯源对不上账;同类还有给 SDK 误发 patch tag(§七.1 要求 patch 位恒为 `.0`) |
|
||||
|
||||
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
||||
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
|
||||
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
要么**在渲染时**用 `meta.Version` 插值(系统提示词占位符 `{{kernel_version}}` 即此机制),
|
||||
要么要求读运行时快照,并用测试钉住(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
**"插值"指的是每次组装时现算,不是把算好的结果固化进配置库/文档** ——
|
||||
固化过的版本号与写死没有区别,而且更难发现。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-12 更新)
|
||||
## 三、当前分支对齐(2026-09-13 更新)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 `1.3.0`) | ✅ 保持 |
|
||||
| `release/v1.2.x` | **本条发布线**,`meta.Version` = `1.2.0`,vendored SDK 定版 `1.2.0`;已载入两个发布前修复(GUI 输出目录、知识库同名覆盖) | 🆕 2026-09-12 从 main 切出;**尚无 tag** |
|
||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1` | 📦 已退役(§2.6:下个中版本发布即退役),保留供追溯 |
|
||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 **`1.4.0`** —— `1.3.0` 已归发布线所有) | ✅ 保持 |
|
||||
| `release/v1.3.x` | **本条发布线**,`meta.Version` = **`1.3.6`**(该线最后一个 patch);承载 `v1.3.1`…`v1.3.6`;vendored SDK 定版 `1.3.0` | ✅ 保持 |
|
||||
| `release/v1.2.x` | 承载 `v1.2.0` / `v1.2.1` / `v1.2.2`,末态 `meta.Version` = `1.2.2` | 📦 已退役(§2.6),保留供追溯 |
|
||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1`,末态 `meta.Version` = `1.1.1` | 📦 已退役,保留供追溯 |
|
||||
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
||||
| `feature/multimodal-embedding` | 已合入 main(`eb4762a`,43 提交,`--no-ff`) | ⏳ 待删(删远端分支需用户确认,§执行守则 3) |
|
||||
|
||||
> `feature/memory-media`、`feature/plugin-proc-migration` 均已从远端删除(旧表里的待删项已处理)。
|
||||
> - `v1.3.0` 是**已撤回**的坏 tag:设备输出通道名 `device/<id>` 里的 `/` 拼进 LLM 函数名
|
||||
> `output_send__device/<id>`,上游按**整条请求** 400,全量对话不可用(修复见 `v1.3.1`)。
|
||||
> - `feature/*` 分支(`input-semantics`、`multimodal-embedding`、`memory-media`、`plugin-proc-migration`)
|
||||
> 均已合入并删除。
|
||||
|
||||
### SDK 仓(homeagent-sdk)
|
||||
|
||||
| 分支 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.2.0`**)——SDK **不跟 beta 发版**(§七.2),1.2.0 要等核心的**正式** tag 才定版(§七.3),在那之前路牌不得越过它。此阶段与核心 main(`1.3.0`)**故意不对称**,详见 §七.4 | ✅ 保持 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应 |
|
||||
| `release/v1.2.x` | **尚未创建** | ⏳ 随核心**正式** tag 一起建(§七.3:分支上把版本定为 `1.2.0` 再打 `v1.2.0`;beta 阶段不发 SDK) |
|
||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.4.0`**)——`1.3.0` 已随核心**正式** tag 定版(§七.3),故路牌推进 | ✅ 保持 |
|
||||
| `release/v1.3.x` | `meta.Version` = `1.3.0`,承载 tag `v1.3.0`(5 平台 `hmapdev` + `SHA256SUMS` + 4 个源码归档) | ✅ 与核心对应 |
|
||||
| `release/v1.2.x` | `meta.Version` = `1.2.0`,承载 tag `v1.2.0`;同线的 `v1.2.1` 属**误发的 patch tag**(§七.1 违规),其 gitcode 条目标题已标注「(已撤回)」 | 📦 已退役 |
|
||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应(已退役) |
|
||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||
|
||||
> ❗**SDK 仓不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`。
|
||||
> 2026-09-13 曾误发 `v1.3.1`(文档用),**已撤回**(远端 tag 已删,本地 commit `05b7a20` 可恢复);
|
||||
> `v1.2.1` 是同一类历史遗留。
|
||||
> ❗**现网 SDK store 例外**:本机 `hmapdev` store 用 `--from` 装的是 SDK 源码构建的 1.3.0,
|
||||
> 与 tag 内容一致。
|
||||
|
||||
### 1.0.x 发布线 tag 历史
|
||||
|
||||
| tag | 提交 | 通道 | 说明 |
|
||||
@ -242,6 +261,11 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
|
||||
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
||||
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。
|
||||
- **客户端版本必须与内核同步**(GUI / 鸿蒙 / waiter 同一个号,当前皆为 `internal/meta.Version`):
|
||||
内核版本是唯一事实源,客户端不得各写一个。拉齐用 `make sync-client-versions`,
|
||||
发版前跑 `make check-client-versions` 做漂移门禁。waiter 直接引用 `internal/meta`(无第二份字段);
|
||||
鸿蒙的 `versionName/versionCode` 由脚本写 `AppScope/app.json5`,运行时代码从 `bundleManager` 读,
|
||||
不再硬编码。
|
||||
|
||||
---
|
||||
|
||||
@ -298,10 +322,15 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
|
||||
## 六、本规范与「接口冻结」约束的关系
|
||||
|
||||
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。
|
||||
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
|
||||
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。
|
||||
> **接口冻结已到期(v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是
|
||||
> 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守。
|
||||
> 迁移完成(v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则。
|
||||
> 本节保留历史条款,但**不再作为合回门禁**。
|
||||
|
||||
- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~
|
||||
—— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立);
|
||||
公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。
|
||||
- **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
|
||||
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
|
||||
——发布分支冻结功能(§2.3),接口是最典型的功能面。
|
||||
@ -342,11 +371,79 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
|
||||
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`;
|
||||
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release;
|
||||
4. 上传 5 平台 plugindev 产物 + `SHA256SUMS`。
|
||||
4. 上传 5 平台 `hmapdev`(插件开发工具链)产物 + `SHA256SUMS`。
|
||||
|
||||
同一中版本内的后续核心 patch(1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0,
|
||||
没有新东西要发。只有接口再次变化并进入下一个中版本时,SDK 才发 1.2.0。
|
||||
|
||||
### 5. 发版产物清单(可复现)
|
||||
|
||||
**推 tag ≠ 完成发版**:还要打包产物、建 gitcode release 条目、上传附件。
|
||||
2026-09-13 出现过"tag 推了、release 条目和产物都没有"的情况(`v1.3.1`–`v1.3.6`),
|
||||
事后才补 —— 记录在此以免重犯。
|
||||
|
||||
**核心仓**(在 tag 的**干净 worktree** 里构建,不要用带其它会话改动的工作区):
|
||||
|
||||
| 产物 | 生成方式 |
|
||||
|---|---|
|
||||
| `homeagent_<版本>_linux_amd64.tar.gz` | `VERSION=<版本> bash deploy/packaging/package-linux.sh amd64` |
|
||||
| `homeagent-client_<版本>_amd64.deb`、`-server`、`-full` | 同上;server/full 需要 Chinese-CLIP 与 ONNX Runtime 资产目录(`build/model-assets/`、`build/runtime-assets/`) |
|
||||
| `SHA256SUMS` | **全部产物生成完毕之后**统一计算(边打边算会漏掉后生成的包) |
|
||||
| 4 个源码归档(`.zip` / `.tar.gz` / `.tar.bz2` / `.tar`) | gitcode 打 tag 时自动生成,无需上传 |
|
||||
|
||||
**SDK 仓**:`VERSION=<版本> bash package/build.sh all hmapdev` ⇒
|
||||
`hmapdev_{linux,darwin}_{amd64,arm64}` + `hmapdev_windows_amd64.exe` + `SHA256SUMS`。
|
||||
|
||||
**Windows 安装器(WSL 安装型)**:`homed` **不再装到 Windows**(插件体系依赖 fd 继承与
|
||||
共享内存区段内偏移解引用,Windows 句柄模型表达不了),安装器的职责是**引导 WSL2 并把
|
||||
Linux 包送进发行版里安装**。产物 `HomeAgent_v<版本>_{Server,Client,Full}_win64.exe`:
|
||||
|
||||
```bash
|
||||
# 先有 Linux 包(安装器送进 WSL 的就是它),再打安装器
|
||||
VERSION=<版本> bash deploy/packaging/package-linux.sh amd64
|
||||
VERSION=<版本> bash deploy/packaging/package-windows.sh server amd64 # 只装内核+CLI 的 WSL 场景
|
||||
VERSION=<版本> bash deploy/packaging/package-windows.sh client amd64 # 需要 Windows GUI payload
|
||||
```
|
||||
|
||||
- `package-windows.sh` 会按**变体只放对应的那一个 deb** 进 payload。为什么:WSL 侧脚本只取
|
||||
payload 里第一个 `.deb`(`install-via-wsl.ps1`),而 server/full 的 deb 各带 ~719MB 模型 ——
|
||||
照 `build.sh` 的 `stage_linux_payload`(把所有 deb+tar 全塞)打出来会是 ~2.4GB 的安装器。
|
||||
- `client`/`full` 变体还带 Windows GUI,需要 electron-builder 产出
|
||||
`build/homeagent-gui-win32-x64/`;缺它就**明确失败**,不产出"装完没有界面"的半残包。
|
||||
|
||||
**上传**(两仓同一个脚本):
|
||||
|
||||
```bash
|
||||
# 核心仓
|
||||
python3 deploy/scripts/upload_assets.py <tag> <token> # 默认上传 dist/release 下可识别的产物
|
||||
# SDK 仓(hmapdev_* 没有扩展名,不会被自动识别 ⇒ 必须显式列文件名)
|
||||
GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
|
||||
python3 deploy/scripts/upload_assets.py <tag> <token> hmapdev_linux_amd64 ...
|
||||
```
|
||||
|
||||
- 脚本先向 `releases/<tag>/upload_url` 取 **OBS 预签名 URL** 再 PUT ⇒ **release 条目必须先存在**;
|
||||
- alpha/beta 的产物可以上传,但必须在 release 条目上勾选**预发布**标志(§2.4);
|
||||
- 校验和必须覆盖**全部**附件,否则等于没有校验。
|
||||
- ❗❗**gitcode 的 release 附件是"同名只写一次"**(实测:同名两次不同内容,下载始终是第一次那份;
|
||||
且没有可用的删除接口 —— release JSON 不含 `id`,附件列表接口 404,`DELETE .../attach_files/<名>`
|
||||
只要数字 id)。**后果**:`SHA256SUMS` 若第一次上传时只覆盖了部分平台,之后**永远改不回来** ——
|
||||
1.3.1–1.3.10 都踩了:首次只传了 linux/amd64,后来补 arm64/darwin/win 时合并重传**全部无效**,
|
||||
线上那份至今只有 4 项。
|
||||
⇒ **纪律:首次上传 `SHA256SUMS` 前必须已打包全部平台**;分批上传时**先传产物、最后传校验和**,
|
||||
且校验和只传一次。补救只能换名(如 `SHA256SUMS.complete`)或重建 release(要重传全部产物)。
|
||||
- ❗**流水线脚本必须 `set -e`(或显式检查每步)**:否则某一步失败(例如驱动脚本在 tag 里
|
||||
不存在)之后它仍会继续跑到上传,把**半成品校验和**推上去覆盖全量的那份。
|
||||
(实测:v1.3.10 的校验和被 4 项覆盖掉,只能重建。)
|
||||
- ❗**在 tag 的 worktree 里构建时,驱动脚本要么已进该 tag,要么支持目录覆盖**:
|
||||
新补的脚本只存在于 main,去 tag 的 worktree 里调就是 `No such file or directory`。
|
||||
现 `package-windows.sh` 支持 `DIST_LINUX` / `BUILD_DIR` / `DIST_RELEASE` 覆盖,
|
||||
可以"用主仓的脚本 + 产物目录指向 worktree"。
|
||||
- ❗**分批上传时,后一轮必须在全量产物上重算 `SHA256SUMS`**,不能只算本轮那几个文件:
|
||||
同名附件会**覆盖**前一轮的校验和(实测:先传 amd64 的 9 个资产,后补 arm64 时
|
||||
只算了 arm64 的 4 个,结果 amd64 的校验和从 release 上消失 ⇒ 已下载的包失去校验依据,
|
||||
只能把产物全部下回来重算)。要么一次打包全部平台再算,要么后一轮把**已上传的**
|
||||
也纳入计算。
|
||||
|
||||
### 4. 版本号在两仓 main 上的含义
|
||||
|
||||
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
||||
@ -364,3 +461,52 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
|
||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||
|
||||
---
|
||||
|
||||
## 八、公开 SDK 接口的演进规则
|
||||
|
||||
> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。
|
||||
> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠
|
||||
> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**——
|
||||
> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。
|
||||
> 取代它的是下面三条更弱、但仍然硬的规则。
|
||||
|
||||
### 1. 只增不减,签名不改
|
||||
|
||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
||||
|
||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
||||
而那些插件根本不关心媒体。
|
||||
|
||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
||||
|
||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是
|
||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
||||
每个存量插件都会因未实现而编译失败。
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口,
|
||||
每个外部插件都**编不过**——是硬失败,不是软降级。
|
||||
|
||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。
|
||||
|
||||
### 4. 「接口纯追加」不等于「无需重编」
|
||||
|
||||
插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。
|
||||
协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配
|
||||
会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。
|
||||
必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。
|
||||
|
||||
### 5. 合回 main 前要同步的东西
|
||||
|
||||
1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`;
|
||||
2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含
|
||||
`TestProcTemplate_CoversAllCoreMethods`);
|
||||
3. 存量插件源码零改动(逐个 `cd example/<n> && go vet ./...`);
|
||||
4. 并发安全(`go test -race -count=5 ./sdk/`)。
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef const char* (*verfn)(void);
|
||||
static const char* call_ver(void* f){ return ((verfn)f)(); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Go 用 dlopen 加载纯 C shim(shim 本身常驻,无所谓)
|
||||
sp := C.CString("./shim.so")
|
||||
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
C.free(unsafe.Pointer(sp))
|
||||
if shim == nil {
|
||||
fmt.Println("shim 加载失败:", C.GoString(C.dlerror()))
|
||||
os.Exit(1)
|
||||
}
|
||||
openName := C.CString("shim_open")
|
||||
closeName := C.CString("shim_close")
|
||||
symName := C.CString("shim_sym")
|
||||
shimOpen := C.dlsym(shim, openName)
|
||||
shimClose := C.dlsym(shim, closeName)
|
||||
shimSym := C.dlsym(shim, symName)
|
||||
C.free(unsafe.Pointer(openName))
|
||||
C.free(unsafe.Pointer(closeName))
|
||||
C.free(unsafe.Pointer(symName))
|
||||
fmt.Printf("shim 就绪: open=%p close=%p sym=%p\n\n", shimOpen, shimClose, shimSym)
|
||||
|
||||
// 直接用 dlopen/dlsym 调 shim 的三个函数(避免再写一层 C 包装)
|
||||
load := func(path string) unsafe.Pointer {
|
||||
cp := C.CString(path)
|
||||
defer C.free(unsafe.Pointer(cp))
|
||||
return C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
}
|
||||
ver := func(h unsafe.Pointer) string {
|
||||
n := C.CString("probe_version")
|
||||
defer C.free(unsafe.Pointer(n))
|
||||
f := C.dlsym(h, n)
|
||||
if f == nil { return "<no sym>" }
|
||||
return C.GoString(C.call_ver(f))
|
||||
}
|
||||
|
||||
fmt.Println("--- 场景: Go(带 NODELETE runtime) 加载/卸载纯 C 的第三层 so ---")
|
||||
h1 := load("./probe.so")
|
||||
fmt.Printf("1) dlopen probe.so handle=%p version=%s\n", h1, ver(h1))
|
||||
|
||||
rc := C.dlclose(h1)
|
||||
fmt.Printf("2) dlclose rc=%d\n", int(rc))
|
||||
|
||||
// 换内容(V1 -> V2),同路径
|
||||
in, _ := os.ReadFile("probe_v2.so")
|
||||
os.WriteFile("probe.so", in, 0755)
|
||||
fmt.Println("3) 磁盘 probe.so 内容替换为 V2(同路径)")
|
||||
|
||||
h2 := load("./probe.so")
|
||||
fmt.Printf("4) 再 dlopen 同路径 handle=%p version=%s\n", h2, ver(h2))
|
||||
if h1 == h2 {
|
||||
fmt.Println(" => 句柄相同:未卸载,仍是旧代码")
|
||||
} else {
|
||||
fmt.Println(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
|
||||
}
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef void* (*openfn)(const char*);
|
||||
typedef int (*closefn)(void*);
|
||||
static void* c_open(void* f, const char* p){ return ((openfn)f)(p); }
|
||||
static int c_close(void* f, void* h){ return ((closefn)f)(h); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func cnt(s string) int {
|
||||
b, _ := os.ReadFile("/proc/self/maps")
|
||||
n := 0
|
||||
for _, l := range strings.Split(string(b), "\n") { if strings.Contains(l, s) { n++ } }
|
||||
return n
|
||||
}
|
||||
|
||||
func main() {
|
||||
sp := C.CString("./shim.so")
|
||||
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
C.free(unsafe.Pointer(sp))
|
||||
no := C.CString("shim_open"); nc := C.CString("shim_close")
|
||||
fo := C.dlsym(shim, no); fc := C.dlsym(shim, nc)
|
||||
C.free(unsafe.Pointer(no)); C.free(unsafe.Pointer(nc))
|
||||
|
||||
// 经【纯 C shim】去 dlopen/dlclose Go c-shared 插件
|
||||
qp := C.CString("/home/newqqagent/plugins/qq/plugin.so")
|
||||
h := C.c_open(fo, qp)
|
||||
C.free(unsafe.Pointer(qp))
|
||||
fmt.Printf("经 C shim dlopen Go 插件 handle=%p 映射段=%d\n", h, cnt("qq/plugin.so"))
|
||||
rc := C.c_close(fc, h)
|
||||
fmt.Printf("经 C shim dlclose rc=%d 映射段=%d\n", int(rc), cnt("qq/plugin.so"))
|
||||
if cnt("qq/plugin.so") > 0 {
|
||||
fmt.Println("\n❌ 仍未卸载 —— NODELETE 属于目标 .so 本身,与谁调 dlopen 无关")
|
||||
} else {
|
||||
fmt.Println("\n✅ 卸载成功")
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef char* (*verfn)(void);
|
||||
static char* call_ver(void* f){ return ((verfn)f)(); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func threads() int {
|
||||
e, _ := os.ReadDir("/proc/self/task")
|
||||
return len(e)
|
||||
}
|
||||
func rss() int {
|
||||
b, _ := os.ReadFile("/proc/self/status")
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "VmRSS:") {
|
||||
var k int
|
||||
fmt.Sscanf(l, "VmRSS: %d kB", &k)
|
||||
return k
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func main() {
|
||||
base, baseT := rss(), threads()
|
||||
fmt.Printf("基线: RSS=%dKB threads=%d\n\n", base, baseT)
|
||||
src, _ := os.ReadFile("glv1.so")
|
||||
os.MkdirAll("stress", 0755)
|
||||
var hs []unsafe.Pointer
|
||||
for i := 1; i <= 30; i++ {
|
||||
p := fmt.Sprintf("stress/%010d-qq.so", 1700000000+i)
|
||||
os.WriteFile(p, src, 0755)
|
||||
cp := C.CString("./" + p)
|
||||
h := C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
C.free(unsafe.Pointer(cp))
|
||||
if h == nil { fmt.Printf("第 %d 次失败\n", i); break }
|
||||
hs = append(hs, h)
|
||||
C.dlclose(h) // 模拟每次都尝试卸载(no-op)
|
||||
if i%10 == 0 {
|
||||
fmt.Printf("第 %2d 次重载: RSS=%dKB (+%dKB) threads=%d (+%d)\n",
|
||||
i, rss(), rss()-base, threads(), threads()-baseT)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n30 次重载后: RSS 增长 %dKB, 线程增长 %d\n", rss()-base, threads()-baseT)
|
||||
fmt.Printf("每次重载均摊: RSS +%.1fKB, 线程 +%.2f\n",
|
||||
float64(rss()-base)/30, float64(threads()-baseT)/30)
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V1"; }
|
||||
@ -1,2 +0,0 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V2"; }
|
||||
@ -1,9 +0,0 @@
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
void* shim_open(const char* p){
|
||||
void* h = dlopen(p, RTLD_NOW|RTLD_LOCAL);
|
||||
if(!h) printf(" [shim] open FAIL: %s\n", dlerror());
|
||||
return h;
|
||||
}
|
||||
int shim_close(void* h){ return dlclose(h); }
|
||||
void* shim_sym(void* h, const char* n){ return dlsym(h, n); }
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 10:多媒体 payload —— 共享内存零拷贝 vs JSON base64 ===")
|
||||
sizes := []int{100 * 1024, 1024 * 1024, 5 * 1024 * 1024}
|
||||
for _, sz := range sizes {
|
||||
img := make([]byte, sz)
|
||||
for i := range img { img[i] = byte(i % 251) }
|
||||
|
||||
// A. JSON + base64(当前 ContentBlock 的做法)
|
||||
t0 := time.Now()
|
||||
b64 := base64.StdEncoding.EncodeToString(img)
|
||||
blob, _ := json.Marshal(map[string]string{"type": "image_url", "url": "data:image/png;base64," + b64})
|
||||
var back map[string]string
|
||||
json.Unmarshal(blob, &back)
|
||||
dec, _ := base64.StdEncoding.DecodeString(back["url"][22:])
|
||||
jsonDur := time.Since(t0)
|
||||
|
||||
// B. 共享内存 arena(写入 + 偏移解引用,零拷贝读)
|
||||
mfd, _ := unix.MemfdCreate("arena", 0)
|
||||
unix.Ftruncate(mfd, int64(sz+4096))
|
||||
data, _ := unix.Mmap(mfd, 0, sz+4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
t0 = time.Now()
|
||||
copy(data[4096:], img) // 写 arena
|
||||
view := data[4096 : 4096+sz] // 偏移解引用 = 零拷贝切片
|
||||
_ = view[sz-1]
|
||||
shmDur := time.Since(t0)
|
||||
unix.Munmap(data)
|
||||
unix.Close(mfd)
|
||||
|
||||
fmt.Printf("\n%s payload:\n", map[int]string{100*1024:"100KB", 1024*1024:"1MB", 5*1024*1024:"5MB"}[sz])
|
||||
fmt.Printf(" A JSON+base64: %8v 传输体积 %d B (+%.0f%%) 解出 %d B %s\n",
|
||||
jsonDur, len(blob), float64(len(blob)-sz)/float64(sz)*100, len(dec),
|
||||
map[bool]string{true:"✓",false:"✗"}[len(dec)==sz])
|
||||
fmt.Printf(" B 共享内存: %8v 传输体积 8 B (描述符) 零拷贝视图 %d B\n", shmDur, len(view))
|
||||
fmt.Printf(" → 加速 %.0fx, 体积节省 %.0f%%\n",
|
||||
float64(jsonDur)/float64(shmDur), float64(len(blob)-8)/float64(len(blob))*100)
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
|
||||
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 11:工具调用 RPC 端到端延迟(实测 payload 中位 93B)===")
|
||||
cmd := exec.Command("./plug11")
|
||||
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
|
||||
cmd.Start()
|
||||
enc := json.NewEncoder(bufio.NewWriter(sin))
|
||||
w := bufio.NewWriter(sin); enc = json.NewEncoder(w)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
|
||||
args := json.RawMessage(`{"city":"hangzhou","days":3,"unit":"celsius","detail":true}`)
|
||||
const N = 10000
|
||||
lat := make([]time.Duration, 0, N)
|
||||
for i := 0; i < N; i++ {
|
||||
t0 := time.Now()
|
||||
enc.Encode(Req{ID: i, Method: "weather_query", Args: args}); w.Flush()
|
||||
var r Res
|
||||
if err := dec.Decode(&r); err != nil { break }
|
||||
lat = append(lat, time.Since(t0))
|
||||
}
|
||||
sin.Close(); cmd.Wait()
|
||||
sort.Slice(lat, func(a,b int) bool { return lat[a] < lat[b] })
|
||||
p := func(q float64) time.Duration { return lat[int(float64(len(lat))*q)] }
|
||||
fmt.Printf("样本 %d 次\n", len(lat))
|
||||
fmt.Printf(" p50 = %v\n p90 = %v\n p99 = %v\n max = %v\n", p(0.5), p(0.9), p(0.99), lat[len(lat)-1])
|
||||
fmt.Printf("\n对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ %.5f%%\n",
|
||||
float64(p(0.5))/float64(3*time.Second)*100)
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
import ("bufio";"encoding/json";"os")
|
||||
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
|
||||
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
|
||||
func main(){
|
||||
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
|
||||
for { var q Req
|
||||
if err:=dec.Decode(&q); err!=nil {return}
|
||||
enc.Encode(Res{ID:q.ID, Result:`{"ok":true,"data":"` + string(q.Args) + `"}`}); w.Flush() }
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func threads() int { e, _ := os.ReadDir("/proc/self/task"); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 1:eventfd 是否走 Go netpoller(只 park goroutine 不占 OS 线程)===")
|
||||
base := threads()
|
||||
fmt.Printf("基线线程数: %d (GOMAXPROCS=%d)\n\n", base, runtime.GOMAXPROCS(0))
|
||||
|
||||
const N = 200 // 模拟 200 个订阅者等待
|
||||
var wg sync.WaitGroup
|
||||
var woke int64
|
||||
files := make([]*os.File, N)
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
if err != nil { fmt.Println("eventfd 失败:", err); return }
|
||||
f := os.NewFile(uintptr(efd), fmt.Sprintf("evt%d", i))
|
||||
files[i] = f
|
||||
wg.Add(1)
|
||||
go func(f *os.File) {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 8)
|
||||
// 阻塞读:若走 netpoller 只 park goroutine
|
||||
if _, err := f.Read(buf); err == nil {
|
||||
atomic.AddInt64(&woke, 1)
|
||||
}
|
||||
}(f)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond) // 让所有 goroutine 进入等待
|
||||
waiting := threads()
|
||||
fmt.Printf("%d 个 goroutine 阻塞在 eventfd.Read 后:\n", N)
|
||||
fmt.Printf(" 线程数 = %d (增长 %d)\n", waiting, waiting-base)
|
||||
if waiting-base < 20 {
|
||||
fmt.Println(" ✅ 走 netpoller:线程未随等待者数量增长")
|
||||
} else {
|
||||
fmt.Printf(" ❌ 退化为阻塞 syscall:每个等待者占一个 OS 线程\n")
|
||||
}
|
||||
|
||||
// 全部唤醒
|
||||
one := []byte{1,0,0,0,0,0,0,0}
|
||||
for _, f := range files { f.Write(one) }
|
||||
wg.Wait()
|
||||
fmt.Printf("\n唤醒数 = %d/%d 唤醒后线程数 = %d\n", woke, N, threads())
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 子进程:fd 3 = eventfd(通知), fd 4 = shm 文件
|
||||
func main() {
|
||||
efd := os.NewFile(3, "evt")
|
||||
shmf := os.NewFile(4, "shm")
|
||||
|
||||
data, err := unix.Mmap(int(shmf.Fd()), 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { fmt.Println("CHILD mmap 失败:", err); os.Exit(1) }
|
||||
fmt.Printf("CHILD: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
|
||||
|
||||
buf := make([]byte, 8)
|
||||
if _, err := efd.Read(buf); err != nil {
|
||||
fmt.Println("CHILD read err:", err); os.Exit(1)
|
||||
}
|
||||
n := binary.LittleEndian.Uint64(buf)
|
||||
fmt.Printf("CHILD: 被 eventfd 唤醒, 计数=%d\n", n)
|
||||
|
||||
// 按偏移读:头部 16 字节 = {off uint32, len uint32, seq uint64}
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
seq := binary.LittleEndian.Uint64(data[8:16])
|
||||
payload := string(data[off : off+ln])
|
||||
fmt.Printf("CHILD: 偏移解引用 off=%d len=%d seq=%d → %q\n", off, ln, seq, payload)
|
||||
|
||||
// 子进程回写(验证双向可见)
|
||||
copy(data[2048:], []byte("CHILD-ACK"))
|
||||
binary.LittleEndian.PutUint32(data[16:20], 2048)
|
||||
binary.LittleEndian.PutUint32(data[20:24], uint32(len("CHILD-ACK")))
|
||||
fmt.Println("CHILD: 已回写 ACK")
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 2:跨进程 eventfd 通知 + 共享内存偏移解引用 ===")
|
||||
|
||||
// eventfd 不带 CLOEXEC(需要被子进程继承)
|
||||
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK)
|
||||
if err != nil { panic(err) }
|
||||
evtFile := os.NewFile(uintptr(efd), "evt")
|
||||
|
||||
// shm: 用 memfd(匿名,无需 /dev/shm 清理)
|
||||
mfd, err := unix.MemfdCreate("stagectx", 0)
|
||||
if err != nil { panic(err) }
|
||||
if err := unix.Ftruncate(mfd, 4096); err != nil { panic(err) }
|
||||
shmFile := os.NewFile(uintptr(mfd), "shm")
|
||||
|
||||
data, err := unix.Mmap(mfd, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { panic(err) }
|
||||
fmt.Printf("PARENT: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
|
||||
|
||||
// 写 payload 到 arena(偏移 1024),头部记描述符
|
||||
msg := "hello-from-parent-via-offset"
|
||||
copy(data[1024:], []byte(msg))
|
||||
binary.LittleEndian.PutUint32(data[0:4], 1024)
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(msg)))
|
||||
binary.LittleEndian.PutUint64(data[8:16], 42)
|
||||
fmt.Printf("PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:%d, seq:42}\n", len(msg))
|
||||
|
||||
cmd := exec.Command("go", "run", "exp2_child.go")
|
||||
cmd.ExtraFiles = []*os.File{evtFile, shmFile} // → 子进程 fd 3, 4
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Start(); err != nil { panic(err) }
|
||||
|
||||
time.Sleep(3 * time.Second) // 等 go run 编译+启动
|
||||
fmt.Println("PARENT: 数据到位后 post eventfd(不等待消费者)")
|
||||
t0 := time.Now()
|
||||
evtFile.Write([]byte{1,0,0,0,0,0,0,0})
|
||||
fmt.Printf("PARENT: post 耗时 %v ← post-and-forget\n", time.Since(t0))
|
||||
|
||||
cmd.Wait()
|
||||
|
||||
// 读子进程回写
|
||||
off := binary.LittleEndian.Uint32(data[16:20])
|
||||
ln := binary.LittleEndian.Uint32(data[20:24])
|
||||
if ln > 0 {
|
||||
fmt.Printf("PARENT: 读到子进程回写 → %q ✅ 双向可见\n", string(data[off:off+ln]))
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
|
||||
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
|
||||
|
||||
func main() {
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc, dec := json.NewEncoder(out), json.NewDecoder(in)
|
||||
|
||||
const N = 20000
|
||||
t0 := time.Now()
|
||||
for i := 0; i < N; i++ {
|
||||
enc.Encode(req{ID: i, Method: "stage.lock"})
|
||||
out.Flush()
|
||||
var r resp
|
||||
if err := dec.Decode(&r); err != nil { fmt.Fprintln(os.Stderr, "dec:", err); return }
|
||||
}
|
||||
d := time.Since(t0)
|
||||
fmt.Fprintf(os.Stderr, "CHILD: %d 次 lock RPC 往返 用时 %v, 均摊 %.2f µs/次\n",
|
||||
N, d, float64(d.Microseconds())/float64(N))
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
|
||||
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 3:锁仲裁 RPC 往返成本(stdio JSON-RPC)===")
|
||||
cmd := exec.Command("go", "run", "exp3_child.go")
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
|
||||
var mu sync.Mutex // 内核侧真实的锁仲裁
|
||||
dec := json.NewDecoder(bufio.NewReader(stdout))
|
||||
w := bufio.NewWriter(stdin)
|
||||
enc := json.NewEncoder(w)
|
||||
for {
|
||||
var q req
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
mu.Lock() // 真实加锁
|
||||
mu.Unlock() // 立即释放(模拟仲裁开销)
|
||||
enc.Encode(resp{ID: q.ID, OK: true})
|
||||
w.Flush()
|
||||
}
|
||||
cmd.Wait()
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type ring struct {
|
||||
writeSeq atomic.Uint64
|
||||
cap uint64
|
||||
slots []uint64
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 4:事件环 post-and-forget vs 同步 Publish(慢消费者场景)===")
|
||||
const tokens = 5000
|
||||
|
||||
// --- A. 现状:同步 Publish,消费者慢 ---
|
||||
slowHandler := func() { time.Sleep(20 * time.Microsecond) }
|
||||
t0 := time.Now()
|
||||
for i := 0; i < tokens; i++ { slowHandler() }
|
||||
syncDur := time.Since(t0)
|
||||
fmt.Printf("A 同步 Publish (慢消费者 20µs): %d token 耗时 %v → 均摊 %.1f µs/token\n",
|
||||
tokens, syncDur, float64(syncDur.Microseconds())/tokens)
|
||||
|
||||
// --- B. 新方案:写环 + eventfd post,不等消费者 ---
|
||||
r := &ring{cap: 1024, slots: make([]uint64, 1024)}
|
||||
efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK)
|
||||
f := os.NewFile(uintptr(efd), "e")
|
||||
|
||||
var dropped atomic.Uint64
|
||||
// 慢消费者 goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 8)
|
||||
var readSeq uint64
|
||||
for {
|
||||
if _, err := f.Read(buf); err != nil { return }
|
||||
w := r.writeSeq.Load()
|
||||
if w-readSeq > r.cap {
|
||||
dropped.Add(w - readSeq - r.cap)
|
||||
readSeq = w - r.cap
|
||||
}
|
||||
for readSeq < w { readSeq++ }
|
||||
time.Sleep(20 * time.Microsecond) // 慢
|
||||
select { case <-done: return; default: }
|
||||
}
|
||||
}()
|
||||
|
||||
t0 = time.Now()
|
||||
one := []byte{1,0,0,0,0,0,0,0}
|
||||
for i := 0; i < tokens; i++ {
|
||||
s := r.writeSeq.Add(1)
|
||||
r.slots[s%r.cap] = s // 写数据
|
||||
f.Write(one) // post,不等
|
||||
}
|
||||
asyncDur := time.Since(t0)
|
||||
close(done)
|
||||
fmt.Printf("B 环+eventfd post: %d token 耗时 %v → 均摊 %.2f µs/token\n",
|
||||
tokens, asyncDur, float64(asyncDur.Microseconds())/tokens)
|
||||
fmt.Printf("\n加速比 %.1fx 丢弃事件 %d(消费者跟不上,已计数)\n",
|
||||
float64(syncDur)/float64(asyncDur), dropped.Load())
|
||||
if asyncDur < syncDur/5 {
|
||||
fmt.Println("✅ post-and-forget 使流式发布与消费者速度解耦")
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil { return 0 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "Pss:") {
|
||||
f := strings.Fields(l)
|
||||
n, _ := strconv.Atoi(f[1]); return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func threads(pid int) int {
|
||||
e, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e)
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 5:17 个 Go 子进程插件的真实常驻开销(PSS 计入共享页去重)===")
|
||||
var cmds []*exec.Cmd
|
||||
for i := 0; i < 17; i++ {
|
||||
c := exec.Command("./plugbin")
|
||||
c.Stdin, _ = os.Open(os.DevNull)
|
||||
if err := c.Start(); err != nil { fmt.Println("start:", err); return }
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
totalPss, totalThreads := 0, 0
|
||||
for _, c := range cmds {
|
||||
totalPss += pssKB(c.Process.Pid)
|
||||
totalThreads += threads(c.Process.Pid)
|
||||
}
|
||||
fmt.Printf("17 进程合计: PSS = %.1f MB, 线程 = %d\n", float64(totalPss)/1024, totalThreads)
|
||||
fmt.Printf("单进程均摊: PSS = %.2f MB, 线程 = %.1f\n",
|
||||
float64(totalPss)/1024/17, float64(totalThreads)/17)
|
||||
fmt.Printf("\n对照 homed 当前(单进程装 17 个 .so):\n")
|
||||
// 找 homed
|
||||
out, _ := exec.Command("pgrep", "-x", "homed").Output()
|
||||
if p := strings.TrimSpace(string(out)); p != "" {
|
||||
pid, _ := strconv.Atoi(strings.Fields(p)[0])
|
||||
fmt.Printf(" homed PSS = %.1f MB, 线程 = %d\n", float64(pssKB(pid))/1024, threads(pid))
|
||||
}
|
||||
for _, c := range cmds { c.Process.Kill(); c.Wait() }
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// 模拟一个最小插件:stdio JSON-RPC loop + 一个 goroutine
|
||||
func main() {
|
||||
go func() { select {} }()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
dec := json.NewDecoder(in)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var m map[string]interface{}
|
||||
if err := dec.Decode(&m); err != nil { return }
|
||||
enc.Encode(map[string]interface{}{"ok": true})
|
||||
out.Flush()
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil { return -1 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "Pss:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func rssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil { return -1 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "VmRSS:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func threads(pid int) int { e,_ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 5b:17 个 Go 子进程常驻开销(保持 stdin 管道存活)===")
|
||||
var cmds []*exec.Cmd
|
||||
var pipes []interface{ Close() error }
|
||||
for i := 0; i < 17; i++ {
|
||||
c := exec.Command("./plugbin")
|
||||
w, _ := c.StdinPipe() // 保持打开 → 不 EOF
|
||||
pipes = append(pipes, w)
|
||||
c.Stdout = nil
|
||||
if err := c.Start(); err != nil { fmt.Println(err); return }
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tp, tr, tt, alive := 0, 0, 0, 0
|
||||
for _, c := range cmds {
|
||||
pid := c.Process.Pid
|
||||
if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil { continue }
|
||||
alive++
|
||||
if v := pssKB(pid); v > 0 { tp += v }
|
||||
if v := rssKB(pid); v > 0 { tr += v }
|
||||
tt += threads(pid)
|
||||
}
|
||||
fmt.Printf("存活进程 %d/17\n", alive)
|
||||
fmt.Printf("合计: PSS=%.1f MB RSS=%.1f MB 线程=%d\n",
|
||||
float64(tp)/1024, float64(tr)/1024, tt)
|
||||
if alive > 0 {
|
||||
fmt.Printf("均摊: PSS=%.2f MB RSS=%.2f MB 线程=%.1f\n",
|
||||
float64(tp)/1024/float64(alive), float64(tr)/1024/float64(alive), float64(tt)/float64(alive))
|
||||
}
|
||||
out, _ := exec.Command("pgrep", "-x", "homed").Output()
|
||||
if p := strings.TrimSpace(string(out)); p != "" {
|
||||
pid, _ := strconv.Atoi(strings.Fields(p)[0])
|
||||
fmt.Printf("\n对照 homed(单进程 + 17 个 .so): RSS=%.1f MB 线程=%d\n",
|
||||
float64(rssKB(pid))/1024, threads(pid))
|
||||
}
|
||||
for _, c := range cmds { c.Process.Kill(); c.Wait() }
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 6:子进程崩溃隔离 + 退出码/EOF 作为 recordCrash 信号 ===")
|
||||
cmd := exec.Command("./crashbin")
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = nil // 丢弃 panic 栈
|
||||
cmd.Start()
|
||||
fmt.Printf("插件进程 pid=%d 已启动\n", cmd.Process.Pid)
|
||||
|
||||
enc := json.NewEncoder(sin)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
|
||||
// 正常调用
|
||||
enc.Encode(map[string]string{"method": "ping"})
|
||||
var r map[string]interface{}
|
||||
if err := dec.Decode(&r); err == nil { fmt.Println("正常调用 → ", r) }
|
||||
|
||||
// 触发崩溃
|
||||
fmt.Println("\n发送 boom(插件内 panic)...")
|
||||
t0 := time.Now()
|
||||
enc.Encode(map[string]string{"method": "boom"})
|
||||
err := dec.Decode(&r)
|
||||
|
||||
detected := "未检测到"
|
||||
if errors.Is(err, io.EOF) || err == io.ErrUnexpectedEOF { detected = "EOF" } else if err != nil { detected = fmt.Sprintf("%v", err) }
|
||||
fmt.Printf("调用侧感知: %s (耗时 %v)\n", detected, time.Since(t0))
|
||||
|
||||
werr := cmd.Wait()
|
||||
var ec int = -1
|
||||
if ee, ok := werr.(*exec.ExitError); ok { ec = ee.ExitCode() }
|
||||
fmt.Printf("进程退出码 = %d (panic → 2,可直接喂 recordCrash)\n", ec)
|
||||
|
||||
fmt.Printf("\n宿主进程仍存活: pid=%d ✅ 崩溃已隔离\n", os.Getpid())
|
||||
fmt.Println("→ 对照:当前 .so 模型下,bridge 兜不住的 panic 会带崩整个 homed")
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var m map[string]interface{}
|
||||
if err := dec.Decode(&m); err != nil { return }
|
||||
if m["method"] == "boom" {
|
||||
panic("插件故意崩溃") // 真 panic
|
||||
}
|
||||
enc.Encode(map[string]interface{}{"ok": true})
|
||||
out.Flush()
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func spawnAndAsk(bin string) string {
|
||||
cmd := exec.Command(bin)
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Start()
|
||||
enc := json.NewEncoder(sin)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
enc.Encode(map[string]string{"method": "version"})
|
||||
var r map[string]interface{}
|
||||
dec.Decode(&r)
|
||||
sin.Close()
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
if v, ok := r["version"].(string); ok { return v }
|
||||
return "?"
|
||||
}
|
||||
|
||||
func build(ver, out string) {
|
||||
src := fmt.Sprintf(`package main
|
||||
import ("bufio";"encoding/json";"os")
|
||||
func main(){
|
||||
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
|
||||
for { var m map[string]interface{}
|
||||
if err:=dec.Decode(&m); err!=nil {return}
|
||||
enc.Encode(map[string]string{"version":%q}); w.Flush() }
|
||||
}`, ver)
|
||||
os.MkdirAll("v", 0755)
|
||||
os.WriteFile("v/main.go", []byte(src), 0644)
|
||||
os.WriteFile("v/go.mod", []byte("module v\ngo 1.21\n"), 0644)
|
||||
c := exec.Command("go", "build", "-o", "../"+out, ".")
|
||||
c.Dir = "v"
|
||||
if b, err := c.CombinedOutput(); err != nil { fmt.Println("build err:", string(b)) }
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 7:子进程模型下的热重载(迁移的原始目标)===")
|
||||
build("v1.0.0", "hotbin")
|
||||
fmt.Printf("1) 首次启动插件 → version = %s\n", spawnAndAsk("./hotbin"))
|
||||
|
||||
fmt.Println("2) 替换二进制为 v2.0.0(同路径,无需版本化 hash 目录)")
|
||||
build("v2.0.0", "hotbin")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
v := spawnAndAsk("./hotbin")
|
||||
fmt.Printf("3) 重启插件进程 → version = %s\n", v)
|
||||
if v == "v2.0.0" {
|
||||
fmt.Println("\n✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏")
|
||||
fmt.Println(" 对照 .so 模型:同路径 dlopen 复用旧映像,永远拿不到 v2")
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 8:跨进程并发扇出改写同一 StageContext(最高风险点 3.4)===")
|
||||
|
||||
mfd, _ := unix.MemfdCreate("stagectx", 0)
|
||||
unix.Ftruncate(mfd, 65536)
|
||||
shmFile := os.NewFile(uintptr(mfd), "shm")
|
||||
data, _ := unix.Mmap(mfd, 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
|
||||
// 初始 final_text = "" @1024, arena 游标 = 1024
|
||||
binary.LittleEndian.PutUint32(data[0:4], 1024)
|
||||
binary.LittleEndian.PutUint32(data[4:8], 0)
|
||||
binary.LittleEndian.PutUint32(data[8:12], 1024)
|
||||
|
||||
tags := []string{"A", "B", "C", "D", "E"} // 5 个并发插件
|
||||
var mu sync.Mutex // 内核侧锁仲裁
|
||||
var wg sync.WaitGroup
|
||||
var rpcCount int64
|
||||
var cntMu sync.Mutex
|
||||
|
||||
t0 := time.Now()
|
||||
for _, tag := range tags {
|
||||
cmd := exec.Command("go", "run", "exp8_worker.go", tag)
|
||||
cmd.ExtraFiles = []*os.File{shmFile}
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
w := bufio.NewWriter(sin)
|
||||
enc := json.NewEncoder(w)
|
||||
held := false
|
||||
for {
|
||||
var q map[string]string
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
switch q["method"] {
|
||||
case "stage.lock": mu.Lock(); held = true
|
||||
case "stage.unlock": if held { mu.Unlock(); held = false }
|
||||
}
|
||||
cntMu.Lock(); rpcCount++; cntMu.Unlock()
|
||||
enc.Encode(map[string]bool{"ok": true}); w.Flush()
|
||||
}
|
||||
if held { mu.Unlock() }
|
||||
cmd.Wait()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
dur := time.Since(t0)
|
||||
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
final := string(data[off : off+ln])
|
||||
|
||||
fmt.Printf("\n--- 结果 ---\n")
|
||||
fmt.Printf("最终 final_text 长度 = %d\n", len(final))
|
||||
counts := map[string]int{}
|
||||
for _, t := range tags { counts[t] = strings.Count(final, t) }
|
||||
fmt.Printf("各插件写入次数: %v\n", counts)
|
||||
total := 0
|
||||
for _, c := range counts { total += c }
|
||||
fmt.Printf("总字符 = %d, 长度 = %d → %s\n", total, len(final),
|
||||
map[bool]string{true:"一致 ✅ 无丢失/无撕裂", false:"不一致 ❌"}[total == len(final)])
|
||||
fmt.Printf("RPC 锁操作 = %d 次, 总耗时 %v\n", rpcCount, dur)
|
||||
fmt.Printf("\n注:写入次数少于 5×300 是 arena 64KB 上限所致(append-only 未压实),符合设计\n")
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 模拟插件:拿锁 → 读 final_text → 追加自己的标记 → 写回 → 放锁
|
||||
// 锁通过 stdio RPC 向内核申请(方案 3.7:锁仲裁回归内核,无 cgo)
|
||||
func main() {
|
||||
tag := os.Args[1]
|
||||
shmf := os.NewFile(3, "shm")
|
||||
data, err := unix.Mmap(int(shmf.Fd()), 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { fmt.Fprintln(os.Stderr, "mmap:", err); os.Exit(1) }
|
||||
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(w)
|
||||
rpc := func(method string) {
|
||||
enc.Encode(map[string]string{"method": method}); w.Flush()
|
||||
var r map[string]interface{}; dec.Decode(&r)
|
||||
}
|
||||
|
||||
const iters = 300
|
||||
for i := 0; i < iters; i++ {
|
||||
rpc("stage.lock")
|
||||
// --- 临界区:偏移解引用读写 final_text ---
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
cur := string(data[off : off+ln])
|
||||
add := tag
|
||||
newS := cur + add
|
||||
// append-only arena:写到新位置
|
||||
newOff := binary.LittleEndian.Uint32(data[8:12])
|
||||
if int(newOff)+len(newS) > 65536 { rpc("stage.unlock"); break }
|
||||
copy(data[newOff:], []byte(newS))
|
||||
binary.LittleEndian.PutUint32(data[0:4], newOff)
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(newS)))
|
||||
binary.LittleEndian.PutUint32(data[8:12], newOff+uint32(len(newS)))
|
||||
rpc("stage.unlock")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "worker "+tag+" done, iters="+strconv.Itoa(iters))
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func run(name, arg string, mu *sync.Mutex, crashed *bool) {
|
||||
cmd := exec.Command("go", "run", "exp9_worker.go", arg)
|
||||
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = nil
|
||||
cmd.Start()
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
w := bufio.NewWriter(sin); enc := json.NewEncoder(w)
|
||||
held := false
|
||||
for {
|
||||
var q map[string]string
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
switch q["method"] {
|
||||
case "stage.lock": mu.Lock(); held = true; fmt.Printf(" [%s] 获得锁\n", name)
|
||||
case "stage.unlock": if held { mu.Unlock(); held = false; fmt.Printf(" [%s] 释放锁\n", name) }
|
||||
}
|
||||
enc.Encode(map[string]bool{"ok":true}); w.Flush()
|
||||
}
|
||||
err := cmd.Wait()
|
||||
// 关键:进程死了,内核侧检测到 EOF/退出 → 强制释放它持有的锁
|
||||
if held {
|
||||
mu.Unlock()
|
||||
*crashed = true
|
||||
fmt.Printf(" [%s] 进程死亡(%v),内核强制释放其持有的锁 ← 自愈\n", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 9:持锁进程崩溃后的自愈(验证无需 robust pthread_mutex)===")
|
||||
var mu sync.Mutex
|
||||
crashed := false
|
||||
|
||||
fmt.Println("\n1) 插件 X 拿锁后 panic:")
|
||||
run("X", "crash", &mu, &crashed)
|
||||
|
||||
fmt.Println("\n2) 插件 Y 随后申请同一把锁:")
|
||||
done := make(chan bool, 1)
|
||||
go func() { run("Y", "normal", &mu, new(bool)); done <- true }()
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Println("\n✅ Y 正常获得并释放锁 —— 无死锁")
|
||||
fmt.Println(" → 内核持有锁的所有权,进程死亡由 Wait()/EOF 检测并强制释放")
|
||||
fmt.Println(" → 不需要 PTHREAD_PROCESS_SHARED|ROBUST,也不需要处理 EOWNERDEAD")
|
||||
fmt.Println(" → 整个架构可做到零 cgo")
|
||||
case <-time.After(15 * time.Second):
|
||||
fmt.Println("\n❌ 死锁:Y 拿不到锁(说明需要 robust 语义)")
|
||||
}
|
||||
_ = crashed
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user