mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 14:03:17 +00:00
Compare commits
280 Commits
v1.3.2
...
ci-assets-
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb4307677 | |||
| 6d7de92bbd | |||
| d676adbd0e | |||
| a417b5f927 | |||
| fba7dba373 | |||
| 9020590e13 | |||
| 67def306e5 | |||
| bf2d6867e7 | |||
| 034945890c | |||
| 3b08f04897 | |||
| db483c2c0c | |||
| f96f67707a | |||
| 95bdd18827 | |||
| a96ba70db9 | |||
| 1f2b078322 | |||
| 7ff0331c50 | |||
| 1b95d0ef2d | |||
| 81ac13f266 | |||
| 512effa1ad | |||
| 9640dad3b1 | |||
| df51cd4705 | |||
| 554d93cc7e | |||
| f693af3960 | |||
| 764d1b0dd3 | |||
| 1911575352 | |||
| a9fe741847 | |||
| 573f6bade7 | |||
| a103618ee7 | |||
| 146a71f11b | |||
| 6a8a4dc373 | |||
| e7ec4c6b0a | |||
| eb5e8fdceb | |||
| a6a025028d | |||
| 92ada6e882 | |||
| 0dd69a3433 | |||
| c8ba1ddfef | |||
| e0aeb14917 | |||
| 5d864b87a8 | |||
| c8ec2837dc | |||
| d7c2279205 | |||
| f4c8d86a13 | |||
| 43bc25525d | |||
| d36b4f6d34 | |||
| 1a2f59a262 | |||
| c10a36a96c | |||
| 491ae17eb1 | |||
| 82057c85e8 | |||
| 2e175ffbd8 | |||
| 2462759691 | |||
| 028537f77a | |||
| 1e0f603ad0 | |||
| da8841e52d | |||
| e329231c35 | |||
| 1a5e00a493 | |||
| c698ae031c | |||
| 5cb409c974 | |||
| b334d1584e | |||
| bc411e7426 | |||
| cf016ca909 | |||
| 1d111d7b39 | |||
| 302986d894 | |||
| e70b7e954c | |||
| 72634d140b | |||
| def3d37947 | |||
| d55932bb53 | |||
| 1a511b8b89 | |||
| ab1a17a74f | |||
| 762d442844 | |||
| 2a230ee89d | |||
| 21a3a0bc84 | |||
| 8a36be0746 | |||
| 29a9fab942 | |||
| b2c55235b7 | |||
| 8132a740c6 | |||
| 80cbda1059 | |||
| 9f85e58c64 | |||
| 4eb2d7e126 | |||
| be9c1dc5ac | |||
| c7d1aa06d2 | |||
| 90bd499ddd | |||
| 833dc7f30f | |||
| dc84e276f5 | |||
| 6ee55ce9c0 | |||
| df872b9e86 | |||
| eb14b3a1d2 | |||
| f7f2fd2a53 | |||
| a6103154f6 | |||
| 30d78234b8 | |||
| 6c557c1d10 | |||
| 55c5419899 | |||
| b1b63497bc | |||
| 076e53c7ba | |||
| ebf6df319b | |||
| 531d7f41f4 | |||
| c4ba7b148c | |||
| d3a796fde2 | |||
| 9e282ad774 | |||
| e2b96e0686 | |||
| b381f34259 | |||
| 8b85f9f9fe | |||
| aa08b1b903 | |||
| 0f8ca6894a | |||
| f146781b4b | |||
| b4c6b17721 | |||
| 428f148374 | |||
| dfcfe3593d | |||
| fa5965e253 | |||
| 1527fa4e9b | |||
| 3604171431 | |||
| 17b010d070 | |||
| a15d8d048c | |||
| 43938729af | |||
| 3a860b9b05 | |||
| 5467a9f256 | |||
| bcf99d0259 | |||
| 0efcf6c4db | |||
| ff1c615b53 | |||
| 15e5e87fbf | |||
| 4ead049a52 | |||
| 7ae92e62aa | |||
| 8b376941cb | |||
| bf8a01fb67 | |||
| 2b78a9288e | |||
| f877dff95f | |||
| d7df1dd1c9 | |||
| 95a52bc1b3 | |||
| 4dcbdb1269 | |||
| 35aeb6f88b | |||
| 8f52bfc10b | |||
| 0d4399c173 | |||
| 1291379b9f | |||
| e3867b3481 | |||
| 40669d7b9b | |||
| dc53cf48df | |||
| ddbe720e5d | |||
| e61d1b3929 | |||
| 75896784f4 | |||
| 5c7f3f82c5 | |||
| 0a3cddbc9e | |||
| 5e704e233c | |||
| c1d3265cc4 | |||
| 5ab10f679b | |||
| 199e5effa5 | |||
| f5939979c2 | |||
| 4e39c1f05d | |||
| 82a19d91d6 | |||
| abcc15984a | |||
| 9b18852285 | |||
| 47fd1e8d24 | |||
| 3500744ff5 | |||
| 9f3bd1df62 | |||
| 1b206209d8 | |||
| e16a67d025 | |||
| d8b64dbd47 | |||
| aa4fda3188 | |||
| 11ace9fb53 | |||
| fa7cdb7b3f | |||
| d2733da0ba | |||
| 4aed6c445a | |||
| ee3e0da7de | |||
| 15315ad684 | |||
| 0fd96d6630 | |||
| 93172508f9 | |||
| f2e0d55c5b | |||
| 499a4f3aa1 | |||
| 6dca498bc8 | |||
| 6c9b164081 | |||
| 1e2c914119 | |||
| ee4d079c49 | |||
| fa61247eec | |||
| fcaf3ffdb7 | |||
| 545d3b45a1 | |||
| 333c0ec119 | |||
| 2022875642 | |||
| f340adb1e1 | |||
| 89259b3508 | |||
| e90f3a629e | |||
| 3ad6d3bf0e | |||
| 4ca82c03d1 | |||
| 6b4f470b62 | |||
| f671632915 | |||
| e00c94bc43 | |||
| c5128a3971 | |||
| d6e0a930df | |||
| d9b2f2c81c | |||
| e4e112ce92 | |||
| 78d857620c | |||
| 847af53e9a | |||
| 2b6e430154 | |||
| 76d0a22c41 | |||
| e1e3183918 | |||
| 2b145b5d96 | |||
| b5a20113cb | |||
| 246306a989 | |||
| 0bb3191284 | |||
| b9f638a297 | |||
| 59607a7ef5 | |||
| d3f7e61760 | |||
| 59a4e1a296 | |||
| accf1923fc | |||
| 990e740a37 | |||
| c34e7cbb25 | |||
| fd03e8b905 | |||
| 6547506704 | |||
| 6c8ce237dd | |||
| 642c55c948 | |||
| 4f84465305 | |||
| b27ef885be | |||
| 81a0aa09d8 | |||
| c7218157eb | |||
| 7ae87ac14c | |||
| 8d0ce2c576 | |||
| 4a54f6de0e | |||
| f3d1d526f1 | |||
| ac31bbc371 | |||
| 753c3a2082 | |||
| 455aca4430 | |||
| 50d3b9de92 | |||
| ed4826f7c3 | |||
| 3885f34304 | |||
| 82512e18a0 | |||
| f145e88192 | |||
| 7310f0d190 | |||
| 66e8f7f957 | |||
| 41272d12b4 | |||
| a65e33af8a | |||
| 62ec9950b1 | |||
| 61de261c36 | |||
| ba16ce3e38 | |||
| dca43a8f53 | |||
| 1687433330 | |||
| 701574056b | |||
| 499ae40209 | |||
| 47808fced0 | |||
| 7dce320a31 | |||
| 86898ba41c | |||
| 2e35ba2169 | |||
| fc61e2268a | |||
| 0db5a31de0 | |||
| b23644ad74 | |||
| 1b0a5b1dc2 | |||
| 653e13b470 | |||
| 232c41b7bb | |||
| bb77607887 | |||
| 16df55597f | |||
| d817e9e10d | |||
| 64aa019046 | |||
| 3f3b9bf693 | |||
| 0b019f0d2b | |||
| 7d7377a47e | |||
| 8ea7a2a11e | |||
| a45f742339 | |||
| 0c833bdc33 | |||
| 608fff2315 | |||
| 9d53a79d0d | |||
| 6abdce16aa | |||
| 2a0e3bd3a8 | |||
| 08ac63af49 | |||
| 0391ea4c92 | |||
| eadda42b6d | |||
| b87a8f71ea | |||
| d3f6d3cbee | |||
| 4b814fa418 | |||
| c9960fa25f | |||
| 43e240dc47 | |||
| 7e27c3bcf6 | |||
| 86bcea7955 | |||
| 7824aabff2 | |||
| 6f16355603 | |||
| bcf86064d7 | |||
| f876be6f63 | |||
| cd7aebe224 | |||
| fb2f3a5875 | |||
| 84198ad5e1 | |||
| 10b68ab4e0 | |||
| 960e765349 | |||
| 446b63b1ce | |||
| 8a47d72287 | |||
| 4dcfee30f2 | |||
| 6924865077 |
199
.github/workflows/ci.yml
vendored
Normal file
199
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,199 @@
|
||||
# HomeAgent 主仓 CI。
|
||||
#
|
||||
# 设计原则:**CI 里跑的每一条命令,都是本地已实测通过的命令**。
|
||||
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态,
|
||||
# 最后所有人学会忽略它。
|
||||
#
|
||||
# 覆盖范围与本地 `make test` 对齐(build / vet / test / client-versions /
|
||||
# gui / csrc),并按依赖拆成独立 job,便于失败定位。
|
||||
#
|
||||
# 明确**不在** CI 里跑的东西(依赖真机/密钥/内网,跑了只会变 flaky 噪音):
|
||||
# - deploy-*.sh / homed 生产部署
|
||||
# - waiter 真机验证(192.168.2.x)
|
||||
# - cmd/gui 的 `npm run test-live`(需真 Electron + Xvfb + 真后端)
|
||||
# - scripts/kernel-stress/*(需 llmsproxy 与压测端点)
|
||||
# - 需要 DEEPSEEK_API_KEY / MEDIALIVE_* 的真实 LLM 测试(已自带 t.Skip)
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# 只读权限:CI 不需要写仓库。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# 同一分支连续推送时取消旧跑,省额度也避免过期结果误导。
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# gojieba / onnx 相关包需要 cgo ⇒ 不能用 CGO_ENABLED=0。
|
||||
CGO_ENABLED: 1
|
||||
# 减少 go test 输出噪音。
|
||||
GOFLAGS: -buildvcs=false
|
||||
|
||||
jobs:
|
||||
# ── Go 后端:构建 + 静态检查 + 全量测试 + 跨平台客户端版本一致性 ──
|
||||
go:
|
||||
name: Go build / vet / test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# cgo 需要 gcc/g++(gojieba 会编译自带 C++ 源码)。
|
||||
- name: 确认 cgo 工具链
|
||||
run: |
|
||||
gcc --version | head -1
|
||||
g++ --version | head -1
|
||||
|
||||
- name: go build ./...
|
||||
run: go build ./...
|
||||
|
||||
- name: go vet ./...
|
||||
run: go vet ./...
|
||||
|
||||
# ./... 不点名 cmd/gui(该目录是纯 Electron,无 .go 文件):
|
||||
# 显式 `go test ./cmd/gui` 会报 "no Go files",那是误报,不是缺陷。
|
||||
- name: go test ./...
|
||||
run: go test ./... -count=1 -timeout 20m
|
||||
|
||||
# 跨平台客户端版本一致性:内核 internal/meta 是唯一事实源,
|
||||
# GUI(package.json) / 鸿蒙(AppScope/app.json5) / waiter 都必须跟它一致。
|
||||
- name: 客户端版本一致性
|
||||
run: make check-client-versions
|
||||
|
||||
# ── 竞态检测(并发改动的主要防线)──
|
||||
race:
|
||||
name: Race detector
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: go test -race(并发核心)
|
||||
run: |
|
||||
go test -race \
|
||||
./internal/agent/core/ ./cmd/waiter/ \
|
||||
-count=1 -timeout 15m
|
||||
|
||||
# ── 交叉编译:可在无 cgo 下构建的客户端/工具 ──
|
||||
#
|
||||
# 只有这三个 cmd 支持纯交叉编译。另外三个依赖 cgo(gojieba / onnx):
|
||||
# homed / memgc / homed-kb-migrate → internal/memory(gojieba)
|
||||
# homed → internal/agent/api(onnx)
|
||||
# 它们必须在原生平台构建(见 Makefile 的 build target)。
|
||||
cross:
|
||||
name: Cross-compile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
ext: ""
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
ext: ""
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
ext: ".exe"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: 构建 ${{ matrix.goos }}/${{ matrix.goarch }}
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
for c in waiter initconfig mock-server; do
|
||||
out="dist/${c}_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
|
||||
go build -trimpath -o "$out" "./cmd/${c}"
|
||||
echo " ✓ ${c} ${{ matrix.goos }}/${{ matrix.goarch }}"
|
||||
done
|
||||
|
||||
# ── Electron GUI(纯 Node 测试,零依赖)──
|
||||
#
|
||||
# `npm test` 只跑三个 .mjs,全部只 import node: 内置模块(fs/url/path/vm),
|
||||
# 所以**不需要 npm ci、不需要 electron**,秒级完成。
|
||||
# `npm run test-live` 需真 Electron + 真后端 ⇒ 不进 CI。
|
||||
gui:
|
||||
name: GUI (node)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: npm test
|
||||
working-directory: cmd/gui
|
||||
run: npm test
|
||||
|
||||
# ── C 基础设施门禁(ABI / 告警 / ASan+UBSan / 跨架构)──
|
||||
csrc:
|
||||
name: C infrastructure gates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# clang 供双编译器告警对照;gcc-aarch64 供跨架构编译门禁。
|
||||
# 门禁在缺工具时是显式 SKIP 而不是假通过,这里装齐以免静默降级。
|
||||
- name: 安装 C 工具链
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq cmake clang gcc-aarch64-linux-gnu
|
||||
|
||||
- name: make check-csrc
|
||||
run: make check-csrc
|
||||
|
||||
# ── 文档站构建(mkdocs,纯 Python,无外部依赖)──
|
||||
docs:
|
||||
name: Docs build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: 校验站点配置可解析
|
||||
# 这里只做「配置与文档源没坏」的轻量校验,不做完整 mkdocs build
|
||||
# (站点发布有独立流水线,见 deploy-sdk-site.sh)。
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f mkdocs.yml ]; then
|
||||
python -c \
|
||||
"import yaml; yaml.safe_load(open('mkdocs.yml'))" \
|
||||
&& echo "mkdocs.yml OK"
|
||||
else
|
||||
echo "无 mkdocs.yml,跳过"
|
||||
fi
|
||||
test -d docs || echo "无 docs/,跳过"
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@ -26,14 +26,26 @@ cmd/gui/dist/
|
||||
#
|
||||
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only),
|
||||
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
|
||||
# example/ 下已跟踪的 20 个文件(plg.json + plugin.go)早于本规则,
|
||||
# example/ 下已跟踪的 21 个文件(10 个示例的 plg.json + plugin.go,
|
||||
# 加 qq/plugin_test.go)早于本规则,
|
||||
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
|
||||
third_party/homeagent-sdk/bin/
|
||||
third_party/homeagent-sdk/tools/
|
||||
third_party/homeagent-sdk/package/
|
||||
third_party/homeagent-sdk/scripts/
|
||||
# skills/ 是 SDK 仓的 skill 源(hmapdev skill install 的来源),
|
||||
# 与 tools/、docs/ 同理属 SDK 仓自治范围,不进本仓。
|
||||
third_party/homeagent-sdk/skills/
|
||||
third_party/homeagent-sdk/.gitignore
|
||||
third_party/homeagent-sdk/README*
|
||||
third_party/homeagent-sdk/example/
|
||||
# 文档站(mkdocs.yml + docs/ + 生成器)属于 SDK 仓,与 README* / tools/ 同理。
|
||||
# 注意 docs/ 带前导路径限定,够精确:本仓自己的 docs/ 不受影响。
|
||||
third_party/homeagent-sdk/docs/
|
||||
third_party/homeagent-sdk/mkdocs.yml
|
||||
third_party/homeagent-sdk/site_build/
|
||||
# 主题覆盖目录(只覆盖 footer.html,补备案号)——同属文档站。
|
||||
third_party/homeagent-sdk/overrides/
|
||||
.codegraph/
|
||||
|
||||
# codegraph 本地索引配置(含嵌套 SDK 仓放行,仅本地生效)
|
||||
@ -65,3 +77,6 @@ dist/
|
||||
.hvigor-home/
|
||||
.npm-cache/
|
||||
.ohpm/
|
||||
|
||||
# 漂移巡检的运行时状态(含时间戳与指纹,每次跑都变)
|
||||
.drift-watch/
|
||||
|
||||
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]
|
||||
384
Makefile
384
Makefile
@ -1,4 +1,22 @@
|
||||
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt
|
||||
.PHONY: test-gui 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 csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full
|
||||
# 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= 关掉。
|
||||
#
|
||||
# 注意:内核 C 编解码层(internal/agent/api/ha_codec.c)**不靠 tag 开关**,
|
||||
# 而是由 cgo 本身决定(`//go:build cgo` / `!cgo`)。原因:它是零依赖纯 C99
|
||||
# 源码内联编译,不需要任何外部库/工具链前提;而 homed 本就强制 cgo
|
||||
# (sqlite3 + gojieba),所以 C 路径自然生效,无需额外开关。
|
||||
# 对比 onnxruntime:那个需要运行期的 libonnxruntime.so,所以必须显式 tag。
|
||||
HOMED_TAGS ?= onnxruntime
|
||||
TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),)
|
||||
|
||||
BINARY=homed
|
||||
CLI_BINARY=waiter
|
||||
@ -8,22 +26,272 @@ GOCACHE=/tmp/gocache
|
||||
export GOPATH=/tmp/gopath
|
||||
BUILD_DIR=build
|
||||
PROJECT_ROOT := $(CURDIR)
|
||||
VERSION ?= $(shell git describe --tags --dirty 2>/dev/null || echo "0.8.0")
|
||||
# 版本号来源(见 docs/git-branching.md §2.1):
|
||||
#
|
||||
# 发布线(release/vX.Y.x):用该线的 tag 描述,产出 v1.3.12 这类正式号。
|
||||
# main(开发线):**不**用 git describe —— main 上可达的最新 tag 永远属于
|
||||
# 某条已发布的旧 patch 线(实测:main 可达 tag 是 v1.3.2,而 v1.3.12 打在
|
||||
# release/v1.3.x 上、不在 main 的祖先路径里),于是 main 构建会自称
|
||||
# "v1.3.2-192-gxxxxxx" —— 版本号看着像在 1.3.2 补丁线上,实际是下一个
|
||||
# 中版本的开发态,属于会误导人的路牌。
|
||||
#
|
||||
# 所以 main 显式产出 <meta.Version>dev:1.4.0dev。
|
||||
# meta.Version 由源码声明(internal/meta/meta.go),随发布推进而更新。
|
||||
# 构建号(距上次 tag 的提交数 + 短 SHA)仍然保留,便于定位具体提交。
|
||||
VERSION ?= $(shell git describe --tags --dirty 2>/dev/null)
|
||||
# 开发线(main)强制走 dev 号:以 meta.Version 源码声明为准,加 dev 后缀。
|
||||
# 不能用「describe 为空」来判断 —— main 上 describe 非空(可达 v1.3.2),
|
||||
# 但那个号属于已发布的旧 patch 线,对 main 没有版本语义。
|
||||
# 判定用「是否在发布线分支」而不是「是否等于 main」:detached HEAD(CI 常见)
|
||||
# 时分支名是 HEAD,若只判 main 就会退回 describe,产出 v1.3.2-192 这种
|
||||
# 属于旧 patch 线的误导号(实测踩到)。发布线之外的任何状态都走 dev 号。
|
||||
CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
ifneq ($(filter release/v%,$(CURRENT_BRANCH)),)
|
||||
VERSION := $(VERSION)
|
||||
else
|
||||
META_VERSION := $(shell grep -oE 'Version = "[0-9.]+"' internal/meta/meta.go | head -1 | grep -oE '[0-9.]+')
|
||||
VERSION := $(META_VERSION)dev$(if $(filter --dirty,$(shell git status --porcelain)),+dirty)
|
||||
endif
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
LDFLAGS = -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=$(VERSION) -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=$(COMMIT) -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=$(BUILD_TIME)
|
||||
|
||||
all: build build-cli
|
||||
|
||||
# csrc:C 源码的**独立**产物(静态库 + 契约测试),供 C 侧复用(鸿蒙/嵌入式/C SDK)。
|
||||
#
|
||||
# ⚠️ Go 构建**不依赖**它:internal/agent/api/ha_codec.{c,h} 是指向 csrc/ 的
|
||||
# **符号链接**,cgo 直接编这份源码,而不是链接预构建的 .a。
|
||||
#
|
||||
# 为什么是「包内符号链接」而不是别的(历史教训 + 实测,勿回退):
|
||||
# 1. 不能链静态库:.a 是构建产物、不入库,而发布脚本原先不产出它
|
||||
# ⇒「不入库 + 不生成」两头空(实测 cannot find csrc/build/libha_codec.a);
|
||||
# 且交叉编译 linux/arm64 时宿主 x86-64 的 .a 被链进目标产物,
|
||||
# 报 `file in wrong format`。
|
||||
# 2. 不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含):
|
||||
# ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**,改了 C 源码但缓存命中时
|
||||
# 会静默沿用旧代码(实测:变异 C 源码后 go test 仍报 ok)。
|
||||
# 这对「逐步推进 C 化」是致命的——改动无效却无人察觉。
|
||||
# 3. 包内符号链接:文件在包目录内 ⇒ 缓存按内容哈希正确跟踪
|
||||
# (实测:改 csrc/ 源文件后 go test 立即判红);
|
||||
# 同时只有一份权威源(csrc/),无副本漂移、无需同步目标。
|
||||
#
|
||||
# csrc 目标本身只服务「C 侧独立使用 + ctest」,不参与 Go 构建链路。
|
||||
CSRC_DIR=csrc
|
||||
CSRC_BUILD=$(CSRC_DIR)/build
|
||||
CSRC_LIB=$(CSRC_BUILD)/libha_codec.a
|
||||
|
||||
# ============================ C 编译告警门禁 ============================
|
||||
#
|
||||
# 为什么要「零告警」而不是「有告警就看看」:
|
||||
# 1. 本仓 C 代码量还小(ha_codec.c 约 340 行),任何告警都值得当场修;
|
||||
# 门禁零成本维持(本地实测 4 个编译器×标准组合全 0 告警)。
|
||||
# 2. C 侧没有 Go 那套 vet 等价物,告警是**唯一的**静态信号。
|
||||
# 若是「先攒着」,C 侧会慢慢退化成一堆没人看的噪声,然后没人看。
|
||||
# 3. -Wconversion 特意包含在内:C→Go 经 cgo 时隐式窄化(如 size_t→int)
|
||||
# 是真实事故来源(长度字段截断),而它在默认档下是静默的。
|
||||
#
|
||||
# -Wpedantic 尤其重要:它抓出「用了 C11 特性但 CFLAGS 写 -std=c99」这类
|
||||
# 跨工具链不一致(本轮就当场抓到 _Static_assert 一例,见 ha_abi.h)。
|
||||
CSRC_STD ?= c99
|
||||
CSRC_WARN_FLAGS = -Wall -Wextra -Wpedantic -Wshadow -Wconversion
|
||||
CSRC_CFLAGS = -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -I$(CSRC_DIR)/include
|
||||
CSRC_SRCS = $(wildcard $(CSRC_DIR)/src/*.c)
|
||||
CSRC_HDRS = $(wildcard $(CSRC_DIR)/include/*.h)
|
||||
|
||||
# 可选的第二编译器:只有一份编译器通过 ≠ C 写法可移植
|
||||
# (GCC 扩展在 clang 下报错、或反之,都是真实的发布事故)。
|
||||
CSRC_CC2 ?= clang
|
||||
|
||||
csrc: $(CSRC_LIB)
|
||||
|
||||
$(CSRC_LIB): $(wildcard $(CSRC_DIR)/src/*.c) $(wildcard $(CSRC_DIR)/include/*.h) $(CSRC_DIR)/CMakeLists.txt
|
||||
@cmake -S $(CSRC_DIR) -B $(CSRC_BUILD) -DBUILD_TESTS=ON >/dev/null
|
||||
@cmake --build $(CSRC_BUILD) -j >/dev/null
|
||||
@echo "Built: $(CSRC_LIB)(C 侧复用,不参与 Go 构建)"
|
||||
|
||||
# csrc-test:C 侧契约测试(黄金对照的另一半,见 docs/zh/c-core/llm-orchestration-c.md §五)
|
||||
csrc-test: csrc
|
||||
@cd $(CSRC_BUILD) && ctest --output-on-failure
|
||||
|
||||
# csrc-lint:C 侧告警门禁(主编译器 + 第二编译器交叉,零告警)
|
||||
#
|
||||
# 用 \`-Werror\` 而不是只看输出:只有「告警即失败」才是门禁,
|
||||
# 否则它只是打印给人看,而人会累。
|
||||
# cmd/gui 的判据入口。
|
||||
#
|
||||
# 为什么单列:cmd/gui 是**纯 Electron 目录**(0 个 .go、无 go.mod),
|
||||
# `go test ./...` 会跳过它(实测 43 包全绿、0 处提及)。
|
||||
# 也就是说:不挂到这里,GUI 的判据**没有任何标准工具链会跑**。
|
||||
# `go test ./cmd/gui` 报 "no Go files [setup failed]" 属预期,不是回归。
|
||||
test-gui:
|
||||
@command -v node >/dev/null || { echo "SKIP: 无 node,GUI 判据未跑"; exit 0; }
|
||||
@cd cmd/gui && npm test --silent
|
||||
|
||||
.PHONY: csrc-lint
|
||||
csrc-lint:
|
||||
@echo "== C 告警门禁($(CSRC_STD),$(CSRC_WARN_FLAGS))=="
|
||||
@for cc in $(CC) $(CSRC_CC2); do \
|
||||
command -v $$cc >/dev/null 2>&1 || { echo " [SKIP] $$cc 不存在"; continue; }; \
|
||||
out=$$($$cc $(CSRC_CFLAGS) -Werror -fsyntax-only $(CSRC_SRCS) 2>&1); \
|
||||
if [ -n "$$out" ]; then \
|
||||
echo " [FAIL] $$cc 有告警:"; echo "$$out" | head -20; exit 1; \
|
||||
else \
|
||||
echo " $$cc: 0 告警 ✓"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# csrc-abi:C 侧 ABI 版本自洽性(编译期断言已在 ha_abi.h 内,这里做运行期核对)
|
||||
.PHONY: csrc-abi
|
||||
csrc-abi:
|
||||
@echo "== C ABI 版本自述 =="
|
||||
@printf '#include <stdio.h>\n#include "ha_codec.h"\nint main(void){printf("%%d\\n", ha_codec_abi_version());return 0;}\n' > $(CSRC_BUILD)/abi_probe.c 2>/dev/null || mkdir -p $(CSRC_BUILD) && printf '#include <stdio.h>\n#include "ha_codec.h"\nint main(void){printf("%%d\\n", ha_codec_abi_version());return 0;}\n' > $(CSRC_BUILD)/abi_probe.c
|
||||
@$(CC) $(CSRC_CFLAGS) $(CSRC_BUILD)/abi_probe.c -o $(CSRC_BUILD)/abi_probe $(CSRC_SRCS) 2>/dev/null
|
||||
@v=$$($(CSRC_BUILD)/abi_probe); \
|
||||
if [ "$$v" -ge 1000 ] && [ "$$v" -le 99999 ]; then \
|
||||
echo " ha_codec ABI_VERSION = $$v (major=$$((v/1000)) minor=$$((v%1000))): OK"; \
|
||||
else \
|
||||
echo " [FAIL] ABI 版本荒谬:$$v"; exit 1; \
|
||||
fi
|
||||
|
||||
# csrc-sanitize:ASan + UBSan 跑 C 契约测试
|
||||
#
|
||||
# 目的:内存错误与未定义行为在 C 侧默认是**静默的**(不崩、结果看起来对),
|
||||
# 而内核 L1 路径零 malloc 的设计依赖「没有越界写」这一前提。
|
||||
# C 侧没有 Go 的 -race 等价物,sanitizer 就是这里的关等物。
|
||||
# 若本机无 libasan/libubsan(交叉工具链常见),明确 SKIP 而非静默跳过。
|
||||
.PHONY: csrc-sanitize
|
||||
# ★ 每个测试文件**各自**链接成独立二进制:契约测试每个都带 main,
|
||||
# 合在一起会「multiple definition of main」——而报错被 2>/dev/null
|
||||
# 吞掉后会被误报成「本机无 sanitizer」,是个假的 SKIP。
|
||||
# 故这里逐个构建、逐个跑,任何一个失败都判红。
|
||||
csrc-sanitize:
|
||||
@echo "== C 侧 ASan+UBSan =="
|
||||
@tmp=$$(mktemp -d); \
|
||||
built=0; \
|
||||
for t in $(CSRC_DIR)/test/test_*.c; do \
|
||||
case "$$t" in *fuzz*) continue ;; esac; \
|
||||
base=$$(basename $$t .c); \
|
||||
if ! $(CC) $(CSRC_CFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer \
|
||||
-o $$tmp/$$base $(CSRC_SRCS) $$t 2>$$tmp/build.log; then \
|
||||
if grep -qi 'sanitize\|asan\|ubsan' $$tmp/build.log; then \
|
||||
echo " [SKIP] 本机无 ASan/UBSan 运行库,已跳过"; rm -rf $$tmp; exit 0; \
|
||||
fi; \
|
||||
echo " [FAIL] 构建失败 ($$base):" ; head -10 $$tmp/build.log; rm -rf $$tmp; exit 1; \
|
||||
fi; \
|
||||
built=1; \
|
||||
if ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
|
||||
$$tmp/$$base > $$tmp/$$base.out 2>&1; then \
|
||||
echo " ASan+UBSan $$base: PASS"; \
|
||||
else \
|
||||
echo " [FAIL] sanitizer 报告 ($$base):"; head -30 $$tmp/$$base.out; rm -rf $$tmp; exit 1; \
|
||||
fi; \
|
||||
done; \
|
||||
rm -rf $$tmp; \
|
||||
if [ "$$built" = "0" ]; then echo " [FAIL] 没找到任何契约测试"; exit 1; fi
|
||||
|
||||
# csrc-headers:头文件自包含性(每个 .h 都能单独编过)
|
||||
#
|
||||
# 为什么需要:ha_codec.h 头写了「本头文件是对外契约,签名冻结」,
|
||||
# 而**头文件能不能自己编过**是另一件事。若头里用到了自己没包含的东西
|
||||
# (比如用了 int32_t 却没 <stdint.h>),后果是:
|
||||
# - 在某个翻译单元里恰好被别的头预先包含了 → 静默编过
|
||||
# - 在别处(鸿蒙/嵌入式/C SDK 直接包含它)→ 报一堆无关的错
|
||||
# 本轮就靠它抓出 ha_abi.h 的静态断言垫片缺 <assert 类依赖> 类问题。
|
||||
# 判据:每个头单独编 -fsyntax-only 必须为 0 告警 0 错。
|
||||
.PHONY: csrc-headers
|
||||
csrc-headers:
|
||||
@echo "== 头文件自包含性 =="
|
||||
@ok=1; \
|
||||
for h in $(CSRC_HDRS); do \
|
||||
base=$$(basename $$h); \
|
||||
inc=$$(dirname $$h); \
|
||||
out=$$(echo "$$cc" | tr -d '-'; ); \
|
||||
for cc in $(CC) $(CSRC_CC2); do \
|
||||
command -v $$cc >/dev/null 2>&1 || continue; \
|
||||
printf '#include "%s"\nint main(void){return 0;}\n' "$$base" > $(CSRC_BUILD)/hdr_probe.c; \
|
||||
res=$$($$cc -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -I$$inc -I$(CSRC_DIR)/include -Werror \
|
||||
-fsyntax-only $(CSRC_BUILD)/hdr_probe.c 2>&1); \
|
||||
if [ -n "$$res" ]; then \
|
||||
echo " [FAIL] $$base 单独包含时失败($$cc):"; echo "$$res" | head -10; ok=0; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
if [ "$$ok" = "1" ]; then echo " $(words $(CSRC_HDRS)) 个头文件:自包含 OK ✓"; else exit 1; fi
|
||||
|
||||
# csrc-fuzz:libFuzzer 跑不变式 + 内存安全(需 clang,无则明确 SKIP)
|
||||
#
|
||||
# 这是 C 侧唯一能「持续」而非「等下一次手写用例」的检验。
|
||||
# ha_codec 的等价契约(与 Go 的 utf8.DecodeRuneInString 一致)在正常输入下
|
||||
# 永远测不到,只有随机字节能覆盖截断序列/过长编码/代理对/超 U+10FFFF。
|
||||
# 门禁不能假装通过:无 clang 或无 libFuzzer 时显式 SKIP 并说明。
|
||||
.PHONY: csrc-fuzz
|
||||
csrc-fuzz:
|
||||
@echo "== C 侧 libFuzzer(clang)=="
|
||||
@if ! command -v $(CSRC_CC2) >/dev/null 2>&1; then \
|
||||
echo " [SKIP] $(CSRC_CC2) 不存在,无法跑 libFuzzer"; exit 0; \
|
||||
fi; \
|
||||
tmp=$$(mktemp -d); \
|
||||
if ! $(CSRC_CC2) $(CSRC_CFLAGS) -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer \
|
||||
-o $$tmp/fz $(CSRC_SRCS) $(CSRC_DIR)/test/test_fuzz_ha_codec.c 2>/dev/null; then \
|
||||
echo " [SKIP] 无 libFuzzer 运行库(需要 clang 自带),已跳过"; rm -rf $$tmp; exit 0; \
|
||||
fi; \
|
||||
SECS=$${FUZZ_SECS:-20}; \
|
||||
if $$tmp/fz -max_total_time=$$SECS -rss_limit_mb=4096 > $$tmp/fz.log 2>&1; then \
|
||||
runs=$$(grep -oE 'Done [0-9]+ runs' $$tmp/fz.log | tail -1); \
|
||||
echo " libFuzzer: PASS($${runs:-完成},$${SECS}s)"; rm -rf $$tmp; \
|
||||
else \
|
||||
echo " [FAIL] 模糊测试崩溃:"; tail -30 $$tmp/fz.log; rm -rf $$tmp; exit 1; \
|
||||
fi
|
||||
|
||||
# csrc-cross:交叉编译 C 侧(arm64 是 homed 的真实发布目标之一)
|
||||
#
|
||||
# 为什么要单独门禁:Go 侧的 `go build` 不等于 C 代码在该架构上能编。
|
||||
# C 侧的架构相关问题(endianness 假设、指针宽度、size_t vs int 宽度、
|
||||
# -fsanitize 不可用)只有真的用目标编译器编一遍才会暴露。
|
||||
# 与 deploy/packaging/build.sh 的 arm64 目标共用同一套 CC 变量。
|
||||
.PHONY: csrc-cross
|
||||
csrc-cross:
|
||||
@echo "== C 侧交叉编译(linux/arm64)=="
|
||||
@CC_ARM64=$${CC_ARM64:-aarch64-linux-gnu-gcc}; \
|
||||
if ! command -v $$CC_ARM64 >/dev/null 2>&1; then \
|
||||
echo " [SKIP] $$CC_ARM64 不存在(未装交叉工具链)"; exit 0; \
|
||||
fi; \
|
||||
ok=1; \
|
||||
for src in $(CSRC_SRCS); do \
|
||||
if ! $$CC_ARM64 $(CSRC_CFLAGS) -Werror -fsyntax-only $$src 2>&1 | head -20; then \
|
||||
ok=0; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$ok" = "1" ]; then \
|
||||
echo " $$CC_ARM64: 0 告警、编译通过 ✓($(words $(CSRC_SRCS)) 个源文件)"; \
|
||||
else \
|
||||
echo " [FAIL] arm64 交叉编译失败"; exit 1; \
|
||||
fi
|
||||
|
||||
# check-csrc:C 侧全部门禁的聚合入口(接进 make test 与 CI)
|
||||
.PHONY: check-csrc
|
||||
check-csrc: csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross
|
||||
@echo "== C 基础设施门禁:全部通过 =="
|
||||
|
||||
# check-csrc-full:在 check-csrc 基础上加模糊测试(耗时,故分开)
|
||||
.PHONY: check-csrc-full
|
||||
check-csrc-full: check-csrc csrc-fuzz
|
||||
@echo "== C 基础设施门禁(含模糊测试):全部通过 =="
|
||||
|
||||
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= 显式关掉时才符合预期)"
|
||||
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'CGO_ENABLED=1' \
|
||||
|| echo "WARN: 本次构建未启用 cgo,编解码走纯 Go 回退(不应发生)"
|
||||
|
||||
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
|
||||
@ -34,13 +302,36 @@ build-static:
|
||||
CGO_ENABLED=1 $(GO) build -tags netgo -installsuffix dynlink -ldflags '-extldflags "-static" $(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
|
||||
@echo "Built (static): $(BUILD_DIR)/$(BINARY)-static"
|
||||
|
||||
# build-linux-arm64:交叉编译 homed(真实发布目标之一)。
|
||||
#
|
||||
# 两处必须显式给定,否则必然失败(都不是 C 化引入的,但都长期缺覆盖):
|
||||
# 1. CC/CXX 交叉工具链。缺 CXX 时 cgo 回退到宿主 g++,而宿主编译器不认
|
||||
# aarch64 汇编,报 `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'`。
|
||||
# deploy/packaging/build.sh:49 一直是对的,此处此前漏了。
|
||||
# 2. .syso 隔离。cmd/{homed,waiter}/*.syso 是 Windows COFF 资源对象,
|
||||
# Go 会把同目录 .syso **无条件**链进任何目标;交叉到非 Windows 平台报
|
||||
# `file format not recognized`。build.sh 有 hide_syso_for_target,此处同样漏了。
|
||||
CC_ARM64 ?= aarch64-linux-gnu-gcc
|
||||
CXX_ARM64 ?= aarch64-linux-gnu-g++
|
||||
|
||||
build-linux-arm64:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/
|
||||
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64"
|
||||
@for f in cmd/homed/*.syso cmd/waiter/*.syso; do \
|
||||
[ -f "$$f" ] || continue; \
|
||||
mv "$$f" "$$f.hidden"; \
|
||||
done; \
|
||||
trap 'for f in cmd/homed/*.syso.hidden cmd/waiter/*.syso.hidden; do \
|
||||
[ -f "$$f" ] || continue; mv "$$f" "$${f%.hidden}"; done' EXIT; \
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=$(CC_ARM64) CXX=$(CXX_ARM64) \
|
||||
$(GO) build $(TAG_ARGS) -installsuffix dynlink -ldflags '$(LDFLAGS)' \
|
||||
-o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/; \
|
||||
for f in cmd/homed/*.syso.hidden cmd/waiter/*.syso.hidden; do \
|
||||
[ -f "$$f" ] || continue; mv "$$f" "$${f%.hidden}"; done; \
|
||||
trap - EXIT
|
||||
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64 ($$(file $(BUILD_DIR)/$(BINARY)-arm64 | sed 's/.*: //'))"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(BINARY)
|
||||
rm -rf $(BUILD_DIR) $(BINARY) $(CSRC_BUILD)
|
||||
|
||||
install: build
|
||||
-systemctl stop homeagent 2>/dev/null
|
||||
@ -50,8 +341,62 @@ install: build
|
||||
systemctl daemon-reload
|
||||
@echo "Installed. Run: systemctl enable --now homeagent"
|
||||
|
||||
# ⚠ internal/plugin/proc 的 grandchild 系列测试(fork 真进程 + syscall.Kill
|
||||
# 杀进程组)**本身不稳定**,与本次改动无关:
|
||||
# · 单独跑同一命令:ok 11.5s / FAIL 交替出现(实测至少各一次)
|
||||
# · 全量并发跑:曾 600s 超时,也曾 90s 就 FAIL
|
||||
# · 失败形态固定是 TestKillReturnsEvenWhenGrandchildSurvives,
|
||||
# 伴随日志 `[proc] audit 退出: signal: killed`
|
||||
# 症状像 fork/kill 的进程组语义在容器/并发下不稳(孙进程 setsid 脱组后
|
||||
# 杀不掉 ⇒ Wait 挂死),但**尚未定位到根因**,所以这里只记录现象、
|
||||
# 不下结论。要稳定门禁就单独跑并重试:
|
||||
# go test ./internal/plugin/proc/ -count=1
|
||||
# ⚠ 别把"单跑通过"当结论——同一命令会交替通过/失败。
|
||||
# ★ test-gui 必须**一定被执行**,但不能被前序失败短路,也不能吞掉自己的失败。
|
||||
#
|
||||
# 问题:`go test ./...` 一旦 FAIL,make 立即中止 ⇒ 挂在它后面的目标
|
||||
# 都不会跑。实测 internal/plugin/proc 偶发 FAIL 时,make test 日志里
|
||||
# **找不到 test-gui 的任何输出** —— 门禁形同虚设。
|
||||
# 但直接用 `-@$(MAKE) test-gui` 又会**吞掉** GUI 判据自己的失败码,
|
||||
# 变成给假绿灯。
|
||||
#
|
||||
# 做法:先无条件跑并把结果存进变量,最后统一决定退出码。
|
||||
# 这样既保证它一定跑,也保留它自己的失败。
|
||||
test:
|
||||
$(GO) test ./...
|
||||
@rc=0; $(GO) test ./... || rc=$$?; \
|
||||
$(MAKE) test-gui || rc=$$?; \
|
||||
$(MAKE) csrc-test || rc=$$?; \
|
||||
$(MAKE) check-csrc || rc=$$?; \
|
||||
$(MAKE) check-codec-cgo-only || rc=$$?; \
|
||||
exit $$rc
|
||||
|
||||
# check-codec-cgo-only:钉死「编解码层完全 C 化」这一决定。
|
||||
#
|
||||
# 两条断言,缺一不可:
|
||||
# ① CGO_ENABLED=1 下测试全绿(含黄金对照:C 与纯 Go 参考实现逐值相等)
|
||||
# ② CGO_ENABLED=0 下**构建必须失败**
|
||||
#
|
||||
# 为什么②要断言「失败」而不是「也能编过」:内核已完全 C 化,C 是唯一实现。
|
||||
# 若有人在 CGO_ENABLED=0 下让整包静默编过(例如加回一个纯 Go 回退),
|
||||
# 就会同时存在两份语义可能分叉的实现 —— 而 C 侧对畸形 UTF-8 的解码边界
|
||||
# 一旦与 Go 分叉,只表现为 rune 计数偏差(进而 token 预算与截断点偏移),
|
||||
# **不会立刻暴露**。所以这里把「不许有第二条路」变成可执行的断言。
|
||||
#
|
||||
# 注:这不影响任何现有构建 —— waiter/initconfig/memgc 均不依赖本包
|
||||
# (go list -deps 实测);homed 本就强制 cgo。
|
||||
.PHONY: check-codec-cgo-only
|
||||
check-codec-cgo-only:
|
||||
@echo "== 编解码层:完全 C 化检查 =="
|
||||
@CGO_ENABLED=1 $(GO) test -count=1 ./internal/agent/api/ \
|
||||
&& echo " ① cgo 下测试全绿(含黄金对照): OK"
|
||||
@if CGO_ENABLED=0 $(GO) build ./internal/agent/api/ 2>/dev/null; then \
|
||||
echo " [FAIL] CGO_ENABLED=0 下本包竟然构建成功——"; \
|
||||
echo " 编解码层已完全 C 化,不该存在第二条实现路径。"; \
|
||||
echo " 若是有意引入回退,请同时更新本检查与 codec_cgo.go 的说明。"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo " ② CGO_ENABLED=0 下响亮失败(防静默回退): OK"; \
|
||||
fi
|
||||
|
||||
run: build
|
||||
./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent
|
||||
@ -61,3 +406,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
|
||||
|
||||
87
README.md
87
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);
|
||||
- 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0;实测加载峰值 1.59GB、静置回收后稳态约 0.89GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
@ -221,6 +256,19 @@ internal/
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
>
|
||||
> 另有**一处许可口径已变更**(2026-09-24):上面 v1.2.0 条目里的「插件静态链接 SDK 故须同许可」
|
||||
> **自今日起不成立**。SDK 改以 **MIT** 发布(宽松许可,不继承内核的 AGPL),故外部插件**不是**
|
||||
> 本项目的衍生作品,作者可自行选择许可(含闭源、商业、私有),亦不受 §13 网络条款约束。
|
||||
> 原文保留以呈现当时的口径。
|
||||
>
|
||||
> 另有**两项性能断言的量纲需要更正**(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 解引用崩溃)。
|
||||
|
||||
@ -245,7 +293,7 @@ internal/
|
||||
[Releases](https://gitcode.com/JianFeeeee/HomeAgent/releases) 提供三种变体:
|
||||
|
||||
| 变体 | 内容 | 适用 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| **full** | homed + waiter + 桌面 GUI + systemd unit | 单机全功能 |
|
||||
| **server** | homed + waiter + systemd unit | 服务器(无桌面环境) |
|
||||
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
|
||||
@ -265,7 +313,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 构建。
|
||||
|
||||
## 许可
|
||||
|
||||
@ -275,13 +328,19 @@ make install # 安装到系统
|
||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
||||
|
||||
插件与本项目通过公开 SDK **静态链接**(SDK 源码会进入插件二进制),因此插件是本项目的
|
||||
衍生作品,需以相同许可发布;子进程隔离不改变这一点,因为被链接的是 SDK 代码本身。
|
||||
插件与本项目通过公开 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 静态链接
|
||||
(SDK 源码会进入插件二进制),但那个仓**以 MIT 发布**——MIT 是宽松许可,拿到授权的代码不继承
|
||||
本项目的 AGPL。因此**外部插件不是本项目的衍生作品**,作者可自行选择许可(含闭源、商业、私有),
|
||||
既不必同许可、也不受 §13 网络条款约束。第三方插件生态的安全与活跃正建立在这条之上。
|
||||
|
||||
边界很清楚:**AGPL 覆盖内核与随包内置插件**(`homed`、`internal/`、`internal/plugins/` 下 18 个内置插件);
|
||||
**MIT 覆盖公开 SDK**(`sdk/`,`go.mod` 零外部依赖、只依赖 Go 标准库,不引用内核任何代码)。
|
||||
子进程隔离在这里不重要了——决定许可的是被链接的 SDK 代码本身,而它是 MIT。
|
||||
|
||||
### 随包分发的第三方组件
|
||||
|
||||
| 组件 | 许可 | 位置 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Chinese-CLIP ViT-B/16(ONNX 产物) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
|
||||
| ONNX Runtime(`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
|
||||
| jieba 词库(内嵌进二进制) | MIT | 源码 `internal/memory/jiebadict/` |
|
||||
|
||||
100
README_EN.md
100
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,23 @@ 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.
|
||||
>
|
||||
> **One licensing statement has also changed** (2026-09-24): "statically linked plugins must match"
|
||||
> in the v1.2.0 entry below **no longer holds**. The SDK is now released under **MIT** (a permissive
|
||||
> license that does not inherit the kernel's AGPL), so external plugins are **not** derivative works
|
||||
> of this project: authors choose their own license (closed-source, commercial or private included)
|
||||
> and are not bound by §13. The original text is kept to show the position at the time.
|
||||
>
|
||||
> **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 +342,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
|
||||
|
||||
@ -291,9 +359,19 @@ source when you distribute the software, **you must also offer the source to use
|
||||
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
|
||||
as a network service therefore has to make the modified source available to that service's users.
|
||||
|
||||
Plugins are **statically linked** against this project through the public SDK (the SDK source
|
||||
ends up inside the plugin binary), so plugins are derivative works and must be released under
|
||||
the same license. Process isolation does not change this — what is linked is the SDK code itself.
|
||||
Plugins are **statically linked** against this project through the public
|
||||
[homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) (the SDK source ends up inside the
|
||||
plugin binary), but that repository is released under **MIT** — a permissive license, so code
|
||||
received under it does **not** inherit this project's AGPL. External plugins are therefore **not
|
||||
derivative works of this project**: authors pick their own license (closed-source, commercial or
|
||||
private included), with no same-license obligation and no §13 network clause. The safety and
|
||||
vitality of the third-party plugin ecosystem rest on this.
|
||||
|
||||
The boundary is clean: **AGPL covers the kernel and the bundled plugins** (`homed`, `internal/`,
|
||||
the 18 built-in plugins under `internal/plugins/`); **MIT covers the public SDK** (`sdk/`, whose
|
||||
`go.mod` has zero external dependencies and imports only the Go standard library — it never
|
||||
references any kernel code). Process isolation is beside the point here — what decides the
|
||||
license is the linked SDK code itself, and that code is MIT.
|
||||
|
||||
### Third-party components shipped with the packages
|
||||
|
||||
|
||||
@ -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"> :
|
||||
@ -770,7 +790,14 @@ pmgr.ReloadPlugins() // Reload all plugins
|
||||
|
||||
Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabled_at`, `disabled_by`). Disabling takes effect immediately (plugin stops receiving input); full removal requires a restart.
|
||||
|
||||
> **Note**: `PluginMgr()` is only available to built-in plugins; external dynamic plugins cannot call it directly.
|
||||
> **Note**: `PluginMgr()` returns an interface with **only 3 methods** (`ReloadOne` /
|
||||
> `ListLoadedPlugins` / `IsPluginDisabled`). Enable/disable/remove/reload-all
|
||||
> (`EnablePlugin` / `DisablePlugin` / `RemovePlugin` / `ReloadPlugins`) and the
|
||||
> built-in check (`IsBuiltinPlugin`) exist only on the kernel-internal
|
||||
> `internal/sdk.PluginManager` — external plugins cannot reach them. To reload from
|
||||
> an external plugin, use `ReloadOne`.
|
||||
> The two interfaces have similar names but are **not the same**:
|
||||
> `sdk.PluginMgrAPI` (public, 3 methods) vs `internal/sdk.PluginManager` (internal, 9).
|
||||
|
||||
---
|
||||
|
||||
@ -784,7 +811,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 +824,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"> :
|
||||
@ -763,7 +783,12 @@ pmgr.ReloadPlugins() // 重载所有插件
|
||||
|
||||
内部机制:禁用记录存储在 SQLite `disabled_plugins` 表(`name`, `disabled_at`, `disabled_by`),禁用立即生效(插件不再接收输入),完全卸载需重启内核。
|
||||
|
||||
> **注意**:`PluginMgr()` 仅内置插件可用,外部动态插件无法直接调用。
|
||||
> **注意**:`PluginMgr()` 返回的接口**只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` /
|
||||
> `IsPluginDisabled`)。启用/禁用/卸载/重载全部(`EnablePlugin` / `DisablePlugin` /
|
||||
> `RemovePlugin` / `ReloadPlugins`)以及内置插件判定(`IsBuiltinPlugin`)只在内核内部
|
||||
> 的 `internal/sdk.PluginManager` 上,外部插件拿不到——需重载插件请调 `ReloadOne`。
|
||||
> 两个接口叫相似的名字但**不是同一个**:`sdk.PluginMgrAPI`(公开,3 方法)与
|
||||
> `internal/sdk.PluginManager`(内部,9 方法)。
|
||||
|
||||
---
|
||||
|
||||
@ -777,7 +802,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 +815,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 自助开发插件 |
|
||||
|
||||
### 内置插件
|
||||
|
||||
|
||||
105
assets/skills/knowledge-base/DEPLOY.md
Normal file
105
assets/skills/knowledge-base/DEPLOY.md
Normal file
@ -0,0 +1,105 @@
|
||||
# kbtree 本机部署交接(交给部署方执行)
|
||||
|
||||
我负责把 kbtree 的**代码**合入 main,并把 **skill + 调用方法**在本机装好。
|
||||
**二进制替换与重启由你做** —— 我不碰运行中的生产守护进程。
|
||||
|
||||
## 现状(交接时的事实)
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| kbtree 代码 | ✅ 已在 `main`(`41d7543`),已推送 |
|
||||
| skill | ✅ 已装 `/home/newqqagent/skills/knowledge-base/` |
|
||||
| token | ✅ 已写入 `config_kbtree` 表(固定值,重启不变) |
|
||||
| 配置库备份 | ✅ `config.db.bak-20260926-143643` |
|
||||
| **服务** | ❌ **未上线** —— 运行中的二进制里没有 kbtree |
|
||||
| 我构建的候选二进制 | `/tmp/homed-new`(84.7MB,与 main 同源,`-tags onnxruntime`) |
|
||||
|
||||
**为何未上线**:14:35 有人替换了 `/usr/local/bin/homed`(84,985,720 字节)并重启了
|
||||
服务(现 PID 1450152)。那个二进制与 main 构建**不是同一份**,比我的大 2.4MB。
|
||||
直接部署会覆盖它 —— 所以交给你决定何时、以及以哪个版本为准。
|
||||
|
||||
实测确认现役二进制不含 kbtree:
|
||||
|
||||
```bash
|
||||
strings /usr/local/bin/homed | grep -c kbtree # → 0
|
||||
ss -ltn | grep 9892 # → 无监听
|
||||
```
|
||||
|
||||
## 上线步骤
|
||||
|
||||
**必须按顺序,且第 1 步不能用 `cp`**:
|
||||
|
||||
```bash
|
||||
DATA=/home/newqqagent
|
||||
|
||||
# 1. 备份配置库(WAL 模式下 cp 会拿到不一致快照,必须用 .backup)
|
||||
sqlite3 $DATA/config.db ".backup '$DATA/config.db.bak-$(date +%Y%m%d-%H%M%S)'"
|
||||
|
||||
# 2. 确认要部署的二进制
|
||||
strings /tmp/homed-new | grep -c kbtree # 期望 ≥ 1;为 0 说明候选不对
|
||||
|
||||
# 3. 原子替换(install 内部是 rename,不会写坏运行中进程的映像)
|
||||
install -m 0755 /tmp/homed-new /usr/local/bin/homed
|
||||
|
||||
# 4. 重启
|
||||
systemctl restart homeagent
|
||||
|
||||
# 5. 验证
|
||||
systemctl is-active homeagent
|
||||
journalctl -u homeagent --since "-2min" | grep kbtree
|
||||
ss -ltn | grep 9892
|
||||
```
|
||||
|
||||
### 第 5 步期望看到
|
||||
|
||||
```
|
||||
[kbtree] 已启动 http://127.0.0.1:9892
|
||||
[kbtree] 外部 agent 可用:GET /tree、/categories、/counts、/search?q=&category=
|
||||
```
|
||||
|
||||
## 部署后的冒烟测试
|
||||
|
||||
```bash
|
||||
cd /home/newqqagent/skills/knowledge-base
|
||||
|
||||
./scripts/kb_tree.sh -h # 帮助(不需要 token)
|
||||
./scripts/kb_tree.sh categories # 分类列表
|
||||
./scripts/kb_tree.sh counts # 各分类条目数
|
||||
./scripts/kb_tree.sh tree -d 1 -i 0 # 只看第一层结构
|
||||
./scripts/kb_tree.sh search -q 知识库 -c tech -l 3
|
||||
```
|
||||
|
||||
token 自动从 `config/config.json` 读(权限 600),无需手动 export。
|
||||
覆盖方式:`KB_TOKEN=xxx ./scripts/kb_tree.sh ...` 或改那个 json。
|
||||
|
||||
## 端口与 token
|
||||
|
||||
- 监听:`kbtree.listen_addr` = `127.0.0.1:9892`(**仅本机**)
|
||||
- token:`kbtree.token` 已在 `config_kbtree` 表里设为固定值。
|
||||
若要改成随机(每次重启变),把该行 value 置空即可 ——
|
||||
但那样每次重启都要重新把 token 告诉所有使用者。
|
||||
- 改 token 时**两处一起改**:`config_kbtree` 表 + `skills/knowledge-base/config/config.json`。
|
||||
|
||||
要对外(别的机器)时改 `listen_addr` 为 `0.0.0.0:9892`,
|
||||
但**先想清楚 token 怎么分发** —— 它是唯一的屏障。
|
||||
|
||||
## 退出码约定(脚本)
|
||||
|
||||
| 码 | 含义 |
|
||||
|---|---|
|
||||
| 0 | 成功 |
|
||||
| 2 | 参数错误 / 未知命令 |
|
||||
| 3 | 令牌无效(401) |
|
||||
| 4 | 分类不存在(404,stderr 会列出现有分类) |
|
||||
| 5 | 其它 HTTP 错误 |
|
||||
| 7 | 连不上(**通常是服务未上线**) |
|
||||
| 8 | 请求发送失败 |
|
||||
|
||||
## 回滚
|
||||
|
||||
```bash
|
||||
install -m 0755 <旧二进制> /usr/local/bin/homed
|
||||
systemctl restart homeagent
|
||||
```
|
||||
|
||||
配置与 skill 都不影响回滚(kbtree 未启动时它们只是闲置文件)。
|
||||
148
assets/skills/knowledge-base/SKILL.md
Normal file
148
assets/skills/knowledge-base/SKILL.md
Normal file
@ -0,0 +1,148 @@
|
||||
---
|
||||
name: knowledge-base
|
||||
description: 按分类树检索 HomeAgent 知识库。当需要查阅项目知识、架构约定、历史决策,或用户提到"知识库/knowledge/知识库有哪些/这个项目的约定是什么"时使用。支持树形导航、分类内检索、以图搜知识。
|
||||
version: 1.1.0
|
||||
author: HomeAgent
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
先看树定位分类,再做定向检索。接口由 HomeAgent 的 `kbtree` 插件提供,
|
||||
只读、需 token。详见下方"接入"。
|
||||
|
||||
HomeAgent 知识库按**分类树**组织(`tech/go/并发`、`life/sleep` …)。
|
||||
|
||||
### 何时用
|
||||
|
||||
- 用户问"这个项目/内核的某个约定是什么" → 先看树,找对分类再检索
|
||||
- 用户提到"知识库"或某个看起来像分类名的词(如 `tech/go`)→ 查该子树
|
||||
- 用户给了图片并问"知识库里有相关的吗" → 见"以图搜知识"
|
||||
|
||||
### 接入
|
||||
|
||||
服务默认监听 `127.0.0.1:9892`(配置项 `kbtree.listen_addr`),只读,
|
||||
每次请求需带 token(配置项 `kbtree.token`;未配置则启动时随机生成)。
|
||||
|
||||
```bash
|
||||
BASE=http://127.0.0.1:9892
|
||||
AUTH="X-API-Key: $KB_TOKEN" # 或 Authorization: Bearer <token> 或 ?token=
|
||||
```
|
||||
|
||||
先摸清可用接口:
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" "$BASE/"
|
||||
```
|
||||
|
||||
### 本机封装脚本(推荐先用它)
|
||||
|
||||
`scripts/kb_tree.sh` 把上面四条命令封好了,省得手拼 URL 与鉴权头:
|
||||
|
||||
```bash
|
||||
S=~/.claude/skills/knowledge-base/scripts/kb_tree.sh # 按实际安装路径调整
|
||||
|
||||
$S # 整棵树
|
||||
$S tree -c tech -d 1 # tech 子树,只看第一层
|
||||
$S categories # 分类列表
|
||||
$S counts # 各分类条目数
|
||||
$S search -q goroutine -c tech
|
||||
```
|
||||
|
||||
token 读取顺序:环境变量 `KB_TOKEN` → 同上<E5908C><E4B88A>目录的 `../config.json`。
|
||||
**不要**把 token 写在命令行上(会进 shell 历史与 `ps` 输出)。
|
||||
|
||||
### 核心工作流:先看树,再定向检索
|
||||
|
||||
**别一上来就全文搜索。** 知识库是分层的,先定位分类能显著提高命中率,
|
||||
也能避免把范围外的弱匹配当答案。
|
||||
|
||||
#### 第 1 步 · 看有哪些分类
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" "$BASE/categories"
|
||||
# {"categories":["cook","life","tech","tech/go","tech/rust"]}
|
||||
|
||||
# 内容最多的分类(按条目数倒序)
|
||||
curl -s -H "$AUTH" "$BASE/counts"
|
||||
# {"counts":[{"category":"tech","count":3}, ...], "total":5}
|
||||
```
|
||||
|
||||
#### 第 2 步 · 浏览树结构
|
||||
|
||||
```bash
|
||||
# 整棵树,只要结构
|
||||
curl -s -H "$AUTH" "$BASE/tree?items=0"
|
||||
|
||||
# 只要第一层 —— 分类多时用来做懒加载
|
||||
curl -s -H "$AUTH" "$BASE/tree?depth=1&items=0"
|
||||
|
||||
# 某棵子树,带条目详情
|
||||
curl -s -H "$AUTH" "$BASE/tree?category=tech/go"
|
||||
```
|
||||
|
||||
节点里 `name` 是**本级段名**(`"go"`),`path` 是**完整路径**
|
||||
(`"tech/go"`)。拼层级用 `name`,把 `path` 拿去请求子节点。
|
||||
`item_count` 是本级条目数,`total_count` 是整棵子树。
|
||||
|
||||
#### 第 3 步 · 分类内检索
|
||||
|
||||
```bash
|
||||
# 关键词 + 分类子树(推荐)
|
||||
curl -s -H "$AUTH" "$BASE/search?q=goroutine&category=tech"
|
||||
|
||||
# 全库
|
||||
curl -s -H "$AUTH" "$BASE/search?q=goroutine&limit=5"
|
||||
```
|
||||
|
||||
`category` 是**前缀匹配**:`tech` 会命中 `tech/go`、`tech/rust` 下的条目;
|
||||
传 `tech/go` 只命中它自己的子树。
|
||||
|
||||
### 以图搜知识(多模态)
|
||||
|
||||
若知识条目挂了图片,它在**多模态统一空间**里有向量,能被图片本身检索到。
|
||||
前提是宿主已接入多模态向量 provider(否则只是记录了媒体,不参与召回)。
|
||||
|
||||
判断是否就绪:HomeAgent 自身的知识库接口会返回稠密路状态
|
||||
(`dense.enabled` / `dense.ready` / `dense.total`)。若为未启用,
|
||||
**不要承诺"能以图搜"**。
|
||||
|
||||
本服务只提供**按关键词检索**——把图片字节提交给嵌入服务计算向量不在此接口内。所以:
|
||||
- 用户给了图 → 用图的**可见内容**(或你先读图得到的文字)当关键词检索
|
||||
- 或用本机可用的读图工具先看图,再拿描述来检索
|
||||
|
||||
### 读结果
|
||||
|
||||
`/search` 每条结果:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `name` | 知识名(**已含分类前缀**,如 `tech/go/并发`) |
|
||||
| `content` | 正文全文 |
|
||||
|
||||
`/tree` 里的条目额外有 `preview`(前 120 字)、`size`、`updated_at`、
|
||||
`media`(挂载的媒体 digest/mime/kind)。
|
||||
|
||||
### 易错点
|
||||
|
||||
- **`name` 已经含分类**。不要再拼 `category + "/" + name`,会得到
|
||||
`tech/go/tech/go/并发`。
|
||||
- **检索会返回弱匹配。** 词法路会给所有条目打一个低分,靠排序把强命中顶到
|
||||
前面。**只看第一条**;第一条明显不相关就换个分类或关键词,别把第 2、3 条
|
||||
当答案。
|
||||
- **分类不存在返回 404**,并在 `categories` 字段里附上现有分类 —— 用它自查
|
||||
拼写。
|
||||
- **`items=0` 只是不要正文**,`total_count` 仍准确,可用于判断规模。
|
||||
- **本服务只读**。写方法返回 405。要写知识请用 HomeAgent 主 agent 的
|
||||
`knowledge_create`(或 WebUI 界面),不要试图绕过它直接写这个接口。
|
||||
- 知识名里不能有 `..`、空格(会被规范成 `_`)、点开头的段。
|
||||
|
||||
### 写入
|
||||
|
||||
本服务不提供写入。若你在 HomeAgent 主 agent 内部,写入用内核工具:
|
||||
|
||||
```
|
||||
knowledge_create name="tech/go/调度" content="正文..."
|
||||
```
|
||||
|
||||
`name` 用 `/` 表示分类层级(如 `tech/go/调度`)。写完它**立刻可检索**
|
||||
(词法 IDF 是增量维护的,不必重启)。
|
||||
170
assets/skills/knowledge-base/scripts/kb_tree.sh
Executable file
170
assets/skills/knowledge-base/scripts/kb_tree.sh
Executable file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# kbtree 客户端:按分类树检索 HomeAgent 知识库。
|
||||
#
|
||||
# 由 knowledge-base skill 调用(agent 读 SKILL.md 后按需调本脚本)。
|
||||
# 也可人工使用:
|
||||
# kb_tree.sh # 整棵树
|
||||
# kb_tree.sh -c tech -d 1 # tech 子树,只看一层
|
||||
# kb_tree.sh -q goroutine -c tech # 在 tech 子树内检索
|
||||
# kb_tree.sh -l # 列分类
|
||||
#
|
||||
# token 读取顺序:环境变量 KB_TOKEN > <skill>/config/config.json(或 <skill>/config.json)> 报错。
|
||||
# 故意不放命令行参数:token 会进 shell 历史与 ps 输出。
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${KB_BASE:-http://127.0.0.1:9892}"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 读 token
|
||||
# 帮助:-h/--help 必须在解析命令之前判,否则会被当成未知命令
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-h|--help)
|
||||
echo "用法:"
|
||||
echo " kb_tree.sh [tree|categories|counts|search] [选项]"
|
||||
echo "选项:"
|
||||
echo " -q 关键词 检索词(给了 q 就走 search)"
|
||||
echo " -c 分类 限定分类子树(前缀匹配),如 tech / tech/go"
|
||||
echo " -d 层数 树展开层数,1=只看第一层(懒加载)"
|
||||
echo " -i 0|1 是否带条目详情,默认 1"
|
||||
echo " -l 条数 search 的返回条数,默认 10"
|
||||
echo "环境变量: KB_TOKEN(也可放同目录 config/config.json)"
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
if [[ -z "${KB_TOKEN:-}" ]]; then
|
||||
# 两种布局都试:<skill>/config/config.json(当前布局)与 <skill>/config.json。
|
||||
# 只写一种会因目录结构调整而静默失效——症状是「明明配了 token 却说没找到」。
|
||||
for CFG in "$HERE/../config/config.json" "$HERE/../config.json"; do
|
||||
if [[ -f "$CFG" ]]; then
|
||||
KB_TOKEN="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("token",""))' "$CFG" 2>/dev/null || true)"
|
||||
[[ -n "$KB_TOKEN" ]] && break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${KB_TOKEN:-}" ]]; then
|
||||
cat >&2 <<'EOF'
|
||||
kbtree: 未找到访问令牌。
|
||||
|
||||
取 token 的办法(其一):
|
||||
1. 若 homed 启动日志里有「token 未配置,本次随机生成」,那行日志附近有 token;
|
||||
2. 若已配置过固定 token,从配置库读:
|
||||
sqlite3 <data_dir>/config.db "SELECT value FROM config_kbtree WHERE key='token'"
|
||||
3. 直接设置:export KB_TOKEN=<token>
|
||||
|
||||
token 未配置时服务会在启动时随机生成,进程重启即失效。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
auth=(-H "X-API-Key: ${KB_TOKEN}")
|
||||
|
||||
# 第一个参数若是选项(如 -q x),它就是选项而非子命令。
|
||||
# 原先会把 "-q" 当命令名报「未知命令: -q」——而 `kb_tree.sh -q 并发` 是很自然的写法。
|
||||
if [[ $# -gt 0 && "$1" == -* ]]; then
|
||||
cmd=""
|
||||
else
|
||||
cmd="${1:-tree}"
|
||||
shift || true
|
||||
fi
|
||||
query=""; category=""; depth=""; items=""; limit=""
|
||||
while getopts "q:c:d:i:l:h" opt 2>/dev/null; do
|
||||
case "$opt" in
|
||||
q) query="$OPTARG" ;;
|
||||
c) category="$OPTARG" ;;
|
||||
d) depth="$OPTARG" ;;
|
||||
i) items="$OPTARG" ;;
|
||||
l) limit="$OPTARG" ;;
|
||||
h) echo "用法: kb_tree.sh [tree|categories|counts|search] -q 关键词 -c 分类 -d 层数 -i 0|1 -l 条数"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 预检:端口通不通。不通就直说"服务未上线",
|
||||
# 而不是让用户看 curl: (7) Connection refused 后自行猜测。
|
||||
HOSTPORT="${BASE#http://}"; HOSTPORT="${HOSTPORT%%/*}"
|
||||
if ! (exec 3<>"/dev/tcp/${HOSTPORT%%:*}/${HOSTPORT##*:}") 2>/dev/null; then
|
||||
cat >&2 <<EOF
|
||||
kbtree: 连不上 $BASE(端口未监听)。
|
||||
|
||||
最常见原因——**服务还没上线**。kbtree 是 HomeAgent 的内置插件,
|
||||
需要二进制里含它才会启动。若 homed 是在合入该插件之前构建/部署的,
|
||||
就一直没有这个服务。上线后验证:
|
||||
|
||||
systemctl show homeagent -p MainPID --value
|
||||
strings /usr/local/bin/homed | grep -c kbtree # 0 = 二进制里没有
|
||||
|
||||
本机上线步骤(会重启守护进程):
|
||||
sqlite3 <data_dir>/config.db ".backup '<data_dir>/config.db.bak-\$(date +%Y%m%d-%H%M%S)'"
|
||||
install -m 0755 <新二进制> /usr/local/bin/homed # 勿用 cp:会写坏运行中进程的映像
|
||||
systemctl restart homeagent
|
||||
journalctl -u homeagent -f | grep kbtree
|
||||
EOF
|
||||
exit 7
|
||||
fi
|
||||
|
||||
enc() { python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1]))' "$1"; }
|
||||
|
||||
# kb_call 发请求并把错误翻译成人话。
|
||||
# 不用 curl -f:它只吐 "curl: (22) 404",把服务端给的有用信息(404 会附现有分类)
|
||||
# 全丢了 —— 而那恰恰是排查时最需要的。
|
||||
kb_call() {
|
||||
local url="$1" raw code
|
||||
raw="$(curl -sS -w $'\n%{http_code}' "${auth[@]}" "$url" 2>&1)" || {
|
||||
echo "kbtree: 请求失败: ${raw}" >&2; exit 8
|
||||
}
|
||||
code="$(printf '%s' "$raw" | tail -n1)"
|
||||
body="$(printf '%s' "$raw" | sed '$d')"
|
||||
case "$code" in
|
||||
2*) printf '%s' "$body" | python3 -m json.tool; return 0 ;;
|
||||
401) echo "kbtree: 令牌无效(401)。检查 config/config.json 或 KB_TOKEN 是否与 config_kbtree 表一致。" >&2; exit 3 ;;
|
||||
404) echo "kbtree: 分类不存在(404)。服务端返回的现有分类:" >&2
|
||||
printf '%s' "$body" | KB_BODY="$body" python3 -c '
|
||||
import json, os, sys
|
||||
try:
|
||||
d = json.loads(os.environ.get("KB_BODY",""))
|
||||
print(" " + ", ".join(d.get("categories", [])), file=sys.stderr)
|
||||
except Exception:
|
||||
print(" (无法解析响应体)", file=sys.stderr)
|
||||
'
|
||||
exit 4 ;;
|
||||
*) echo "kbtree: HTTP $code" >&2; printf '%s\n' "$body" >&2; exit 5 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 命令在解析选项**之后**定:没显式给子命令时,
|
||||
# 有 -q 走 search、没有则走 tree。
|
||||
if [[ -z "$cmd" ]]; then
|
||||
if [[ -n "$query" ]]; then cmd="search"; else cmd="tree"; fi
|
||||
fi
|
||||
case "$cmd" in
|
||||
tree|categories|counts|search) ;;
|
||||
*) echo "未知命令: $cmd" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [[ "$cmd" == "categories" ]]; then
|
||||
kb_call "$BASE/categories"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$cmd" == "counts" ]]; then
|
||||
kb_call "$BASE/counts"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$cmd" == "search" || -n "$query" ]]; then
|
||||
[[ -n "$query" ]] || { echo "search 需要 -q 关键词" >&2; exit 2; }
|
||||
url="$BASE/search?q=$(enc "$query")"
|
||||
[[ -n "$category" ]] && url="$url&category=$(enc "$category")"
|
||||
[[ -n "$limit" ]] && url="$url&limit=$limit"
|
||||
kb_call "$url"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 默认:树
|
||||
url="$BASE/tree"
|
||||
[[ -n "$category" ]] && url="$url?category=$(enc "$category")"
|
||||
q=""
|
||||
[[ -n "$depth" ]] && q="depth=$depth"
|
||||
[[ -n "$items" ]] && q="${q:+$q&}items=$items"
|
||||
[[ -n "$q" ]] && url="$url?$q"
|
||||
kb_call "$url"
|
||||
6
assets/skills/knowledge-base/skill.json
Normal file
6
assets/skills/knowledge-base/skill.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-base",
|
||||
"description": "按分类树检索 HomeAgent 知识库:树形导航、分类内检索、以图搜知识",
|
||||
"version": "1.1.0",
|
||||
"author": "HomeAgent"
|
||||
}
|
||||
319
cmd/gui/chat-perf.test.mjs
Normal file
319
cmd/gui/chat-perf.test.mjs
Normal file
@ -0,0 +1,319 @@
|
||||
// 聊天页重渲性能的判据 —— 在**真实 Electron 渲染进程**里跑。
|
||||
//
|
||||
// ## 要判的是什么
|
||||
//
|
||||
// 2026-09-28 用户报「聊天页面卡得让人没有用的欲望」。真机实测
|
||||
// (Xvfb + Electron + CDP)确认:`renderChat()` 单次耗时随消息数
|
||||
// **严格线性**,约 1.33ms/消息:
|
||||
//
|
||||
// 10 条 → 13.7ms 100 条 → 128ms
|
||||
// 50 条 → 61.7ms 200 条 → 259ms
|
||||
// 400 条 → 534ms
|
||||
//
|
||||
// 而流式追加时每个 chunk 也会走这条路(即使有节流,放行时就是一次
|
||||
// 全量重渲)。⇒ 聊到几百条时,单次重渲要 **0.5 秒**,体感必然是「卡」。
|
||||
//
|
||||
// ## 为什么必须真浏览器跑
|
||||
//
|
||||
// 瓶颈在 **DOM 操作与 markdown 渲染**(innerHTML 赋值 + 布局),
|
||||
// 在 node 里跑毫无意义 —— jsdom 不会做布局,测出来的数是假的。
|
||||
//
|
||||
// ★ 我第一版在无后端连接时测,得到「0ms / 0 DOM 节点」—— 那是
|
||||
// `buildChatLayout()` 走了「请先添加后端连接」分支、聊天区压根没建
|
||||
// 出来,**测不到**而不是「不卡」。这类假绿灯必须排除。
|
||||
//
|
||||
// 运行:node cmd/gui/chat-perf.test.mjs
|
||||
// 需要:DISPLAY 或 xvfb-run、已构建的 electron、正在运行的 GUI 实例
|
||||
// (脚本自己会拉起,见 main())。
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const GUI = here; // 判据就放在 cmd/gui/ 下(here 已是该目录)
|
||||
const PORT = Number(process.env.GUI_CDP_PORT || 9350);
|
||||
|
||||
let failures = 0;
|
||||
const check = (name, ok, detail) => {
|
||||
if (ok) console.log(` ✓ ${name}`);
|
||||
else {
|
||||
failures++;
|
||||
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 拉起 GUI(若未在跑)──────────────────────────────────────────
|
||||
// 连不上时返回空数组而不是抛 —— 「GUI 没起」是**正常起始状态**,
|
||||
// 由 ensureGui 去拉起它。让 fetch 的 ECONNREFUSED 冒到顶层会直接崩掉,
|
||||
// 什么都测不到。
|
||||
async function cdpTargets() {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}/json`);
|
||||
return r.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGui() {
|
||||
const t = await cdpTargets();
|
||||
if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return null;
|
||||
const bin = join(GUI, "node_modules/.bin/electron");
|
||||
if (!existsSync(bin)) throw new Error("electron 未安装,先 cd cmd/gui && npm i");
|
||||
// ★ --server-args 必须作为**单个** argv 元素。
|
||||
// 写成 "--server-args=-screen 0 1280x900x24" 时,若用 shell 会被按空格
|
||||
// 拆成多个参数(--server-args=-screen / 0 / 1280x900x24),
|
||||
// xvfb-run 收到非法的 display 尺寸 ⇒ 静默起不来。
|
||||
// 这里用 spawn 的数组形式(不经 shell),每个元素原样传递。
|
||||
const child = spawn(
|
||||
"setsid",
|
||||
["xvfb-run", "-a", "--server-args=-screen 0 1280x900x24", bin, "--no-sandbox",
|
||||
`--remote-debugging-port=${PORT}`, "."],
|
||||
// ★ detached 只脱离**进程组**,不脱离**会话**;脚本结尾 process.exit()
|
||||
// 仍会把它带走(实测:GUI 明明 [tray] READY 起来了,判据却报
|
||||
// 「找不到 GUI 页面」—— 因为它被自己启动的父脚本杀了)。
|
||||
// 彻底的做法是 setsid:新建会话,父进程退出后不受影响。
|
||||
{ cwd: GUI, detached: true, stdio: ["ignore", "ignore", "ignore"] },
|
||||
);
|
||||
child.unref();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const t = await cdpTargets();
|
||||
if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return child;
|
||||
}
|
||||
throw new Error(
|
||||
`GUI 启动超时(${PORT})。手动验证命令:\n` +
|
||||
` cd cmd/gui && setsid xvfb-run -a --server-args="-screen 0 1280x900x24" ` +
|
||||
`./node_modules/.bin/electron --no-sandbox --remote-debugging-port=${PORT} . &`,
|
||||
);
|
||||
}
|
||||
|
||||
async function evalInGui(expr) {
|
||||
const tabs = await cdpTargets();
|
||||
const page = tabs.find((t) => t.type === "page" && t.url.includes("index.html"));
|
||||
if (!page) throw new Error("找不到 GUI 页面");
|
||||
const sock = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((r) => (sock.onopen = r));
|
||||
const id = Math.floor(Math.random() * 1e6);
|
||||
sock.send(JSON.stringify({
|
||||
id, method: "Runtime.evaluate",
|
||||
params: { expression: expr, awaitPromise: true, returnByValue: true },
|
||||
}));
|
||||
// ★ JSON.parse 必须包 try —— CDP 的 onmessage 也可能收到二进制帧
|
||||
// (DevTools 自己的通知),e.data 不是 JSON 时裸 parse 会抛,
|
||||
// 抛在事件回调里既冒泡不到 await、也等不到 resolve ⇒ 整个判据挂死。
|
||||
const res = await new Promise((r) => {
|
||||
sock.onmessage = (e) => {
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(e.data);
|
||||
} catch {
|
||||
return; // 非 JSON 帧,忽略
|
||||
}
|
||||
if (m && m.id === id) r(m);
|
||||
};
|
||||
});
|
||||
sock.close();
|
||||
const v = res.result?.result?.value;
|
||||
if (v === undefined) throw new Error(res.result?.exceptionDetails?.text || "求值失败");
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── 判据 ────────────────────────────────────────────────────────
|
||||
|
||||
// ★ 必须先确保 GUI 在跑(自己拉起)。
|
||||
// 漏掉这一行时,脚本会在 GUI 未启动时直接报「找不到 GUI 页面」——
|
||||
// 我重写文件时把调用丢了,而 ensureGui 的定义还在,看起来一切正常。
|
||||
await ensureGui();
|
||||
|
||||
const PROBE = `(async () => {
|
||||
// ★ 必须先有后端连接,否则 buildChatLayout() 走「请先添加」分支,
|
||||
// chat-msgs 压根不建 ⇒ 后面全是 0ms / 0 DOM 的假绿灯。
|
||||
const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")};
|
||||
state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}];
|
||||
state.currentConn = state.connections[0];
|
||||
_chatLayoutBuilt = false;
|
||||
buildChatLayout();
|
||||
if (!document.getElementById('chat-msgs')) return { error: 'chat-msgs 未建出(无后端连接?)' };
|
||||
|
||||
const body = '**要点** 一段中文正文。'.repeat(20);
|
||||
const measure = (n) => {
|
||||
state.messages = Array.from({length:n}, (_,i)=>({role: i%2?'assistant':'user', content: body+' #'+i}));
|
||||
document.querySelector('#view-chat')?.classList.add('active');
|
||||
renderChat();
|
||||
const t = [];
|
||||
for (let k=1;k<=4;k++) {
|
||||
state.messages[n-1].content += 'x'.repeat(20); // 变内容 ⇒ signature 变 ⇒ 不会走缓存
|
||||
const t0 = performance.now();
|
||||
renderChat();
|
||||
t.push(performance.now()-t0);
|
||||
}
|
||||
return { n, avg: t.reduce((a,b)=>a+b,0)/t.length,
|
||||
dom: document.querySelectorAll('#chat-msgs *').length };
|
||||
};
|
||||
return [50, 200, 400].map(measure);
|
||||
})()`;
|
||||
|
||||
const SPLIT = `(async () => {
|
||||
const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")};
|
||||
state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}];
|
||||
state.currentConn = state.connections[0];
|
||||
_chatLayoutBuilt = false;
|
||||
buildChatLayout();
|
||||
if (!document.getElementById('chat-msgs')) return { error: 'chat-msgs 未建出' };
|
||||
const body = '**要点** 一段中文正文。'.repeat(20);
|
||||
state.messages = Array.from({length:200}, (_,i)=>({role:i%2?'assistant':'user', content: body+' #'+i}));
|
||||
document.querySelector('#view-chat')?.classList.add('active');
|
||||
renderChat();
|
||||
|
||||
// 拆开量:markdown 渲染 vs 整体(含 DOM 写入/布局)
|
||||
const texts = state.messages.map(m=>m.content);
|
||||
let md = 0;
|
||||
for (let k=0;k<3;k++){ texts[0]+='y'; const t0=performance.now(); for(const c of texts) renderMd(c); md += performance.now()-t0; }
|
||||
md /= 3;
|
||||
let full = 0;
|
||||
for (let k=1;k<=3;k++){ state.messages[0].content+='y'; const t0=performance.now(); renderChat(); full += performance.now()-t0; }
|
||||
full /= 3;
|
||||
return { full, md, dom: document.querySelectorAll('#chat-msgs *').length };
|
||||
})()`;
|
||||
|
||||
const split = await evalInGui(SPLIT);
|
||||
if (split && split.error) {
|
||||
check("能拆出 md / 整体耗时", false, split.error);
|
||||
} else {
|
||||
const mdPct = Math.round((split.md / split.full) * 100);
|
||||
console.log(` · 200 条:整体 ${split.full.toFixed(1)}ms,其中 renderMd ${split.md.toFixed(1)}ms(${mdPct}%)`);
|
||||
check(
|
||||
"瓶颈已定位(拆分数据可用)",
|
||||
split.full > 0 && split.md >= 0,
|
||||
"拆不出比例 ⇒ 定位不了该优化哪一层",
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await evalInGui(PROBE);
|
||||
if (rows && rows.error) {
|
||||
check("能测到真实 DOM", false, rows.error);
|
||||
} else {
|
||||
check("能测到真实 DOM", true);
|
||||
|
||||
const byN = Object.fromEntries(rows.map((r) => [r.n, r]));
|
||||
for (const r of rows) {
|
||||
console.log(` · ${r.n} 条 → ${r.avg.toFixed(1)}ms,DOM ${r.dom} 节点`);
|
||||
}
|
||||
|
||||
// ★ 体感阈值:200 条时单次重渲超过 100ms,用户就会明确感到"卡";
|
||||
// 实测 250ms。判据定在 100ms —— 这是「产品体感」而非 benchmark 数字。
|
||||
const at200 = byN[200];
|
||||
check(
|
||||
"200 条消息时单次重渲 < 100ms",
|
||||
at200 && at200.avg < 100,
|
||||
`实测 ${at200 ? at200.avg.toFixed(1) : "?"}ms —— 聊天越久越卡的主因`,
|
||||
);
|
||||
|
||||
const g1 = byN[50].avg / 50;
|
||||
const g2 = byN[400].avg / 400;
|
||||
check(
|
||||
"单条成本随规模下降或持平(次线性)",
|
||||
g2 <= g1 * 1.05,
|
||||
`每条成本:50 条时 ${g1.toFixed(3)}ms,400 条时 ${g2.toFixed(3)}ms` +
|
||||
` ⇒ ${g2 > g1 * 1.05 ? "严格线性 ⇒ 没有上限,聊久必卡" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 判据:打开页面必须直接停在最新消息 ───────────────────────────
|
||||
//
|
||||
// 用户报「打开 app 和 webui,没有停在最新消息处,还要反复滑动」。
|
||||
//
|
||||
// 真机实测(200 条 / 3500 DOM 节点):
|
||||
// behavior:"smooth" → 立即 scrollTop=0,300ms 后只到 6894(上限 22838)
|
||||
// ⇒ 既慢又**没到位**
|
||||
// scrollTop=scrollHeight → 立即 22838,一次到位
|
||||
//
|
||||
// 原因:紧邻的 innerHTML 全量重建让 smooth 动画的起点算在**旧**布局上。
|
||||
const STICK = `(async () => {
|
||||
const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")};
|
||||
state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}];
|
||||
state.currentConn = state.connections[0];
|
||||
_chatLayoutBuilt = false;
|
||||
buildChatLayout();
|
||||
const body = '要点正文。'.repeat(30);
|
||||
state.messages = Array.from({length:200}, (_,i)=>({role:i%2?'assistant':'user', content: body+' #'+i}));
|
||||
document.querySelector('#view-chat')?.classList.add('active');
|
||||
const el = document.getElementById('chat-msgs');
|
||||
el.scrollTop = 0; // 模拟「刚打开,在顶部」
|
||||
await new Promise(r=>setTimeout(r,60));
|
||||
state.messages[0].content += 'q'; // 触发 signature 变化
|
||||
renderChat();
|
||||
const immediate = Math.round(el.scrollTop);
|
||||
await new Promise(r=>setTimeout(r,300));
|
||||
const max = Math.round(el.scrollHeight - el.clientHeight);
|
||||
return { immediate, after: Math.round(el.scrollTop), max, stick: state.chatStick };
|
||||
})()`;
|
||||
|
||||
const st = await evalInGui(STICK);
|
||||
if (st && st.error) {
|
||||
check("能测到滚动位置", false, st.error);
|
||||
} else {
|
||||
check(
|
||||
"打开即停在最新消息(立即到位)",
|
||||
st.immediate >= st.max - 5,
|
||||
`立即 scrollTop=${st.immediate},上限=${st.max}` +
|
||||
` ⇒ smooth 动画在 innerHTML 重建后算错起点,用户得手动滑到底`,
|
||||
);
|
||||
check(
|
||||
"300ms 后仍在底部(不被后续渲染带偏)",
|
||||
st.after >= st.max - 5,
|
||||
`300ms 后 scrollTop=${st.after},上限=${st.max}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 判据:增量渲染不能丢消息 ─────────────────────────────────────
|
||||
//
|
||||
// ★ 快了但丢消息就白搭 —— 这条比性能判据更重要。
|
||||
//
|
||||
// 覆盖增删改四种路径:尾部追加(发消息/工具轮)、头部前插(loadOlderChat)、
|
||||
// 中间修改(内容更新)、尾部删除(去重/截断)。
|
||||
const CORRECT = `(async () => {
|
||||
const K = ${JSON.stringify(process.env.GUI_API_KEY || "probe-key")};
|
||||
state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}];
|
||||
state.currentConn = state.connections[0];
|
||||
_chatLayoutBuilt = false;
|
||||
buildChatLayout();
|
||||
const el = document.getElementById('chat-msgs');
|
||||
const body = '要点正文。'.repeat(20);
|
||||
const out = [];
|
||||
const chk = (label) => out.push({
|
||||
label, state: state.messages.length, dom: el.childElementCount,
|
||||
ok: el.childElementCount === state.messages.length,
|
||||
});
|
||||
state.messages = Array.from({length:50},(_,i)=>({role:i%2?'assistant':'user', content: body+' #'+i}));
|
||||
renderChat(); chk('初始 50 条');
|
||||
for (let k=0;k<3;k++) state.messages.push({role:'user', content: body+' new'+k});
|
||||
renderChat(); chk('尾部追加 3 条');
|
||||
for (let k=0;k<5;k++) state.messages.unshift({role:'user', content: body+' old'+k});
|
||||
renderChat(); chk('头部前插 5 条');
|
||||
state.messages[10].content = body + ' CHANGED';
|
||||
renderChat(); chk('修改中间一条');
|
||||
state.messages.splice(-2);
|
||||
renderChat(); chk('删除尾部 2 条');
|
||||
return out;
|
||||
})()`;
|
||||
|
||||
const corr = await evalInGui(CORRECT);
|
||||
if (!Array.isArray(corr) || corr.length === 0) {
|
||||
check("能测到增删改一致性", false, String(corr));
|
||||
} else {
|
||||
check("能测到增删改一致性", true);
|
||||
for (const r of corr) {
|
||||
check(
|
||||
`增量不丢消息:${r.label}`,
|
||||
r.ok,
|
||||
`state 有 ${r.state} 条,DOM 只有 ${r.dom} 个子节点`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@ -810,6 +810,11 @@ const devOs = require("os");
|
||||
let deviceBridge = null; // 当前活动设备桥
|
||||
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
||||
let deviceBridgeAddr = ""; // 设备桥网关地址
|
||||
|
||||
// 设备桥的**登记**状态(与"连接已建立"是两件事)。
|
||||
// 连接成功但 bind 被拒时设备是失联的,只看 connected 会给出假阳性。
|
||||
let deviceBridgeBound = false;
|
||||
let deviceBridgeBindError = "";
|
||||
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
||||
let speechAccum = null;
|
||||
|
||||
@ -1252,9 +1257,33 @@ function onDeviceMsg(msg) {
|
||||
} catch (e) {
|
||||
console.log("[device-bridge] exec error: " + e.message);
|
||||
}
|
||||
} else if (op === "hello_ack" || op === "bind_ack") {
|
||||
} else if (op === "bind_ack") {
|
||||
// ★ bind 结果必须判 ok —— 与鸿蒙端(DeviceBridge.ets:209)对齐。
|
||||
//
|
||||
// 原实现与 Go 客户端同病:只打一行日志、不看 ok。服务端 bind 被拒时回
|
||||
// {"ok":false,"error":"bind rejected"} 并**关闭连接**,GUI 却既不报错也
|
||||
// 不重连,设备静默失联 —— TCP/WS 通但从未登记进网关,命令永远下发不到。
|
||||
//
|
||||
// 另:成功时服务端**不含 device 字段**,原日志靠 `msg.device || deviceBridgeId`
|
||||
// 兜底才显示得像成功,掩盖了「没有真的读 ok」这件事。
|
||||
const accepted = msg.ok === true;
|
||||
deviceBridgeBound = accepted;
|
||||
if (accepted) {
|
||||
console.log("[device-bridge] bind_ok device=" + deviceBridgeId);
|
||||
} else {
|
||||
deviceBridgeBindError = String(msg.error || "bind rejected");
|
||||
console.error(
|
||||
"[device-bridge] bind rejected: " +
|
||||
deviceBridgeBindError +
|
||||
"(设备令牌不匹配或设备未授权)",
|
||||
);
|
||||
}
|
||||
try {
|
||||
rebuildTrayMenu();
|
||||
} catch (e) {}
|
||||
} else if (op === "hello_ack") {
|
||||
console.log(
|
||||
"[device-bridge] " + op + " device=" + (msg.device || deviceBridgeId),
|
||||
"[device-bridge] hello_ack device=" + (msg.device || deviceBridgeId) + " online=" + msg.online,
|
||||
);
|
||||
try {
|
||||
rebuildTrayMenu();
|
||||
@ -2080,6 +2109,8 @@ function argsSafe(cmd) {
|
||||
// url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。
|
||||
async function startDeviceBridge(cfg) {
|
||||
if (!cfg) return;
|
||||
deviceBridgeBound = false;
|
||||
deviceBridgeBindError = "";
|
||||
const url = cfg.url || cfg.gateway || "";
|
||||
const token = cfg.apiKey || cfg.token || "";
|
||||
if (!url || !token) {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
{
|
||||
"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",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron . --no-sandbox",
|
||||
"dev": "electron . --no-sandbox --dev"
|
||||
"dev": "electron . --no-sandbox --dev",
|
||||
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs",
|
||||
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.6"
|
||||
|
||||
164
cmd/gui/protocol-align.test.mjs
Normal file
164
cmd/gui/protocol-align.test.mjs
Normal file
@ -0,0 +1,164 @@
|
||||
// 协议对齐判据:在真 Electron 里验证补齐的端点真的取到数据、
|
||||
// 且三个面板渲染出**可见内容**(不是空壳)。
|
||||
//
|
||||
// ## 背景
|
||||
//
|
||||
// 2026-09-28 用户要求对齐 WebAPI 协议。核实发现 GUI 只用 22 个端点,
|
||||
// 服务端有 47 个。补齐的是**只读诊断类**:agents / network / tracker /
|
||||
// config / persona / proxy(+services)。
|
||||
//
|
||||
// ★ 刻意**不接** /login 与 /logout:那是 cookie 会话认证流程,
|
||||
// 而 GUI 走 `X-API-Key` 头(见 api())。接了反而是错的对齐。
|
||||
//
|
||||
// ★ 只加进 refreshAll、**不加** refreshDataOnly:后者每 15 秒一轮,
|
||||
// 诊断数据不必高频轮询。
|
||||
//
|
||||
// ## 为什么必须真浏览器
|
||||
//
|
||||
// 判据要验的是「面板里真的有内容」,依赖 DOM 渲染 —— node 里测不了。
|
||||
//
|
||||
// 运行:node cmd/gui/protocol-align.test.mjs
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.GUI_CDP_PORT || 9370);
|
||||
|
||||
// apiKey 从哪来:环境变量优先,否则读生产 config.db。
|
||||
//
|
||||
// ★ 默认**不能**用假 key:webui 对错误凭据返回 200 + 登录页 HTML
|
||||
// (looks_like_login_page 会识别),于是所有取数都失败 ⇒ 判据全红,
|
||||
// 看起来像「代码坏了」,实际只是认证缺失。踩过一次。
|
||||
function resolveApiKey() {
|
||||
if (process.env.GUI_API_KEY) return process.env.GUI_API_KEY;
|
||||
const db = "/home/newqqagent/config.db";
|
||||
if (!existsSync(db)) return "";
|
||||
try {
|
||||
const out = spawnSync("sqlite3", [db,
|
||||
"select value from config_webui where key='api_key';"],
|
||||
{ encoding: "utf8" });
|
||||
return (out.stdout || "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const API_KEY = resolveApiKey();
|
||||
if (!API_KEY) {
|
||||
console.log(" ✗ 拿不到 webui api_key:设 GUI_API_KEY 或确认 /home/newqqagent/config.db 可读");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
const check = (n, ok, d) => {
|
||||
if (ok) console.log(` ✓ ${n}`);
|
||||
else {
|
||||
failures++;
|
||||
console.log(` ✗ ${n}${d ? " — " + d : ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function targets() {
|
||||
try {
|
||||
return await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensure() {
|
||||
const t = await targets();
|
||||
if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return;
|
||||
const bin = join(here, "node_modules/.bin/electron");
|
||||
if (!existsSync(bin)) throw new Error("electron 未安装,先 cd cmd/gui && npm i");
|
||||
// ★ setsid:detached 只脱离进程组,脚本退出仍会带走刚起的 GUI
|
||||
// (症状:[tray] READY 打了,判据却报「找不到 GUI 页面」)。
|
||||
spawn(
|
||||
"setsid",
|
||||
["xvfb-run", "-a", "--server-args=-screen 0 1280x900x24", bin, "--no-sandbox",
|
||||
`--remote-debugging-port=${PORT}`, "."],
|
||||
{ cwd: here, detached: true, stdio: "ignore" },
|
||||
).unref();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const t2 = await targets();
|
||||
if (t2.some((x) => x.type === "page" && x.url.includes("index.html"))) return;
|
||||
}
|
||||
throw new Error(`GUI 启动超时(${PORT})`);
|
||||
}
|
||||
|
||||
async function ev(expr) {
|
||||
const tabs = await targets();
|
||||
const page = tabs.find((t) => t.type === "page" && t.url.includes("index.html"));
|
||||
if (!page) throw new Error("找不到 GUI 页面");
|
||||
const sock = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((r) => (sock.onopen = r));
|
||||
const id = Math.floor(Math.random() * 1e6);
|
||||
sock.send(JSON.stringify({
|
||||
id, method: "Runtime.evaluate",
|
||||
params: { expression: expr, awaitPromise: true, returnByValue: true },
|
||||
}));
|
||||
// ★ JSON.parse 必须包 try:CDP 也会发非 JSON 帧,裸 parse 抛在回调里
|
||||
// 既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。
|
||||
const res = await new Promise((r) => {
|
||||
sock.onmessage = (e) => {
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (m && m.id === id) r(m);
|
||||
};
|
||||
});
|
||||
sock.close();
|
||||
const v = res.result?.result?.value;
|
||||
if (v === undefined) throw new Error(res.result?.exceptionDetails?.text || "求值失败");
|
||||
return v;
|
||||
}
|
||||
|
||||
await ensure();
|
||||
|
||||
const r = await ev(`(async () => {
|
||||
const K = ${JSON.stringify(API_KEY)};
|
||||
state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}];
|
||||
state.currentConn = state.connections[0];
|
||||
// ★ 走应用**自己的**入口 refreshAll(),不直接调 refreshDiagData()。
|
||||
// 早先直接调 refreshDiagData ⇒ 判据绕过了应用里的调用点 ⇒
|
||||
// 把「await refreshDiagData()」注释掉,判据仍全绿(实测踩过)。
|
||||
// 变异测试的意义就在于抓这种「判据没测到真路径」。
|
||||
await refreshAll();
|
||||
renderOverview(); renderPersona(); renderProxy();
|
||||
const txt = (id) => { const e = document.getElementById(id); return e ? e.innerText : ''; };
|
||||
return {
|
||||
slots: {
|
||||
agents: !!(state.agents && Array.isArray(state.agents.agents)),
|
||||
network: !!(state.network && Array.isArray(state.network.endpoints)),
|
||||
tracker: !!(state.tracker && typeof state.tracker.changesets === 'number'),
|
||||
runConfig: !!(state.runConfig && !!state.runConfig.daemon),
|
||||
persona: !!(state.persona && state.persona.current_prompt),
|
||||
proxy: !!(state.proxy && typeof state.proxy.total === 'number'),
|
||||
proxyServices: !!(state.proxyServices && Array.isArray(state.proxyServices.services)),
|
||||
},
|
||||
overviewHasDiag: txt('view-overview').includes('诊断') || txt('view-overview').includes('Diagnostic'),
|
||||
personaText: txt('view-persona').slice(0, 120),
|
||||
proxyText: txt('view-proxy').replace(/\\n+/g, ' | ').slice(0, 200),
|
||||
};
|
||||
})()`);
|
||||
|
||||
for (const [k, v] of Object.entries(r.slots)) {
|
||||
check(`端点取到数据:${k}`, v, "state 槽为空 ⇒ 该端点没接上或没取到");
|
||||
}
|
||||
check("总览出现诊断卡片", r.overviewHasDiag);
|
||||
check(
|
||||
"人设面板显示提示词",
|
||||
r.personaText.includes("你是") || r.personaText.length > 40,
|
||||
`实际文本:${r.personaText.slice(0, 60)}`,
|
||||
);
|
||||
check("反代面板列出服务", r.proxyText.includes("→"), `实际文本:${r.proxyText.slice(0, 80)}`);
|
||||
|
||||
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@ -530,11 +530,28 @@ async function api(p, o) {
|
||||
window._haReloginLock = true;
|
||||
try {
|
||||
await syncConnAuth();
|
||||
await new Promise((res2) => setTimeout(res2, 800));
|
||||
// ★ 原来固定 setTimeout(…, 800):认证过期时**每个**请求都白等 0.8s,
|
||||
// 并发几个请求就叠加成明显的「卡」。syncConnAuth 本身是 await 的,
|
||||
// 它返回即代表凭据已就绪,无需再额外空等。
|
||||
// 留 30ms 让 setAuth 的 cookie 落盘,避免极端情况下仍用旧凭据。
|
||||
await new Promise((res2) => setTimeout(res2, 30));
|
||||
} catch (e2) {}
|
||||
window._haReloginLock = false;
|
||||
// 重试一次
|
||||
return api(p, o);
|
||||
// ★★ 锁必须在**递归返回之后**才释放。
|
||||
//
|
||||
// 原写法在递归前 `window._haReloginLock = false` ⇒ 每层递归进来看到的
|
||||
// 都是「没人在重登」⇒ 无限自我递归。真机实测(Electron + CDP):
|
||||
// fetch 被调 13 次、重登 12 次才被上限截断。
|
||||
//
|
||||
// 而且这与「800ms → 30ms」那次优化直接相关:原来只是每 800ms 慢速空转,
|
||||
// 改完变成每 30ms 快速烧 CPU 并反复打服务端 —— **优化放大了这个 bug**。
|
||||
//
|
||||
// try/finally 保证:递归正常返回后锁才释放(下次真实 401 仍可重登),
|
||||
// 递归抛错时也一定释放(不会把后续所有请求都锁死成直接 401)。
|
||||
try {
|
||||
return await api(p, o);
|
||||
} finally {
|
||||
window._haReloginLock = false;
|
||||
}
|
||||
}
|
||||
if (r.status === 408 || r.status === 504) {
|
||||
// 网关超时:API 层直接抛错,调用方可选择提示用户重试或自动降级
|
||||
@ -562,10 +579,18 @@ async function api(p, o) {
|
||||
window._haReloginLock = true;
|
||||
try {
|
||||
await syncConnAuth();
|
||||
await new Promise((res2) => setTimeout(res2, 800));
|
||||
// ★ 原来固定 setTimeout(…, 800):认证过期时**每个**请求都白等 0.8s,
|
||||
// 并发几个请求就叠加成明显的「卡」。syncConnAuth 本身是 await 的,
|
||||
// 它返回即代表凭据已就绪,无需再额外空等。
|
||||
// 留 30ms 让 setAuth 的 cookie 落盘,避免极端情况下仍用旧凭据。
|
||||
await new Promise((res2) => setTimeout(res2, 30));
|
||||
} catch (e2) {}
|
||||
window._haReloginLock = false;
|
||||
return api(p, o);
|
||||
// ★★ 同上:锁在递归返回后才释放(递归前清锁 = 无限重试)
|
||||
try {
|
||||
return await api(p, o);
|
||||
} finally {
|
||||
window._haReloginLock = false;
|
||||
}
|
||||
}
|
||||
// 非 2xx 状态码:解包 server error + 以 ApiError 抛出,调用方按 status 分支处理
|
||||
if (!r.ok) {
|
||||
@ -593,6 +618,31 @@ async function api(p, o) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// 从服务端发现设备网关地址(自动链接的权威来源)。
|
||||
//
|
||||
// 失败不报错:老版本 HomeAgent 没有这个端点,回退到本地推导即可
|
||||
// (见 renderDeviceChannel 里的 state.discoveredGateway || 旧口径)。
|
||||
//
|
||||
// 优先 url_portal(门户同源形态):GUI 主进程连 WS 走系统解析器,
|
||||
// devices.localhost 这类子域在系统解析器下通常解析不到 —— *.localhost
|
||||
// 是浏览器内置特例(RFC 6761),不适用于普通进程。实测确认。
|
||||
async function loadDiscoveredGateway() {
|
||||
if (!state.currentConn || state.currentConn.type !== "webui") {
|
||||
state.discoveredGateway = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var d = await api("/device/gateway");
|
||||
state.discoveredGateway =
|
||||
(d && d.available && (d.url_portal || d.url)) || "";
|
||||
if (state.discoveredGateway) {
|
||||
console.log("[device-bridge] discovered gateway: " + state.discoveredGateway);
|
||||
}
|
||||
} catch (e) {
|
||||
state.discoveredGateway = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Navigation =====
|
||||
function switchView(n) {
|
||||
document.querySelectorAll(".view").forEach((e) => {
|
||||
@ -696,6 +746,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 || {};
|
||||
@ -719,6 +773,7 @@ async function refreshDataOnly() {
|
||||
state.currentConn.type === "webui" &&
|
||||
state.currentConn.url
|
||||
) {
|
||||
await loadDiscoveredGateway();
|
||||
var d = await api("/device/online");
|
||||
state.devices = (d && d.devices) || [];
|
||||
} else {
|
||||
@ -775,6 +830,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 || {};
|
||||
@ -799,6 +858,7 @@ async function refreshAll() {
|
||||
state.currentConn.type === "webui" &&
|
||||
state.currentConn.url
|
||||
) {
|
||||
await loadDiscoveredGateway();
|
||||
var d = await api("/device/online");
|
||||
state.devices = (d && d.devices) || [];
|
||||
} else {
|
||||
@ -908,10 +968,53 @@ async function refreshAll() {
|
||||
} catch (e) {
|
||||
console.error("renderDevices", e);
|
||||
}
|
||||
// ★ 协议对齐补齐的 6 个端点(2026-09-28)。
|
||||
//
|
||||
// GUI 原来只用 22 个端点,服务端有 47 个。缺的这些**都是只读诊断类**,
|
||||
// 接上它们的价值是"不用切到别的工具就能看到"。
|
||||
//
|
||||
// ★ 只加进 refreshAll,**不加** refreshDataOnly:后者每 15 秒一轮,
|
||||
// 6 个端点虽只 +3ms(实测),但没必要为诊断数据高频轮询。
|
||||
await refreshDiagData();
|
||||
try {
|
||||
renderOverview();
|
||||
renderPersona();
|
||||
renderProxy();
|
||||
} catch (e) {
|
||||
console.error("render diag panels", e);
|
||||
}
|
||||
applyI18n();
|
||||
applyCardTilt();
|
||||
}
|
||||
|
||||
// refreshDiagData 拉取协议对齐补齐的诊断类端点。
|
||||
//
|
||||
// 刻意每个都独立 try/catch:某个端点挂了(如老版本内核没有该路由),
|
||||
// 不该拖垮整轮刷新 —— 与既有 6 个 loader 的写法一致。
|
||||
async function refreshDiagData() {
|
||||
try {
|
||||
state.agents = await api("/agents");
|
||||
} catch (e) {}
|
||||
try {
|
||||
state.network = await api("/network");
|
||||
} catch (e) {}
|
||||
try {
|
||||
state.tracker = await api("/tracker");
|
||||
} catch (e) {}
|
||||
try {
|
||||
state.runConfig = await api("/config");
|
||||
} catch (e) {}
|
||||
try {
|
||||
state.persona = await api("/persona");
|
||||
} catch (e) {}
|
||||
try {
|
||||
state.proxy = await api("/proxy");
|
||||
} catch (e) {}
|
||||
try {
|
||||
state.proxyServices = await api("/proxy/services");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ===== 动效补齐:卡片 3D tilt + 光标光斑(事件委托,动态渲染后自动生效) =====
|
||||
function applyCardTilt() {
|
||||
if (!window.matchMedia || window.matchMedia("(hover: none)").matches) return;
|
||||
@ -986,9 +1089,9 @@ function startUptimeTicker() {
|
||||
// 丢失的事件(尤其是非 GUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。
|
||||
// syncChatFromHistory 增量同步,不重建已有消息 DOM,无闪烁。
|
||||
if (_chatSyncTick) clearInterval(_chatSyncTick);
|
||||
_chatSyncTick = setInterval(function () {
|
||||
_chatSyncTick = setInterval(() => {
|
||||
if (state.currentConn && state.currentConn.type !== "cli") {
|
||||
syncChatFromHistory().catch(function () {});
|
||||
syncChatFromHistory().catch(() => {});
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
@ -1004,6 +1107,247 @@ 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((s, i) => {
|
||||
var items = (state.stageTrail || []).filter((t) => (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((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((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 +1362,25 @@ function renderOverview() {
|
||||
"uptime",
|
||||
) +
|
||||
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
||||
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
|
||||
statCard(
|
||||
__("版本", "Version"),
|
||||
(() => {
|
||||
// 构建身份取自 /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 +1444,7 @@ function renderOverview() {
|
||||
"</span></div>" +
|
||||
"</div></div>";
|
||||
}
|
||||
html += renderRuntimePanel();
|
||||
html +=
|
||||
'<div class="card"><h2>' +
|
||||
__("运行时", "Runtime") +
|
||||
@ -1094,9 +1457,148 @@ function renderOverview() {
|
||||
) +
|
||||
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
||||
"</div></div>";
|
||||
html += renderDiagPanel();
|
||||
html += renderLegalCard();
|
||||
document.getElementById("view-overview").innerHTML = html;
|
||||
}
|
||||
|
||||
// renderDiagPanel 协议对齐补齐的诊断卡片(agents / network / tracker / config)。
|
||||
//
|
||||
// 全部走 `(x && x.y)` 的安全取值:任一端点没取到(老内核无该路由、
|
||||
// 连接断开)都只显示 "-",不抛错 —— 与既有卡片一致。
|
||||
function renderDiagPanel() {
|
||||
var a = state.agents || {};
|
||||
var list = a.agents || [];
|
||||
var main0 = list[0] || {};
|
||||
var nw = state.network || {};
|
||||
var tk = state.tracker || {};
|
||||
var cfg = state.runConfig || {};
|
||||
var llmOk = main0.network && main0.network.llm_api_reachable;
|
||||
|
||||
return (
|
||||
'<div class="card"><h2>' +
|
||||
__("诊断", "Diagnostics") +
|
||||
'</h2><div class="grid-4">' +
|
||||
statCard(
|
||||
__("Agent 健康", "Agent health"),
|
||||
main0.health === 1
|
||||
? __("正常", "healthy")
|
||||
: main0.health === 0
|
||||
? "-"
|
||||
: __("异常", "unhealthy"),
|
||||
main0.health === 1 ? "running" : "",
|
||||
) +
|
||||
statCard(
|
||||
__("LLM 可达", "LLM reachable"),
|
||||
llmOk === true
|
||||
? __("是", "yes")
|
||||
: llmOk === false
|
||||
? __("否", "no")
|
||||
: "-",
|
||||
llmOk === false ? "denied" : "",
|
||||
) +
|
||||
statCard(
|
||||
__("网络端点", "Endpoints"),
|
||||
Array.isArray(nw.endpoints) ? String(nw.endpoints.length) : "-",
|
||||
"",
|
||||
) +
|
||||
statCard(
|
||||
__("文件变更集", "Changesets"),
|
||||
tk.changesets !== undefined ? String(tk.changesets) : "-",
|
||||
tk.has_changes ? "warn" : "",
|
||||
) +
|
||||
"</div>" +
|
||||
'<div class="grid-2" style="margin-top:10px">' +
|
||||
'<div><b>' +
|
||||
__("数据目录", "Data dir") +
|
||||
"</b>: " +
|
||||
escHtml(cfg?.daemon?.data_dir || "-") +
|
||||
"</div>" +
|
||||
'<div><b>' +
|
||||
__("心跳间隔", "Heartbeat") +
|
||||
"</b>: " +
|
||||
(cfg?.daemon?.heartbeat_interval
|
||||
? Math.round(cfg.daemon.heartbeat_interval / 1e6) + " ms"
|
||||
: "-") +
|
||||
"</div>" +
|
||||
"</div></div>"
|
||||
);
|
||||
}
|
||||
|
||||
// renderPersona 人设面板:展示 /persona 返回的人格设定。
|
||||
//
|
||||
// 只读展示。当前先不做编辑 —— 编辑要处理保存、失败回滚、并发覆盖,
|
||||
// 与"协议对齐"是两件事,混在一起容易做半。
|
||||
function renderPersona() {
|
||||
var el = document.getElementById("view-persona");
|
||||
if (!el) return;
|
||||
var p = state.persona || {};
|
||||
var prompt = p.current_prompt || "";
|
||||
// ★ 实测结构是 {current_prompt, file_override, initialized},
|
||||
// 不是键值对 —— 我第一版按 map 遍历,结果只会显示三个字段名。
|
||||
el.innerHTML =
|
||||
'<div class="card"><h2>' +
|
||||
__("人设", "Persona") +
|
||||
'</h2><div class="grid-3">' +
|
||||
statCard(
|
||||
__("已初始化", "Initialized"),
|
||||
p.initialized ? __("是", "yes") : __("否", "no"),
|
||||
p.initialized ? "running" : "",
|
||||
) +
|
||||
statCard(
|
||||
__("文件覆盖", "File override"),
|
||||
p.file_override ? __("开", "on") : __("关", "off"),
|
||||
"",
|
||||
) +
|
||||
statCard(__("提示词长度", "Prompt length"), String(prompt.length), "") +
|
||||
"</div>" +
|
||||
'<h2 style="margin-top:12px">' +
|
||||
__("当前提示词", "Current prompt") +
|
||||
'</h2><pre style="white-space:pre-wrap;word-break:break-word">' +
|
||||
escHtml(prompt || __("(空)", "(empty)")) +
|
||||
"</pre></div>";
|
||||
}
|
||||
|
||||
// renderProxy 反代面板:展示 /proxy 的挂载与模式。
|
||||
function renderProxy() {
|
||||
var el = document.getElementById("view-proxy");
|
||||
if (!el) return;
|
||||
var px = state.proxy || {};
|
||||
var svc = state.proxyServices || null;
|
||||
var head =
|
||||
'<div class="card"><h2>' +
|
||||
__("反代", "Reverse Proxy") +
|
||||
'</h2><div class="grid-4">' +
|
||||
statCard(__("基础域名", "Base domain"), px.base_domain || "-", "") +
|
||||
statCard(__("模式", "Mode"), px.mode || "-", "") +
|
||||
statCard(__("总数", "Total"), px.total !== undefined ? String(px.total) : "-", "") +
|
||||
statCard(__("手动", "Manual"), px.manual !== undefined ? String(px.manual) : "-", "") +
|
||||
"</div>";
|
||||
var body = "";
|
||||
var list = svc && Array.isArray(svc.services) ? svc.services : null; // 实测:services 是数组
|
||||
if (list) {
|
||||
body =
|
||||
'<div class="card"><h2>' +
|
||||
__("服务", "Services") +
|
||||
"</h2><pre style=\"white-space:pre-wrap;word-break:break-word\">" +
|
||||
escHtml(
|
||||
list
|
||||
.map((s) => {
|
||||
// ★ 实测字段:{name, host, path, url, target, ok, auth, websocket}
|
||||
return (
|
||||
(s.ok === false ? "✗ " : "✓ ") +
|
||||
(s.name || "?") +
|
||||
" → " +
|
||||
(s.target || s.url || "?")
|
||||
);
|
||||
})
|
||||
.join("\n"),
|
||||
) +
|
||||
"</pre></div>";
|
||||
}
|
||||
el.innerHTML = head + body;
|
||||
}
|
||||
|
||||
// ===== Chat =====
|
||||
var _chatLayoutBuilt = false;
|
||||
|
||||
@ -1422,6 +1924,12 @@ function renderChat() {
|
||||
msgs.forEach((m, i) => {
|
||||
var role = m.role || "user";
|
||||
var c = m.content || "";
|
||||
// ★ 稳定标识:role + 序号 + 内容长度 + 首尾片段。
|
||||
// 序号参与是为了区分「连续两条同 role 同长度」的消息;
|
||||
// 内容片段参与是为了让「同一条消息内容变了」能被识别出来。
|
||||
// 增量渲染靠它定位可复用的 DOM 节点(**不能靠下标** ——
|
||||
// loadOlderChat 会 unshift 前插消息,下标整体位移)。
|
||||
var mkey = role + ":" + i + ":" + c.length + ":" + c.slice(0, 24) + ":" + c.slice(-24);
|
||||
if (role === "assistant") {
|
||||
if (typeof marked === "undefined") {
|
||||
c = "<pre>" + escHtml(c) + "</pre>";
|
||||
@ -1543,12 +2051,12 @@ function renderChat() {
|
||||
}
|
||||
if (role === "system") {
|
||||
html +=
|
||||
'<div class="msg msg-system"><div class="msg-bubble">' +
|
||||
'<div class="msg msg-system" data-msgkey="' + escHtml(mkey) + '"><div class="msg-bubble">' +
|
||||
(c || "") +
|
||||
"</div></div>";
|
||||
} else if (isChan) {
|
||||
html +=
|
||||
'<div class="msg msg-channel">' +
|
||||
'<div class="msg msg-channel" data-msgkey="' + escHtml(mkey) + '">' +
|
||||
'<div class="msg-avatar chan-avatar" style="background:' +
|
||||
chanColor(m.source) +
|
||||
'">' +
|
||||
@ -1570,6 +2078,8 @@ function renderChat() {
|
||||
html +=
|
||||
'<div class="msg msg-' +
|
||||
role +
|
||||
'" data-msgkey="' +
|
||||
escHtml(mkey) +
|
||||
'">' +
|
||||
'<div class="msg-avatar">' +
|
||||
(role === "user" ? userAvatar : aiAvatar) +
|
||||
@ -1587,7 +2097,7 @@ function renderChat() {
|
||||
__("小宅", "Agent") +
|
||||
'">';
|
||||
html +=
|
||||
'<div class="msg msg-assistant"><div class="msg-avatar">' +
|
||||
'<div class="msg msg-assistant" data-msgkey="__loading__"><div class="msg-avatar">' +
|
||||
aiAvatar2 +
|
||||
'</div><div class="msg-content"><div class="msg-bubble">' +
|
||||
'<span class="live-spinner"></span>' +
|
||||
@ -1596,13 +2106,21 @@ function renderChat() {
|
||||
: "") +
|
||||
"</div></div></div>";
|
||||
}
|
||||
msgsEl.innerHTML = html;
|
||||
// ★ 增量渲染:能复用就复用,别整棵重建(见 applyIncrementalChatRender)。
|
||||
applyIncrementalChatRender(msgsEl, html);
|
||||
if (state.chatStick !== false) {
|
||||
try {
|
||||
msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" });
|
||||
} catch (e) {
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
// ★ 必须用 scrollTop 立即到位,**不能**用 scrollTo({behavior:"smooth"})。
|
||||
//
|
||||
// 真机实测(Xvfb + Electron + CDP,200 条消息 / 3500 DOM 节点):
|
||||
// smooth:立即 scrollTop=0,300ms 后只到 6894,而上限是 22838
|
||||
// ⇒ 既慢又**没到位**,用户看到的就是「打开不在最新消息、
|
||||
// 还要反复滑动」
|
||||
// scrollTop=scrollHeight:立即 22838,一次到位
|
||||
//
|
||||
// 原因:上面刚做完 DOM 变更,smooth 动画的起点算的是**旧**布局;
|
||||
// 动画启动前布局又变了,于是滚到错误位置。重建后本就不该有动画
|
||||
// ——用户要的是「立刻看到最新消息」。
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
updateChatBadge();
|
||||
if (window.homeagent && window.homeagent.log) {
|
||||
@ -1657,6 +2175,99 @@ function updateChatBadge() {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
|
||||
// applyIncrementalChatRender 按 data-msgkey 复用可用的 DOM 节点。
|
||||
//
|
||||
// 为什么需要:旧实现无条件整棵 innerHTML 重建 —— 200 条消息 = 3500 个 DOM
|
||||
// 节点全部销毁重建,真机实测单次 235ms;流式追加时每个放行的 chunk 都走这条路
|
||||
// (200 条时每 chunk 6.6ms)⇒ 聊得越久越卡,用户「卡得没有用的欲望」。
|
||||
//
|
||||
// 策略(从便宜到贵):
|
||||
// 1) 尾部追加 —— 新 key 序列是旧序列的前缀 + 新增(最常见:发消息/工具轮)
|
||||
// 2) 头部前插 —— loadOlderChat 往上翻:新序列 = 新前缀 + 旧序列
|
||||
// 3) 局部替换 —— 个别 key 变了(某条消息内容更新)
|
||||
// 4) 兜底整棵重建 —— 序列既非前缀也非后缀(删除/去重/重排)
|
||||
function applyIncrementalChatRender(msgsEl, html) {
|
||||
var want = [];
|
||||
var re = /data-msgkey="([^"]*)"/g;
|
||||
var m;
|
||||
while ((m = re.exec(html)) !== null) want.push(m[1]);
|
||||
|
||||
var kids = Array.prototype.slice.call(msgsEl.children);
|
||||
var have = kids.map((n) => n.getAttribute("data-msgkey") || "");
|
||||
|
||||
// 没带 key(老结构 / 空列表)⇒ 只能整棵重建
|
||||
if (want.length === 0 || have.length === 0) {
|
||||
msgsEl.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
// 情况 2:头部前插。新序列 = 新前缀 + 旧序列
|
||||
if (want.length > have.length) {
|
||||
var off = want.length - have.length;
|
||||
var isPrepend = true;
|
||||
for (var p = 0; p < have.length; p++) {
|
||||
if (want[p + off] !== have[p]) { isPrepend = false; break; }
|
||||
}
|
||||
if (isPrepend) {
|
||||
var prevH = msgsEl.scrollHeight;
|
||||
var prevTop = msgsEl.scrollTop;
|
||||
for (var q = off - 1; q >= 0; q--) {
|
||||
var tn = parseChatNode(html, want[q]);
|
||||
if (tn) msgsEl.insertBefore(tn, msgsEl.firstChild);
|
||||
}
|
||||
// 保持滚动锚点:内容变高后把视口往下挪相同高度
|
||||
msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 1:尾部追加。新序列是旧序列的前缀
|
||||
var isAppend = have.length <= want.length;
|
||||
for (var a = 0; a < have.length && isAppend; a++) {
|
||||
if (want[a] !== have[a]) isAppend = false;
|
||||
}
|
||||
if (isAppend) {
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var b = have.length; b < want.length; b++) {
|
||||
var node = parseChatNode(html, want[b]);
|
||||
if (node) frag.appendChild(node);
|
||||
}
|
||||
msgsEl.appendChild(frag);
|
||||
return;
|
||||
}
|
||||
|
||||
// 情况 3:局部替换
|
||||
for (var c = 0; c < want.length && c < kids.length; c++) {
|
||||
if (have[c] === want[c]) continue;
|
||||
var fresh = parseChatNode(html, want[c]);
|
||||
if (fresh && kids[c] && kids[c].parentNode === msgsEl) {
|
||||
msgsEl.replaceChild(fresh, kids[c]);
|
||||
}
|
||||
}
|
||||
// 尾部有删除 ⇒ 截断
|
||||
while (msgsEl.childElementCount > want.length) {
|
||||
msgsEl.removeChild(msgsEl.lastElementChild);
|
||||
}
|
||||
if (msgsEl.childElementCount === want.length) return;
|
||||
// 兜底
|
||||
msgsEl.innerHTML = html;
|
||||
}
|
||||
|
||||
// parseChatNode 从整段 html 里切出 key 对应的那一个顶层消息节点。
|
||||
//
|
||||
// 用一个容器承载并逐个子节点比对 data-msgkey —— 不引 DOMParser 重新解析整棵
|
||||
// html(那会抵消掉增量渲染省下的开销)。
|
||||
function parseChatNode(html, key) {
|
||||
var holder = document.createElement("div");
|
||||
holder.innerHTML = html;
|
||||
for (var i = 0; i < holder.children.length; i++) {
|
||||
if ((holder.children[i].getAttribute("data-msgkey") || "") === key) {
|
||||
return holder.children[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rerenderChat() {
|
||||
guardedRenderChat();
|
||||
renderChatStarmap();
|
||||
@ -2432,7 +3043,7 @@ async function loadOlderChat() {
|
||||
// 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底(跨渠道消息补偿)。
|
||||
function syncChatFromHistory() {
|
||||
return api("/chat/history?limit=" + CHAT_PAGE_SIZE)
|
||||
.then(function (data) {
|
||||
.then((data) => {
|
||||
if (!data || !data.messages || data.messages.length === 0) return;
|
||||
var serverMsgs = data.messages;
|
||||
var localMsgs = state.messages;
|
||||
@ -2481,7 +3092,7 @@ function syncChatFromHistory() {
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function loadTerminals() {
|
||||
@ -4989,6 +5600,16 @@ async function connectFetchSSE(url) {
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
// ★ 建连成功 ⇒ 退避计数清零。
|
||||
//
|
||||
// 为什么要在这里清:_sseRetryAttempts 只在下面 catch(**建立**连接失败)
|
||||
// 里自增,而 pump() 中途断流后的重连**只读它算延迟**。不清零的话,
|
||||
// 只要历史上累计过 5 次,之后每次断连都固定等 32s —— 哪怕这次刚成功
|
||||
// 连上、说明服务端和网络都好好的。
|
||||
//
|
||||
// 这正是「消息流不稳 / 看着卡」的一个共因:滞后、卡顿、闪断不是四个
|
||||
// 独立问题,而是同一条退避链在空等。
|
||||
state._sseRetryAttempts = 0;
|
||||
var reader = resp.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = "";
|
||||
@ -5211,17 +5832,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(() => {
|
||||
state.pipelinePhase = "";
|
||||
if (state.currentView === "overview") renderOverview();
|
||||
}, 2500);
|
||||
if (state.currentView === "overview") renderOverview();
|
||||
}
|
||||
var badge = document.getElementById("chat-stage");
|
||||
if (badge) {
|
||||
@ -5247,10 +5899,13 @@ async function connectFetchSSE(url) {
|
||||
}
|
||||
}
|
||||
// 断连后先增量同步历史(补偿断连窗口期丢失的事件),再重连
|
||||
syncChatFromHistory().catch(function () {});
|
||||
syncChatFromHistory().catch(() => {});
|
||||
// ★ 此处也清零:本次连接曾成功建立(上面已清),断流是运行期事件,
|
||||
// 不该把「建连失败」的累计次数带进下一次退避。
|
||||
state._sseRetryAttempts = 0;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connectSSE();
|
||||
}, Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000));
|
||||
}, Math.min(1000 * 2 ** Math.min((state._sseRetryAttempts || 0), 5), 32000));
|
||||
}
|
||||
pump();
|
||||
} catch (e) {
|
||||
@ -5258,7 +5913,7 @@ async function connectFetchSSE(url) {
|
||||
state._sseRetryAttempts = attempts;
|
||||
setTimeout(() => {
|
||||
connectSSE();
|
||||
}, Math.min(1000 * Math.pow(2, Math.min(attempts - 1, 5)), 60000));
|
||||
}, Math.min(1000 * 2 ** Math.min(attempts - 1, 5), 60000));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5403,12 +6058,21 @@ function renderDevices() {
|
||||
}
|
||||
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
||||
var dbc = state.dbConfig || {};
|
||||
var webuiUrl = "";
|
||||
// 网关地址优先用**服务端发现的权威值**(state.discoveredGateway),
|
||||
// 其次才是用户手填 / 本地推导。
|
||||
//
|
||||
// 为什么不能继续用「门户 URL 同 host 拼 /api/v1/device/ws」:
|
||||
// 网关改造为子域反代后位于 devices.<基域名>,而**基域名与子域标签都是
|
||||
// 服务端配置**,客户端无从得知。硬拼的结果是连到门户自己的路由上。
|
||||
// 服务端 /api/v1/device/gateway 是唯一不会漂移的来源。
|
||||
var webuiUrl = state.discoveredGateway || "";
|
||||
if (
|
||||
!webuiUrl &&
|
||||
state.currentConn &&
|
||||
state.currentConn.type === "webui" &&
|
||||
state.currentConn.url
|
||||
) {
|
||||
// 回退:老部署(无发现端点)仍按旧口径推导,保持向后兼容。
|
||||
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
||||
}
|
||||
var curGateway = dbc.gateway || webuiUrl || "";
|
||||
@ -5553,8 +6217,8 @@ function renderDevices() {
|
||||
html +=
|
||||
'<p style="color:var(--text-muted)">' +
|
||||
__(
|
||||
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
||||
"No devices yet. Devices connect via WebSocket (default 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
||||
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认经 HomeAgent 反代到 devices.<基域名>,或直连 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
||||
"No devices yet. Devices connect via WebSocket (proxied by HomeAgent at devices.<base-domain>, or directly 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
||||
) +
|
||||
"</p>";
|
||||
} else {
|
||||
|
||||
@ -208,6 +208,29 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="rail-spacer"></div>
|
||||
<button
|
||||
class="rail-btn"
|
||||
id="rail-persona"
|
||||
onclick="switchView('persona')"
|
||||
title="人设"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21c0-4 3.6-6 8-6s8 2 8 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rail-btn"
|
||||
id="rail-proxy"
|
||||
onclick="switchView('proxy')"
|
||||
title="反代"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<path d="M4 8h16M4 16h16" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
<circle cx="16" cy="16" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rail-btn"
|
||||
id="rail-settings"
|
||||
@ -307,6 +330,8 @@
|
||||
<div id="view-kernel" class="view"></div>
|
||||
<div id="view-devices" class="view"></div>
|
||||
<div id="view-settings" class="view"></div>
|
||||
<div id="view-persona" class="view"></div>
|
||||
<div id="view-proxy" class="view"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
195
cmd/gui/retry-guard.test.mjs
Normal file
195
cmd/gui/retry-guard.test.mjs
Normal file
@ -0,0 +1,195 @@
|
||||
// 401 恢复路径的**行为**判据 —— 抓「无限重试」这个真缺陷。
|
||||
//
|
||||
// ## 要判的是什么
|
||||
//
|
||||
// 2026-09-28 真机实测(Xvfb + Electron + CDP)时发现:
|
||||
//
|
||||
// api() 的 401 分支:每次重试前都把 _haReloginLock 设回 false
|
||||
// ⇒ 那把锁**永远拦不住自我递归**
|
||||
//
|
||||
// 第1次: lock=false → 重登 → lock=false → return api() ← 递归
|
||||
// 第2次: lock=false → 重登 → lock=false → return api() ← 又递归
|
||||
// …
|
||||
//
|
||||
// 锁的语义本该是「已经重登过一次,别再登」。但它在递归**之前**就被清掉了,
|
||||
// 于是每层递归看到的都是 false。
|
||||
//
|
||||
// 后果与我的另一处改动直接相关:401 分支的固定等待从 800ms 降到 30ms
|
||||
// (commit 1ef1998,那是「认证过期时每个请求白等 0.8s」的修复)。
|
||||
// 但重试**没有次数上限** ⇒ 改前是「每 800ms 慢速空转」,
|
||||
// 改后变成「每 30ms 快速烧 CPU + 反复打服务端」。**我的优化放大成了 bug。**
|
||||
//
|
||||
// 真机上表现为:探针调用 api() 后永不返回(我实测时探针直接挂死,
|
||||
// 得靠封掉第二次 fetch 才能测出耗时)。
|
||||
//
|
||||
// ## 为什么用「真实执行」而不是检查文本
|
||||
//
|
||||
// 判据在 node:vm 沙箱里跑**从源码抽出的** 401 分支代码,
|
||||
// 验证真实调用次数,而不是 grep `_haReloginLock` 出现过几次。
|
||||
//
|
||||
// 运行:node cmd/gui/retry-guard.test.mjs
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "renderer/app.js"), "utf8");
|
||||
|
||||
let failures = 0;
|
||||
const check = (name, ok, detail) => {
|
||||
if (ok) console.log(` ✓ ${name}`);
|
||||
else {
|
||||
failures++;
|
||||
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 抽取 401 分支的真实代码 ─────────────────────────────────────────
|
||||
// 取 `if (r.status === 401) { ... }` 那一整块(含嵌套的 return api())
|
||||
const start401 = src.indexOf("if (r.status === 401) {");
|
||||
if (start401 < 0) {
|
||||
console.log(" ✗ 源码里找不到 401 分支");
|
||||
process.exit(1);
|
||||
}
|
||||
// 用括号配平取整块
|
||||
let depth = 0, end401 = -1;
|
||||
for (let i = src.indexOf("{", start401); i < src.length; i++) {
|
||||
if (src[i] === "{") depth++;
|
||||
else if (src[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end401 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const block401 = src.slice(start401, end401);
|
||||
check("能抽出 401 分支整块", true);
|
||||
console.log(` · 抽取长度 ${block401.length} 字符`);
|
||||
|
||||
// ── 在沙箱里真跑 ──────────────────────────────────────────────────
|
||||
//
|
||||
// ★ 修一次假绿灯:先前我把 401 分支整块当成**独立函数体**执行,
|
||||
// 但那块以 `return api(p, o)` 结尾、外面没有调用它的上下文
|
||||
// ⇒ 我从未真正「进入」那个 if ⇒ api 递归=0 ⇒ 判据一路绿灯,
|
||||
// 压根没测到无限重试。
|
||||
//
|
||||
// 正确做法:造一个**会返回 401 的 fetch**,让真实的 api() 走完整路径。
|
||||
// 沙箱提供:state / window / fetch(恒 401) / syncConnAuth / setTimeout /
|
||||
// ApiError / __ / performance。
|
||||
let reloginCalls = 0;
|
||||
let fetchCalls = 0;
|
||||
const waitLog = [];
|
||||
const MAX_FETCH = 12; // 超过就判定为「无上限」
|
||||
|
||||
const sandbox = {
|
||||
state: { currentConn: { apiKey: "wrong" } },
|
||||
window: { _haReloginLock: false },
|
||||
__: (zh) => zh,
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(msg, status) {
|
||||
super(msg);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
// ★ setTimeout 必须**执行回调**:api() 用它做超时控制
|
||||
// (setTimeout(() => ctl.abort(), to))。先前只记录不执行 ⇒
|
||||
// AbortController 永远不被 abort、异常清理路径走不到,
|
||||
// 表现为「fetch 只调 1 次、8000ms 被当成 401 等待」。
|
||||
// ⇒ 判据测的根本不是 401 路径。
|
||||
setTimeout: (fn, ms) => {
|
||||
// 只把**小延迟**记进 waitLog:8000ms 那个是 api() 的整体超时控制,
|
||||
// 与 401 恢复无关,混进来会让判据误判。
|
||||
if (ms <= 100) waitLog.push(ms);
|
||||
if (typeof fn === "function") {
|
||||
// 不真等 8 秒:微任务里立刻执行,等价于「超时立刻触发」
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
fn();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
syncConnAuth: () => {
|
||||
reloginCalls++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
performance: { now: () => Date.now() },
|
||||
// ★ clearTimeout 也必须提供:api() 在 fetch 之后调它。
|
||||
// 缺了会抛 "clearTimeout is not defined" ⇒ 整段在 fetch 之后就断了
|
||||
// ⇒ 永远走不到 401 分支 ⇒ 判据静默地什么都没测。
|
||||
// 这就是「沙箱不完整 ⇒ 假绿灯」的又一处。
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
AbortController: class {
|
||||
constructor() {
|
||||
this.signal = {};
|
||||
}
|
||||
abort() {}
|
||||
},
|
||||
// 恒回 401 的 fetch —— 真实场景:重登后凭据仍是错的
|
||||
fetch: () => {
|
||||
fetchCalls++;
|
||||
if (fetchCalls > MAX_FETCH) {
|
||||
return Promise.reject(new Error(`NO-RETRY-LIMIT: fetch 被调 ${fetchCalls} 次`));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { get: () => null },
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
// 抽出**真实的 api 函数**(不是 401 分支),让它自己走到那个 if
|
||||
const apiStart = src.indexOf("async function api(p, o) {");
|
||||
let ad = 0, ae = -1;
|
||||
for (let i = src.indexOf("{", apiStart); i < src.length; i++) {
|
||||
if (src[i] === "{") ad++;
|
||||
else if (src[i] === "}") {
|
||||
ad--;
|
||||
if (ad === 0) {
|
||||
ae = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const apiSrc = src.slice(apiStart, ae);
|
||||
check("能抽出真实 api() 函数", apiSrc.includes("r.status === 401"), "抽到的函数里没有 401 分支");
|
||||
|
||||
await vm
|
||||
.runInNewContext(`(async () => { ${apiSrc}; return await api("/status", {}); })()`, sandbox, { timeout: 3000 })
|
||||
.catch((e) => {
|
||||
if (String(e.message).startsWith("NO-RETRY-LIMIT")) fetchCalls = MAX_FETCH + 1;
|
||||
});
|
||||
|
||||
// ── 判据 ──────────────────────────────────────────────────────────
|
||||
|
||||
// ① 无限重试:重试必须有次数上限
|
||||
console.log(` · fetch 被调 ${fetchCalls} 次,重登 ${reloginCalls} 次`);
|
||||
check(
|
||||
"401 重试有次数上限(不会无限递归)",
|
||||
fetchCalls <= 2,
|
||||
`fetch 被调 ${fetchCalls} 次 ⇒ 每轮 401 都重新登录并重试,没有上限。` +
|
||||
`改前是 800ms/轮慢速空转,改后(30ms)变成快速烧 CPU + 反复打服务端。`,
|
||||
);
|
||||
|
||||
// ② 每次重试的固定等待不能太长(那是 1ef1998 的修复,别回退)
|
||||
const waits = waitLog.filter((n) => typeof n === "number");
|
||||
if (waits.length === 0) {
|
||||
check("能观察到 401 分支的固定等待", false, "没抓到 setTimeout —— 分支形态可能变了");
|
||||
} else {
|
||||
const maxWait = Math.max(...waits);
|
||||
console.log(` · 观察到的固定等待: ${waits.join(", ")}ms`);
|
||||
check("401 恢复无长固定阻塞", maxWait <= 100, `最大等 ${maxWait}ms,超过 100ms`);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
164
cmd/gui/sse-backoff-behavior.test.mjs
Normal file
164
cmd/gui/sse-backoff-behavior.test.mjs
Normal file
@ -0,0 +1,164 @@
|
||||
// SSE 退避的**行为**判据 —— 跑真实代码,不是检查文本。
|
||||
//
|
||||
// ## 为什么还要这一条
|
||||
//
|
||||
// `sse-backoff.test.mjs` 检查源码**形状**(有没有清零、上限自不自洽、
|
||||
// 401 等待是否过长)。但形状对 ≠ 行为对:比如清零写在了
|
||||
// `reader` 取流**之后**,文本检查会通过,而实际仍在用旧计数重连。
|
||||
//
|
||||
// 这一条把 app.js 里那段退避代码**原样抽出**执行,用假时钟记录每次
|
||||
// 重连的实际等待,验证「成功建连后第一次重连等 1s,不是 32s」。
|
||||
//
|
||||
// ## 为什么要原样抽取而不是重写
|
||||
//
|
||||
// 抄一份逻辑重写,那份会和真实代码漂移 —— 而漂移本身是这里要防的东西。
|
||||
// 抽取用**同一段表达式**(从源码正则捕获),不是手抄。
|
||||
//
|
||||
// 运行:node cmd/gui/sse-backoff-behavior.test.mjs
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "renderer/app.js"), "utf8");
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail) {
|
||||
if (ok) console.log(` ✓ ${name}`);
|
||||
else {
|
||||
failures++;
|
||||
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 抽取:把「断流后重连」那段的真实表达式取出来 ────────────────────
|
||||
//
|
||||
// 目标形态:
|
||||
// state._sseRetryAttempts = 0; ← 清零
|
||||
// reconnectTimer = setTimeout(() => {...}, <退避表达式>);
|
||||
const PUMP = /state\._sseRetryAttempts\s*=\s*0\s*;\s*reconnectTimer\s*=\s*setTimeout\(\s*\(\)\s*=>\s*\{[\s\S]{0,200}?\}\s*,\s*([\s\S]{0,160}?)\)\s*;/;
|
||||
const m = PUMP.exec(src);
|
||||
if (!m) {
|
||||
check("能抽到断流重连那段(含清零与退避表达式)", false, "源码形态变了");
|
||||
process.exit(1);
|
||||
}
|
||||
const exprSrc = m[1].trim();
|
||||
check("能抽到断流重连那段(含清零与退避表达式)", true);
|
||||
console.log(` · 抽到的退避表达式: ${exprSrc}`);
|
||||
|
||||
// ── 用假状态实跑「延迟计算」───────────────────────────────────────
|
||||
//
|
||||
// ★ 早先这里 (a) 包了一层没意义的假 setTimeout,(b) 用 new Function 动态执行。
|
||||
// 两个问题都实测过:
|
||||
// (a) exprSrc 本身就是**延迟数值**(`setTimeout(fn, <延迟>)` 的第二个
|
||||
// 参数),不是要调用的函数 ⇒ 包一层让 waited 恒为 null,三项全红。
|
||||
// (b) new Function 等价于 eval:把源码当字符串求值,既是安全反模式,
|
||||
// 也让「判据验的到底是不是真代码」变得含糊。
|
||||
//
|
||||
// 现在改成**先把表达式规范化成纯函数、再按参数形状选择调用方式**:
|
||||
// 表达式里只含 Math.* 与一个变量引用,不是任意代码。
|
||||
|
||||
// 规范化:去掉外层多余括号,把 Math.pow(2, x) 改写成 2 ** x(纯语法变化)
|
||||
function normalizeExpr(expr) {
|
||||
return expr.replace(/Math\.pow\(\s*2\s*,\s*([^()]*?)\s*\)/g, "($1) ** 2");
|
||||
}
|
||||
|
||||
// 表达式里引用了两个标识符:state._sseRetryAttempts 与 attempts。
|
||||
// 用 with 风格的间接求值会踩 eval;改为**显式提取叶子常量**后
|
||||
// 直接用一次受限的 Function —— 仍属动态执行,故这里改为:
|
||||
// 把表达式当成「对变量的纯函数」,用 new Function 只做参数绑定。
|
||||
//
|
||||
// ★ 真正避免动态执行的办法:不做求值,改为**在受限沙箱里逐步代入**。
|
||||
// 但退避表达式含嵌套 Math.min/Math.pow,手写解释器不现实。
|
||||
// 折中:只允许「白名单标识符 + Math.* 成员访问」的表达式,
|
||||
// 求值前先校验,不满足就判失败(而不是默默 eval 任意代码)。
|
||||
const ALLOWED = /^[\s\S]*$/;
|
||||
const checkExprSafe = (expr) => {
|
||||
// 允许:数字、Math.* 成员、state._sseRetryAttempts、attempts、括号、运算符、空格
|
||||
const stripped = expr
|
||||
.replace(/Math\.[A-Za-z]+/g, "M")
|
||||
.replace(/state\._sseRetryAttempts/g, "S")
|
||||
.replace(/\battempts\b/g, "A")
|
||||
.replace(/\d+/g, "N")
|
||||
.replace(/\s+/g, "");
|
||||
return ALLOWED.test(stripped) && !/[^SMAN(),.+\-*/<>|?:]/.test(stripped);
|
||||
};
|
||||
|
||||
function evalDelay(state, expr = exprSrc, attempts) {
|
||||
const e = normalizeExpr(expr);
|
||||
if (!checkExprSafe(e)) {
|
||||
throw new Error(`表达式含白名单外的构造,拒绝求值: ${e}`);
|
||||
}
|
||||
// ★ 用 node:vm 而不是 new Function:后者等价于 eval,是安全反模式;
|
||||
// vm.runInNewContext 是 Node 官方的受限执行环境,拿不到宿主作用域,
|
||||
// 且没有 eval 那样的语法特性。超时 1000ms 兜底防死循环。
|
||||
return vm.runInNewContext(
|
||||
`(${e})`,
|
||||
{ state, attempts, Math },
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
// pump() 断流分支的同构:先清零,再用**清零后**的值算延迟
|
||||
function runPumpRetry(attemptsBefore) {
|
||||
const state = { _sseRetryAttempts: attemptsBefore };
|
||||
state._sseRetryAttempts = 0;
|
||||
return evalDelay(state);
|
||||
}
|
||||
|
||||
// catch 分支的同构:attempts = 历史 + 1,算延迟时用 attempts - 1
|
||||
function runCatchRetry(attemptsBefore) {
|
||||
const state = { _sseRetryAttempts: attemptsBefore };
|
||||
const attempts = (state._sseRetryAttempts || 0) + 1;
|
||||
state._sseRetryAttempts = attempts;
|
||||
return evalDelay(state, exprSrc.replace("(state._sseRetryAttempts || 0)", "(attempts - 1)"), attempts);
|
||||
}
|
||||
|
||||
// ── 行为 0:建连成功后必须清零(独立于 pump 路径)──────────────────
|
||||
//
|
||||
// ★ 这条是补漏:变异测试实测「删掉建连后的清零」时,前面的行为判据**仍全绿** ——
|
||||
// 因为抽取锚点只抓 pump 断流那一处,而 runPumpRetry 自己会先清零,
|
||||
// 于是「建连后清零」根本没进入被验证的路径。形状判据能抓到它,
|
||||
// 但行为判据必须有自己的一份,否则两份判据覆盖的是同一件事。
|
||||
const OK_WINDOW = src.slice(
|
||||
src.indexOf("if (!resp.ok || !resp.body)"),
|
||||
src.indexOf("var reader = resp.body.getReader()"),
|
||||
);
|
||||
check(
|
||||
"建连成功后清零(独立检查)",
|
||||
/_sseRetryAttempts\s*=\s*0/.test(OK_WINDOW),
|
||||
"建连成功后没有清零 ⇒ 首次断连仍按历史累计退避",
|
||||
);
|
||||
|
||||
// ── 行为 1:历史累计到 8 次后,成功建连再断流 ⇒ 必须回到 1s ─────────
|
||||
const afterHistory = runPumpRetry(8);
|
||||
console.log(` · 历史累计 8 次后建连成功再断流 → 等 ${afterHistory}ms`);
|
||||
check(
|
||||
"成功建连后重连不按历史累计退避",
|
||||
afterHistory === 1000,
|
||||
`等了 ${afterHistory}ms,应为 1000ms(历史累计 8 次不该把这次也拖慢)`,
|
||||
);
|
||||
|
||||
// ── 行为 2:catch 分支(真建连失败)仍应指数退避 ───────────────────
|
||||
const c1 = runCatchRetry(0);
|
||||
const c3 = runCatchRetry(2);
|
||||
const c6 = runCatchRetry(5);
|
||||
console.log(` · catch 退避: 第1次 ${c1}ms / 第3次 ${c3}ms / 第6次 ${c6}ms`);
|
||||
check("catch 退避随失败次数增长", c1 < c3 && c3 < c6, `${c1} / ${c3} / ${c6} 未严格递增`);
|
||||
check("catch 退避有上限", c6 <= 60000, `第6次等 ${c6}ms,超出 60s`);
|
||||
|
||||
// ── 行为 3:反复"成功建连→断流"不应越等越久 ────────────────────────
|
||||
// 这是本次修复的核心命题:清零后每次断流都该等 1s,而不是逐次累加。
|
||||
const seq = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const cur = 8; // 模拟历史上已经失败很多次
|
||||
seq.push(runPumpRetry(cur));
|
||||
}
|
||||
const allOne = seq.every((v) => v === 1000);
|
||||
console.log(` · 连续 6 次「建连成功→断流」: ${seq.join(", ")}ms`);
|
||||
check("反复建连成功不会越等越久", allOne, `出现非 1000ms 的等待: ${seq.join(", ")}`);
|
||||
|
||||
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
148
cmd/gui/sse-backoff.test.mjs
Normal file
148
cmd/gui/sse-backoff.test.mjs
Normal file
@ -0,0 +1,148 @@
|
||||
// SSE 重连退避的判据。
|
||||
//
|
||||
// ## 要判的是什么
|
||||
//
|
||||
// 2026-09-27 定位到 GUI「消息流不稳、看着卡」的一个共因:
|
||||
// `state._sseRetryAttempts` **只增不减,从不重置**。
|
||||
//
|
||||
// 它的自增只发生在 `connectFetchSSE` 的 catch 分支(连接**建立**失败),
|
||||
// 而 `pump()` 里 stream 正常读完/断开后的重连**只读它算延迟,却从不清零**:
|
||||
//
|
||||
// reconnectTimer = setTimeout(() => { connectSSE(); },
|
||||
// Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000));
|
||||
//
|
||||
// 后果:只要历史上累计过 5 次失败,**之后每次断连都固定等 32s**,
|
||||
// 无论中间成功连上过没有。而"成功连上"恰恰说明网络/服务端已恢复 ——
|
||||
// 那时还按历史累计退避,就是纯粹的空等。
|
||||
//
|
||||
// 这解释了用户报告的全部四类症状(滞后/卡顿/闪断/看着不稳),
|
||||
// 它们不是四个独立问题,而是同一条链。
|
||||
//
|
||||
// ## 为什么用「源码文本」做判据
|
||||
//
|
||||
// cmd/gui 无测试框架、无构建校验(package.json 只有 start/dev),
|
||||
// app.js 是 203KB 单文件。判据从**真实源码**里提取退避表达式并求值,
|
||||
// 而不是抄一份逻辑重写 —— 抄写的那份会和真实代码漂移,
|
||||
// 而漂移本身就是这个判据要防的东西。
|
||||
//
|
||||
// 运行:node cmd/gui/sse-backoff.test.mjs
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "renderer/app.js"), "utf8");
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail) {
|
||||
if (ok) {
|
||||
console.log(` ✓ ${name}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1) 退避必须被重置 ──────────────────────────────────────────────
|
||||
//
|
||||
// 判据是「成功建立连接后有清零动作」。取连接成功之后的那段代码
|
||||
// (resp.ok && resp.body 之后、读流之前)作为观察窗口。
|
||||
const okWindow = src.slice(
|
||||
src.indexOf("if (!resp.ok || !resp.body)"),
|
||||
src.indexOf("var reader = resp.body.getReader()")
|
||||
);
|
||||
check(
|
||||
"连接成功后重置 _sseRetryAttempts",
|
||||
/_sseRetryAttempts\s*=\s*0/.test(okWindow),
|
||||
"成功建连后没有清零 ⇒ 断连重连会一直按历史累计退避(封顶 32s)"
|
||||
);
|
||||
|
||||
// ── 2) 退避上限是 32s 而不是 60s ──────────────────────────────────
|
||||
//
|
||||
// 代码里写的是 Math.min(..., 60000),但指数被 Math.min(attempts, 5) 夹住,
|
||||
// 2^5 = 32s < 60s ⇒ **60s 那个上限永远达不到**。
|
||||
// 判据把两条 clamp 都提取出来算一遍,而不是相信注释或字面量。
|
||||
// 退避有**两处**,自变量不同,一处都不能漏:
|
||||
// · pump() 断流重连 → Math.min(…, (state._sseRetryAttempts || 0), 5), <ceil>)
|
||||
// · catch 建连失败 → Math.min(…, attempts - 1, 5), <ceil>)
|
||||
// 早先只认第一处,于是 catch 那处(上限仍是 60000)根本不在检查范围内。
|
||||
//
|
||||
// ★ 正则写成**字面量**,不用字符串拼接:
|
||||
// `String.raw` 拼接正则时,`\s` 保持双反斜杠字面量 ⇒ 去找字面的 "\s" 而非空白,
|
||||
// 静默失配。逐步 add 写法反而更脆。字面量能被编辑器/linter 检查。
|
||||
//
|
||||
// 两种幂运算形态都认:Math.pow(2, x) 与 2 ** x(biome 会规范化前者)。
|
||||
// ★ 这里刻意**不**用"一个大正则兼容两种形态"——
|
||||
// 两种形态的捕获组数不同(pow 多一层括号 ⇒ 组数差 1),
|
||||
// 实测按 r[length-1] 取值会把 cap 解成 NaN。
|
||||
//
|
||||
// 改为**定位与取值分离**:
|
||||
// · 正则只负责找到那一处退避表达式(不捕获数字)
|
||||
// · 数字用两次 replace 从命中的子串里取
|
||||
// 两者各自简单,且加一种新形态时只需改正则的"外形",不用动取值逻辑。
|
||||
const SHAPE_PUMP = /Math\.min\(\s*1000\s*\*\s*(?:Math\.pow\(\s*2\s*,|2\s*\*\*)\s*Math\.min\([\s\S]{0,80}?_sseRetryAttempts[\s\S]{0,40}?,\s*(\d+)\s*\)\s*\)?\s*,\s*(\d+)\s*\)/;
|
||||
const SHAPE_CATCH = /Math\.min\(\s*1000\s*\*\s*(?:Math\.pow\(\s*2\s*,|2\s*\*\*)\s*Math\.min\(\s*attempts\s*-\s*1\s*,\s*(\d+)\s*\)\s*\)?\s*,\s*(\d+)\s*\)/;
|
||||
const hits = [
|
||||
["pump 断流重连", SHAPE_PUMP.exec(src)],
|
||||
["catch 建连失败", SHAPE_CATCH.exec(src)],
|
||||
];
|
||||
const m = hits.find(([, r]) => r);
|
||||
if (!m) {
|
||||
check("能提取退避表达式", false, "两处退避都没匹配到(正则或代码形态变了)");
|
||||
} else {
|
||||
// 两处退避的**语义不同**,不能用同一把尺子量:
|
||||
//
|
||||
// · pump 断流重连 —— 刚成功建连过(上面已清零),退避从 1s 起步。
|
||||
// 上限写 32000(= 2^5,实测封顶就是 32s,一致)。
|
||||
//
|
||||
// · catch 建连失败 —— 连接都没建起来,attempts 已 +1,退避本就该更长。
|
||||
// 它的 60000 达不到(封顶 32s),但**无害**:意图是"最多等一分钟",
|
||||
// 写 60000 只是把上限放得比实际封顶更宽,不影响任何一次重连的时刻。
|
||||
//
|
||||
// ⇒ 判据只要求「pump 那处上限自洽」;catch 那处只要求「有上限、不是无限重连」。
|
||||
const [, pumpHit] = hits[0];
|
||||
if (pumpHit) {
|
||||
const cap = Number(pumpHit[1]);
|
||||
const ceilMs = Number(pumpHit[2]);
|
||||
const realCeil = Math.min(1000 * 2 ** cap, ceilMs);
|
||||
console.log(` · pump 断流重连: cap=${cap} → 封顶 ${realCeil / 1000}s;声明上限 ${ceilMs / 1000}s`);
|
||||
check(
|
||||
"pump 退避上限自洽(非死代码)",
|
||||
realCeil === ceilMs,
|
||||
`实际封顶 ${realCeil / 1000}s,声明 ${ceilMs}ms ⇒ 声明值不可达`,
|
||||
);
|
||||
}
|
||||
const [, catchHit] = hits[1];
|
||||
if (catchHit) {
|
||||
const cap = Number(catchHit[1]);
|
||||
const ceilMs = Number(catchHit[2]);
|
||||
const realCeil = Math.min(1000 * 2 ** cap, ceilMs);
|
||||
console.log(` · catch 建连失败: cap=${cap} → 封顶 ${realCeil / 1000}s;声明上限 ${ceilMs / 1000}s`);
|
||||
check(
|
||||
"catch 退避有有限上限(不会无限重连)",
|
||||
Number.isFinite(realCeil) && realCeil <= 60000,
|
||||
`封顶 ${realCeil}ms,超出预期`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3) 401 重试不该有固定长阻塞 ────────────────────────────────────
|
||||
//
|
||||
// api() 的 401 分支里有一句固定的 setTimeout(800)。认证过期时
|
||||
// **每个**请求都白等 0.8s;并发几个请求就叠加成明显的「卡」。
|
||||
// 判据:401 分支里那个 setTimeout 的时长不得超过 100ms。
|
||||
const apiStart = src.indexOf("async function api(p, o)");
|
||||
const apiEnd = src.indexOf("\nasync function", apiStart + 10);
|
||||
const apiBody = apiStart > 0 ? src.slice(apiStart, apiEnd > 0 ? apiEnd : apiStart + 4000) : "";
|
||||
const w401 = apiBody.match(/r\.status === 401[\s\S]{0,600}?setTimeout\(\s*(?:res2\s*,\s*)?(\d+)\s*\)/);
|
||||
if (!w401) {
|
||||
check("找到 401 分支的退避时长", false, "未匹配到 401 分支里的 setTimeout");
|
||||
} else {
|
||||
const wait = Number(w401[1]);
|
||||
console.log(` · 401 重试固定等待 ${wait}ms`);
|
||||
check("401 重试无长固定阻塞", wait <= 100, `固定等 ${wait}ms,认证过期时每个请求都白等这么久`);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
100
cmd/homed-kb-migrate/main.go
Normal file
100
cmd/homed-kb-migrate/main.go
Normal file
@ -0,0 +1,100 @@
|
||||
// Command homed-kb-migrate 迁移存量知识库的目录名到规范名。
|
||||
//
|
||||
// 背景:旧版 Add 对名字**整串** sanitize、对路径**逐段** sanitize,
|
||||
// 于是知识名(内存键 / LLM 可见的名字)与盘上目录从第一次落盘起就对不上。
|
||||
// 典型残留:
|
||||
//
|
||||
// tech/_go_/note 分类段内的空格未被 TrimSpace 掉
|
||||
// Tech/Upper 未小写化
|
||||
// a/b with space 空格未替换成下划线
|
||||
//
|
||||
// 迁移把它们重命名到规范名,使三者一致。
|
||||
//
|
||||
// 安全设计:
|
||||
// 1. **默认只报告**(-apply 才真改名)。批量 os.Rename 不可逆。
|
||||
// 2. 检出目标名冲突则**整批拒绝**,不做部分迁移——半迁移状态比不迁移更难收拾。
|
||||
// 3. 单条失败不中断整体,最后统一报告;执行前再查一次目标越界。
|
||||
//
|
||||
// 与 homed 启动时的关系:`homed` 启动会调同一个 PlanMigration 并**只报告**
|
||||
// (见 cmd/homed/bootstrap.go 的 defaultApply)。本命令是人工确认后真正执行
|
||||
// 的那一步。两者共用 internal/knowledge 里的同一份实现,避免口径漂移。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// homed-kb-migrate -root /data/homeagent/knowledge # 报告
|
||||
// homed-kb-migrate -root /data/homeagent/knowledge -apply # 执行
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := flag.String("root", "", "知识库根目录(必填)")
|
||||
apply := flag.Bool("apply", false, "真正执行重命名(缺省只报告)")
|
||||
dryRun := flag.Bool("dry-run", false, "只报告(显式写法,与默认相同)")
|
||||
limit := flag.Int("limit", 0, "单次最多改名条数,0 = 不限")
|
||||
flag.Parse()
|
||||
|
||||
if *root == "" {
|
||||
fmt.Fprintln(os.Stderr, "错误:必须指定 -root <知识库根目录>")
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if *apply && *dryRun {
|
||||
fmt.Fprintln(os.Stderr, "错误:-apply 与 -dry-run 互斥")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(*root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误:%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
|
||||
items, err := knowledge.PlanMigration(abs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误:无法读取 %s:%v\n", abs, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Printf("未发现任何知识条目(%s)\n", abs)
|
||||
return
|
||||
}
|
||||
|
||||
var need, illegal int
|
||||
for _, it := range items {
|
||||
switch {
|
||||
case it.Illegal:
|
||||
illegal++
|
||||
fmt.Printf(" [非法] %-40s 含 .. / 点段 / 隐藏段;写入与删除均已拒绝,需人工处理\n", it.OldName)
|
||||
case it.NewName != "":
|
||||
need++
|
||||
fmt.Printf(" [迁移] %-40s → %s\n", it.OldName, it.NewName)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n共 %d 条:需迁移 %d,已规范 %d,非法 %d\n",
|
||||
len(items), need, len(items)-need-illegal, illegal)
|
||||
|
||||
if !*apply {
|
||||
if need == 0 {
|
||||
fmt.Println("\n无需迁移。加 -apply 不会改变任何东西。")
|
||||
return
|
||||
}
|
||||
fmt.Println("\n这是报告(未改动任何文件)。确认无误后加 -apply 执行。")
|
||||
return
|
||||
}
|
||||
|
||||
applied, failed := knowledge.ApplyMigration(abs, items, *limit)
|
||||
fmt.Printf("\n迁移完成:成功 %d,失败 %d\n", applied, failed)
|
||||
if failed > 0 {
|
||||
fmt.Fprintln(os.Stderr, "存在失败项。若为名称冲突,请先人工处理冲突的目录再重跑。")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
962
cmd/homed/bootstrap.go
Normal file
962
cmd/homed/bootstrap.go
Normal file
@ -0,0 +1,962 @@
|
||||
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
|
||||
}
|
||||
|
||||
// initKnowledgeMigration 在知识库扫盘**之前**把存量目录名规范化。
|
||||
//
|
||||
// 为何不靠 Store 内部自己做:规范名是「内存键 + 盘上目录 + LLM 可见名字」
|
||||
// 三者必须逐字一致,而磁盘重命名属于有破坏性的副作用,应该在 store 扫盘
|
||||
// 之前、在明确的边界上一次性做完,而不是散在 Store 的初始化路径里。
|
||||
//
|
||||
// 为何默认只报告:os.Rename 不可逆,批量重命名生产数据必须由人确认。
|
||||
// 需要真正迁移时用 homed-kb-migrate -apply(或把下面 defaultApply 打开)。
|
||||
//
|
||||
// 本函数体同样遵守 bootstrap 的平移原则。
|
||||
func initKnowledgeMigration(cfg *types.Config) {
|
||||
root := filepath.Join(cfg.Daemon.DataDir, "knowledge")
|
||||
const (
|
||||
// defaultApply = false ⇒ 启动时只扫描并报告,不改名。
|
||||
defaultApply = false
|
||||
// maxRenamePerRun 限制单次重命名数:给失控的目录规模设一个上限,
|
||||
// 避免启动阶段被一次大迁移拖住。
|
||||
maxRenamePerRun = 200
|
||||
)
|
||||
items, err := knowledge.PlanMigration(root)
|
||||
if err != nil {
|
||||
log.Printf("[homed] 知识库迁移扫描失败(跳过): %v", err)
|
||||
return
|
||||
}
|
||||
need, illegal := 0, 0
|
||||
for _, it := range items {
|
||||
if it.Illegal {
|
||||
illegal++
|
||||
} else if it.NewName != "" {
|
||||
need++
|
||||
}
|
||||
}
|
||||
if need == 0 && illegal == 0 {
|
||||
return
|
||||
}
|
||||
if illegal > 0 {
|
||||
log.Printf("[homed] 知识库迁移:%d 条名称非法(含 .. / 点段 / 隐藏段),写入与删除均已拒绝,需人工处理", illegal)
|
||||
}
|
||||
if need == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("[homed] 知识库迁移:%d/%d 条目录名待规范化(例:%s → %s)", need, len(items),
|
||||
items[0].OldName, items[0].NewName)
|
||||
if !defaultApply {
|
||||
log.Printf("[homed] 知识库迁移:当前为只报告模式。确认清单后执行:homed-kb-migrate -root %s -apply", root)
|
||||
return
|
||||
}
|
||||
if need > maxRenamePerRun {
|
||||
log.Printf("[homed] 知识库迁移:需改名 %d 条超过单次上限 %d,本次只处理前 %d 条",
|
||||
need, maxRenamePerRun, maxRenamePerRun)
|
||||
}
|
||||
applied, failed := knowledge.ApplyMigration(root, items, maxRenamePerRun)
|
||||
log.Printf("[homed] 知识库迁移完成:成功 %d,失败 %d", applied, failed)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// D4:把"设备是否已授权"的判据注入插件侧(toolImpl.CanUse 用)。
|
||||
// ⚠️ 必须放在 agent 构造**之后**——判据要用 agent 的 allowedOutputs,
|
||||
// 而 registry 早于 agent 构造(故这里传的是晚绑定闭包)。
|
||||
// 未注入时 ToolAPI 路径对设备放行:那是"授权可被绕过"的既成缺口。
|
||||
// 注入后,cli 的 /terminal、seq 序列等一切走 ToolAPI 的调用都受同一道闸。
|
||||
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,
|
||||
})
|
||||
|
||||
// D4:把「设备是否已授权」的判据注入插件侧(toolImpl.CanUse 消费它)。
|
||||
//
|
||||
// 为什么必须在这里:判据要用 agent 自己的 allowedOutputs,而
|
||||
// pluginReg 早于 agent 构造(newStageAndRegistry 在 main() 里先跑),
|
||||
// 所以 registry 存的是**晚绑定**闭包,注入点必须在 agent 建好之后。
|
||||
//
|
||||
// 不注入的后果(已实测的真实缺口):设备授权闸只存在于
|
||||
// core.executeToolCallInner,即「agent 收到模型 tool_call」那条路径;
|
||||
// 而 ToolAPI.ExecuteTool 是**另一条**独立入口,不经那道闸 ⇒
|
||||
// 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。实测范围不止序列:
|
||||
// cli 的 /terminal 就直接经 ToolAPI 调 agentcli 的终端工具。
|
||||
pluginReg.SetDeviceAuthQuery(func(deviceID string) bool {
|
||||
return agent.IsOutputAllowed("device/" + deviceID)
|
||||
})
|
||||
|
||||
// 方案 B:把 agent 的**内置工具面**注入 ToolAPI。
|
||||
//
|
||||
// 缺口背景:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个
|
||||
// 在 executeToolCallInner 里按前缀分派,从不进 ToolAPI ⇒ 插件经 ToolAPI
|
||||
// 既查不到也调不了。真机实跑实证:seq_run 报「工具 knowledge_list
|
||||
// 不存在或未注册」,而同一轮模型直接调它是成功的。
|
||||
//
|
||||
// 注入必须在此处(agent 构造之后):内置工具的可见性由运行期状态门控
|
||||
// (memory/knowledge 是否就绪),而 provider 持有的是 agent。
|
||||
agent.InstallBuiltinToolProvider()
|
||||
|
||||
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 之后蒸馏器应已停止")
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
1 ICON "E:/program/homeagent/homeagent/build/icon.ico"
|
||||
@ -2,49 +2,20 @@ 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/kbtree"
|
||||
_ "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 +23,167 @@ 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()))
|
||||
}
|
||||
// 迁移必须在 store 扫盘**之前**:改名后扫盘一次到位,
|
||||
// 避免先以旧名建索引、再改名造成内存键与盘上目录短暂不一致。
|
||||
initKnowledgeMigration(cfg)
|
||||
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 {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||
@ -24,7 +25,14 @@ function ensureBridgeStateTracking(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||
/**
|
||||
* 把门户 HTTP 地址转换为同源设备桥 WebSocket 地址(**回退路径**)。
|
||||
*
|
||||
* 网关改造为子域反代后位于 devices.<基域名>,而基域名与子域标签都是**服务端
|
||||
* 配置**,客户端拼不出来。正常路径是先调 discoverGateway() 问服务端;
|
||||
* 本函数只在「服务端没有发现端点」(老版本 HomeAgent)时兜底,
|
||||
* 保留旧部署的可用性。
|
||||
*/
|
||||
export function deviceGatewayUrl(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
@ -50,6 +58,72 @@ export function deviceGatewayUrl(base: string): string {
|
||||
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
||||
*/
|
||||
/**
|
||||
* 向门户询问设备网关的**权威地址**。
|
||||
*
|
||||
* 为什么必须问而不是自己拼:网关现在挂在 devices.<基域名> 子域上,基域名
|
||||
* (webui.base_domain,默认 localhost)与子域标签(插件声明里可改)都在
|
||||
* 服务端,客户端无从得知。服务端作答是唯一不会漂移的做法。
|
||||
*
|
||||
* 失败/老版本(404)不报错,返回空串让调用方回退到 deviceGatewayUrl()——
|
||||
* 发现是增强而非必需。
|
||||
*/
|
||||
async function discoverGateway(portalUrl: string, apiKey: string): Promise<string> {
|
||||
let base: string = portalUrl.trim();
|
||||
if (base.length === 0) {
|
||||
return '';
|
||||
}
|
||||
// 用户配置里可能填的是完整网关地址:截到门户根再拼发现路径
|
||||
const cut: number = base.indexOf('/api/v1/');
|
||||
if (cut >= 0) {
|
||||
base = base.substring(0, cut);
|
||||
}
|
||||
while (base.length > 0 && base.charAt(base.length - 1) === '/') {
|
||||
base = base.substring(0, base.length - 1);
|
||||
}
|
||||
try {
|
||||
const r = await http.createHttp().request(base + '/api/v1/device/gateway', {
|
||||
method: http.RequestMethod.GET,
|
||||
header: { 'X-API-Key': apiKey } as Record<string, string>,
|
||||
connectTimeout: 5000,
|
||||
readTimeout: 5000,
|
||||
});
|
||||
if (r.responseCode !== 200) {
|
||||
return '';
|
||||
}
|
||||
const body: string = typeof r.result === 'string' ? r.result : '';
|
||||
const parsed: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
// 服务端明确报告不可用(没有声明设备网关反代)时不返回地址,
|
||||
// 让调用方回退,而不是拿着一个连不上的 URL 反复重连。
|
||||
if (parsed['available'] !== true) {
|
||||
return '';
|
||||
}
|
||||
// 优先**门户同源形态**(url_portal:同一 host、同一端口)。
|
||||
//
|
||||
// 原因:子域形态 devices.<基域名> 依赖 DNS 解析,而 *.localhost 只有
|
||||
// 浏览器内置该特例(RFC 6761)—— 应用内 HTTP/WS 客户端走系统解析器,
|
||||
// 通常解析不到。门户同源形态无任何 DNS 依赖,永远可解析。
|
||||
const portal: string = parsed['url_portal'] as string;
|
||||
if (portal !== undefined && portal !== null && portal.length > 0) {
|
||||
return portal;
|
||||
}
|
||||
const url: string = parsed['url'] as string;
|
||||
return url === undefined || url === null ? '' : url;
|
||||
} catch (e) {
|
||||
// 网络失败 / 老版本无此端点:静默回退
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析设备桥最终使用的网关地址:优先服务端发现,回退同源推导。 */
|
||||
async function resolveGatewayUrl(portalUrl: string, apiKey: string): Promise<string> {
|
||||
const discovered: string = await discoverGateway(portalUrl, apiKey);
|
||||
if (discovered.length > 0) {
|
||||
return discovered;
|
||||
}
|
||||
return deviceGatewayUrl(portalUrl);
|
||||
}
|
||||
|
||||
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
foregroundActive = true;
|
||||
setBridgeAppContext(context);
|
||||
@ -67,8 +141,9 @@ export async function startForegroundBridge(context: common.UIAbilityContext): P
|
||||
const generation: number = bridgeGeneration;
|
||||
const deviceId: string = connStore.ensureDeviceId();
|
||||
try {
|
||||
const gatewayUrl: string = await resolveGatewayUrl(cur.url, cur.apiKey);
|
||||
await deviceBridge.connect(
|
||||
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||
gatewayUrl, cur.apiKey, deviceId,
|
||||
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||
deviceBridge.disconnect();
|
||||
|
||||
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)")
|
||||
|
||||
179
cmd/waiter/cmd_allowlist_test.go
Normal file
179
cmd/waiter/cmd_allowlist_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 设备命令白名单的配置化。
|
||||
//
|
||||
// ## 为什么要改
|
||||
//
|
||||
// 白名单原本是源码里硬编码的正则(cmd/waiter/device.go 的
|
||||
// homeagentAllowCmd,18 个命令:ls/pwd/cat/df/...)。它的后果是:
|
||||
// **无论怎么配 waiter.yaml 都跑不了 find / grep / sed / sort / tr**,
|
||||
// 而这些正是排查问题最常用的只读命令。生产日志里的实际报错:
|
||||
//
|
||||
// device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist
|
||||
//
|
||||
// 而 waiter.yaml 里当时只有 3 个键(device_gateway / device_token /
|
||||
// device_authorized),**没有任何键能改这个白名单**。
|
||||
//
|
||||
// ## 改动
|
||||
//
|
||||
// 白名单从"编译期常量"变成"运行期配置":waiter.yaml 可加
|
||||
// `device_cmd_allowlist:`(字符串数组,留空则用内置默认集)。
|
||||
// 匹配函数本身是包级变量,由 main 从配置赋值 —— 与同文件既有的
|
||||
// sendBridgeResult 同一模式(那里也是包级函数变量)。
|
||||
//
|
||||
// ## 为什么要留默认集
|
||||
//
|
||||
// 配置缺失/写错时**不能变成"全放行"**:那等于静默拆掉这道闸。
|
||||
// 判定顺序是「配置非空 → 用配置;否则 → 用默认集」,任一分支都仍有闸。
|
||||
|
||||
// TestDefaultAllowlistStillBlocksDestructive 门禁:默认集必须挡住破坏性命令。
|
||||
//
|
||||
// 这是这道闸存在的**唯一理由**。若某天有人把默认集改成"什么都不拦",
|
||||
// 这条判据必须失败。
|
||||
func TestDefaultAllowlistStillBlocksDestructive(t *testing.T) {
|
||||
// 明确危险的:写文件、删文件、改权限、任意解释器
|
||||
for _, cmd := range []string{
|
||||
"rm -rf /",
|
||||
"dd if=/dev/zero of=/dev/sda",
|
||||
"chmod -R 777 /",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"shutdown now",
|
||||
"reboot",
|
||||
":(){ :|:& };:", // fork 炸弹
|
||||
} {
|
||||
if defaultCmdAllowed(cmd) {
|
||||
t.Errorf("默认白名单放过了破坏性命令 %q —— 这道闸的唯一作用就是挡它", cmd)
|
||||
}
|
||||
}
|
||||
// 常规运维命令应当放行
|
||||
for _, cmd := range []string{"ls", "pwd", "uname -a", "df -h", "ps aux", "uptime"} {
|
||||
if !defaultCmdAllowed(cmd) {
|
||||
t.Errorf("默认白名单挡住了常规命令 %q —— 默认集被改窄了", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigAllowlistExtends 判:配置可扩展只读分析命令。
|
||||
func TestConfigAllowlistExtends(t *testing.T) {
|
||||
// 场景:配置里加了 find/grep/sed/sort/tr
|
||||
cfg := []string{"ls", "find", "grep", "sed", "sort", "tr"}
|
||||
save := deviceCmdAllowed
|
||||
defer func() { deviceCmdAllowed = save }()
|
||||
|
||||
deviceCmdAllowed = buildCmdMatcher(cfg)
|
||||
for _, cmd := range []string{
|
||||
"find . -name plugin.go", // 这次的核心诉求
|
||||
"grep -rn authorized .",
|
||||
"sed -n 1,20p file",
|
||||
"sort -u list",
|
||||
"tr a-z A-Z",
|
||||
} {
|
||||
if !cmdAllowed(cmd) {
|
||||
t.Errorf("配置里已声明的命令仍被拒: %q", cmd)
|
||||
}
|
||||
}
|
||||
// 配置里没写的仍应被拒(配置是"替换默认集"而非"追加")
|
||||
if cmdAllowed("rm -rf /") {
|
||||
t.Error("配置未包含 rm 却放行了 —— 配置必须替换而非叠加默认集")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyConfigFallsBackToDefault 判:配置缺失时回退默认集,且**不是**全放行。
|
||||
func TestEmptyConfigFallsBackToDefault(t *testing.T) {
|
||||
save := deviceCmdAllowed
|
||||
defer func() { deviceCmdAllowed = save }()
|
||||
|
||||
deviceCmdAllowed = buildCmdMatcher(nil) // 配置为空
|
||||
if !cmdAllowed("ls") {
|
||||
t.Error("配置为空时连 ls 都不放行 —— 回退逻辑坏了")
|
||||
}
|
||||
if cmdAllowed("rm -rf /") {
|
||||
t.Error("配置为空时放行了 rm -rf —— 空配置绝不能等于全放行")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdAllowlistFromYAML 判:waiter.yaml 的 device_cmd_allowlist 真能读出来。
|
||||
func TestCmdAllowlistFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "waiter.yaml")
|
||||
body := "device_gateway: \"ws://127.0.0.1:9890/api/v1/device/ws\"\n" +
|
||||
"device_authorized: true\n" +
|
||||
"device_cmd_allowlist:\n - ls\n - find\n - grep\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 走**真实**加载路径(readFile),不另写一份解析 ——
|
||||
// 两处会漂移,而漂移本身就是漏洞。
|
||||
cfg := readFile(path)
|
||||
if cfg == nil {
|
||||
t.Fatalf("readFile 读不出配置: %s", path)
|
||||
}
|
||||
if len(cfg.DeviceCmdAllowlist) != 3 {
|
||||
t.Fatalf("device_cmd_allowlist 解析出 %d 项,期望 3: %v",
|
||||
len(cfg.DeviceCmdAllowlist), cfg.DeviceCmdAllowlist)
|
||||
}
|
||||
joined := strings.Join(cfg.DeviceCmdAllowlist, ",")
|
||||
for _, want := range []string{"ls", "find", "grep"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("device_cmd_allowlist 缺 %q:%v", want, cfg.DeviceCmdAllowlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDaemonPathAppliesAllowlist 守住「daemon 模式也必须应用配置」。
|
||||
//
|
||||
// ★ 这条判据来自一次真实的疏漏。
|
||||
//
|
||||
// waiter 有两条设备桥启动路径:
|
||||
//
|
||||
// · main.go 的 startDeviceBridge —— 交互/一次性模式
|
||||
// · daemon.go 的 startDaemonDeviceBridge —— `waiter --daemon`(生产两台都这么跑)
|
||||
//
|
||||
// 我最初只在 main.go 里赋值 deviceCmdAllowed。daemon 路径不经过那里,
|
||||
// 于是配置**完全不生效** —— 而症状是"配置写了、启动也打了招呼、命令照样被拒",
|
||||
// 极难定位(看起来像配置没读到,其实是那条路径没接线)。
|
||||
//
|
||||
// startDaemonDeviceBridge 会起 goroutine 连网关,测试里不能真连;
|
||||
// 所以这里验证它**读了** cfg.DeviceCmdAllowlist 并改了包级匹配函数:
|
||||
// 先让它在缺网关地址时提前返回,确认那条路径的判据逻辑。
|
||||
func TestDaemonPathAppliesAllowlist(t *testing.T) {
|
||||
save := deviceCmdAllowed
|
||||
defer func() { deviceCmdAllowed = save }()
|
||||
|
||||
// 先设成"拒绝一切",若 daemon 路径没有应用配置,它会保持不变
|
||||
deviceCmdAllowed = func(string) bool { return false }
|
||||
|
||||
// 缺 device_gateway ⇒ 提前 return,不会走到白名单赋值。
|
||||
// 这条断言锁住"提前返回"是有意为之(无网关就不该起桥)。
|
||||
cfg := &Config{DeviceToken: "t"}
|
||||
startDaemonDeviceBridge(cfg)
|
||||
if cmdAllowed("find .") {
|
||||
t.Error("无网关时 startDaemonDeviceBridge 不应改动白名单")
|
||||
}
|
||||
|
||||
// ★ 关键:把网关路径走到赋值那一步。
|
||||
// 真实函数会在 dg==""||dt=="" 时返回,所以这里必须给出网关地址;
|
||||
// 而它随后会起 goroutine 连真实网关 —— 用一个不可达地址即可,
|
||||
// goroutine 连不上会自行退出,不影响本断言。
|
||||
cfg2 := &Config{
|
||||
DeviceGateway: "ws://127.0.0.1:1/api/v1/device/ws", // 不可达
|
||||
DeviceToken: "t",
|
||||
DeviceCmdAllowlist: []string{"find", "grep"},
|
||||
}
|
||||
startDaemonDeviceBridge(cfg2)
|
||||
// 赋值发生在 goroutine 之前 ⇒ 同步可见
|
||||
if !cmdAllowed("find . -name x") {
|
||||
t.Error("daemon 路径没有应用 waiter.yaml 的 device_cmd_allowlist —— " +
|
||||
"配置在 `waiter --daemon` 下会完全不生效")
|
||||
}
|
||||
if cmdAllowed("rm -rf /") {
|
||||
t.Error("daemon 路径应用配置后仍放行破坏性命令")
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,17 @@ type Config struct {
|
||||
DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890)
|
||||
DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token
|
||||
DeviceAuthorized bool `yaml:"device_authorized,omitempty"` // 客户端本地授权(用户手动开启,服务端无法篡改)
|
||||
// DeviceCmdAllowlist 是设备桥**命令白名单**(可执行命令名的第一段)。
|
||||
//
|
||||
// 留空/缺省 ⇒ 用内置默认集(见 device.go 的 defaultCmdAllowlist)。
|
||||
// ★ 不是"追加"而是"替换":写了就以它为准,避免"以为加了 find、
|
||||
// 结果还留着 python3 -c 任意执行"这类误判。
|
||||
//
|
||||
// 为什么需要它:白名单原本是源码里硬编码的正则(18 个命令),
|
||||
// 而 waiter.yaml 里没有任何键能改它 ⇒ find / grep / sed / sort / tr
|
||||
// 这些排查问题最常用的**只读**命令一律被拒,实测报错:
|
||||
// device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist
|
||||
DeviceCmdAllowlist []string `yaml:"device_cmd_allowlist,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Config) Active() *Connection {
|
||||
|
||||
@ -304,6 +304,22 @@ func startDaemonDeviceBridge(cfg *Config) {
|
||||
if dg == "" || dt == "" {
|
||||
return
|
||||
}
|
||||
// ★ 命令白名单必须在**这里**也赋值一次。
|
||||
//
|
||||
// 原因:daemon 模式(waiter --daemon,生产两台都这么跑)走的是本函数,
|
||||
// 不经过 main.go 里那处赋值。只改 main.go 的话,配置在 daemon 下**完全不生效**
|
||||
// —— 而症状是"配置写了、启动日志也打了招呼、命令照样被拒",极难定位。
|
||||
//
|
||||
// 赋值放在 goroutine 之前:白名单在收到第一帧命令时就要就绪。
|
||||
if len(cfg.DeviceCmdAllowlist) > 0 {
|
||||
deviceCmdAllowed = buildCmdMatcher(cfg.DeviceCmdAllowlist)
|
||||
printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: %d 条(来自 waiter.yaml)",
|
||||
len(cfg.DeviceCmdAllowlist)))
|
||||
} else {
|
||||
printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: 默认 %d 条(waiter.yaml 未配置 device_cmd_allowlist)",
|
||||
len(defaultCmdAllowlist)))
|
||||
}
|
||||
|
||||
// 设备桥重连循环:WS 断开时自动重连,并保留配置中的本地授权状态。
|
||||
go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
)
|
||||
|
||||
// ===== 设备桥管理 =====
|
||||
@ -46,17 +47,13 @@ func startDeviceBridge(addr, token string) error {
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"cpus": runtime.NumCPU(),
|
||||
// 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本
|
||||
// 因此与 homed 一致,不再是一个空缺字段。
|
||||
"version": meta.Version,
|
||||
}
|
||||
|
||||
// 确保 gateway URL 格式正确
|
||||
gateway := addr
|
||||
if !strings.HasPrefix(gateway, "ws://") && !strings.HasPrefix(gateway, "wss://") {
|
||||
gateway = "ws://" + gateway
|
||||
// 默认 remotedevice WS 路径
|
||||
if !strings.Contains(gateway, "/api/v1/device/ws") {
|
||||
gateway = gateway + "/api/v1/device/ws"
|
||||
}
|
||||
}
|
||||
// 网关地址规范化(含「已是完整端点」「只有 host:port」两种旧输入形态)。
|
||||
gateway := normalizeGateway(addr)
|
||||
|
||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||
cmdRouter = client.NewCmdRouter()
|
||||
@ -96,10 +93,59 @@ func stopDeviceBridge() {
|
||||
|
||||
// ===== 命令分发 =====
|
||||
|
||||
// homeagent 能力白名单命令(与 remotedevice 插件对齐)
|
||||
var homeagentAllowCmd = regexp.MustCompile(
|
||||
"^(ls|pwd|whoami|uname|date|echo|uptime|hostname|cat|df|free|ps|ip|dir|node|python3?|npm|git|curl|wget|systeminfo|tasklist)\\b",
|
||||
)
|
||||
// defaultCmdAllowlist 是**内置默认**命令白名单(命令名的第一段)。
|
||||
//
|
||||
// 只收「只读/低风险」的诊断类命令。这道闸的唯一作用是挡住
|
||||
// rm -rf /、dd、chmod 777、fork 炸弹这类破坏性命令 —— 而触发它的是
|
||||
// **agent**(经 device_ctl_cmdrun),不是人,所以需要一道机器闸。
|
||||
var defaultCmdAllowlist = []string{
|
||||
"ls", "pwd", "whoami", "uname", "date", "echo", "uptime", "hostname",
|
||||
"cat", "df", "free", "ps", "ip", "dir", "node", "python3", "python",
|
||||
"npm", "git", "curl", "wget", "systeminfo", "tasklist",
|
||||
}
|
||||
|
||||
// buildCmdMatcher 由命令名列表构造匹配函数。
|
||||
//
|
||||
// 只取**命令名的第一段**再整词匹配:这样 "grep -rn x ." 能过,
|
||||
// 而 "grepXxx" / "mygrep" 不会因为 contains 而蒙混过关。
|
||||
// 空列表 ⇒ 回退默认集(★ 绝不能变成"全放行")。
|
||||
func buildCmdMatcher(cmds []string) func(string) bool {
|
||||
if len(cmds) == 0 {
|
||||
cmds = defaultCmdAllowlist
|
||||
}
|
||||
set := make(map[string]bool, len(cmds))
|
||||
for _, c := range cmds {
|
||||
if c = strings.TrimSpace(c); c != "" {
|
||||
set[c] = true
|
||||
}
|
||||
}
|
||||
return func(command string) bool {
|
||||
fields := strings.Fields(strings.TrimSpace(command))
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
// 跳过 VAR=value 前缀(`FOO=bar cmd` 这种合法写法)
|
||||
i := 0
|
||||
for i < len(fields) && strings.Contains(fields[i], "=") &&
|
||||
!strings.HasPrefix(fields[i], "-") {
|
||||
i++
|
||||
}
|
||||
if i >= len(fields) {
|
||||
return false
|
||||
}
|
||||
return set[filepath.Base(fields[i])]
|
||||
}
|
||||
}
|
||||
|
||||
// deviceCmdAllowed 是当前生效的命令白名单匹配函数。
|
||||
//
|
||||
// 包级变量 + 由 main 从配置赋值,与同文件既有的 sendBridgeResult 同一模式
|
||||
// (那里也是包级函数变量,测试可替换)。
|
||||
var deviceCmdAllowed = buildCmdMatcher(nil)
|
||||
|
||||
// cmdAllowed / defaultCmdAllowed 是两个测试可读的入口。
|
||||
func cmdAllowed(command string) bool { return deviceCmdAllowed(command) }
|
||||
func defaultCmdAllowed(command string) bool { return buildCmdMatcher(nil)(command) }
|
||||
|
||||
func handleShellCmd(reqID, command string) {
|
||||
cmd := strings.TrimSpace(command)
|
||||
@ -107,7 +153,7 @@ func handleShellCmd(reqID, command string) {
|
||||
sendBridgeResult(reqID, "error", "", "empty command")
|
||||
return
|
||||
}
|
||||
if !homeagentAllowCmd.MatchString(cmd) {
|
||||
if !cmdAllowed(cmd) {
|
||||
sendBridgeResult(reqID, "error", "", "command not in whitelist")
|
||||
return
|
||||
}
|
||||
|
||||
134
cmd/waiter/gateway_discover.go
Normal file
134
cmd/waiter/gateway_discover.go
Normal file
@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// discoveryPath 是 HomeAgent 的「设备网关在哪」端点(相对门户根)。
|
||||
const discoveryPath = "/api/v1/device/gateway"
|
||||
|
||||
// discoveryResponse 是发现端点的响应。
|
||||
type discoveryResponse struct {
|
||||
Available bool `json:"available"`
|
||||
// URL 是**子域形态**(devices.<基域名>)。浏览器能解析(RFC 6761 内置
|
||||
// 特例),但系统解析器(getent/Go/Node)通常解析不到 *.localhost —— 实测如此。
|
||||
URL string `json:"url"`
|
||||
// URLPortal 是**门户同源形态**(同一 host、同一端口,走路径挂载),
|
||||
// 无任何 DNS 依赖。非浏览器客户端应当用这个。
|
||||
URLPortal string `json:"url_portal"`
|
||||
Preferred string `json:"preferred"`
|
||||
Host string `json:"host"` // devices.<基域名>
|
||||
Auth string `json:"auth"` // homeagent | none
|
||||
Reason string `json:"reason"` // available=false 时的原因
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// normalizeGateway 把用户给的地址整理成可直接连接的 WebSocket URL。
|
||||
//
|
||||
// 保留两种输入形态的旧行为:
|
||||
// - 已含路径(含 /api/v1/device/ws)→ 原样使用;
|
||||
// - 只有 host[:port] → 补 ws:// 与默认 WS 路径。
|
||||
//
|
||||
// 新增:**已带子域标签的地址不再被改写**(例如 devices.example.com)——
|
||||
// 旧实现只判断"是否含路径",对子域地址是对的;这里把这条显式化,
|
||||
// 避免以后有人加"自动补门户路径"的逻辑时把它改坏。
|
||||
func normalizeGateway(addr string) string {
|
||||
g := strings.TrimSpace(addr)
|
||||
if g == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(g, "ws://") || strings.HasPrefix(g, "wss://") {
|
||||
if strings.Contains(g, "/api/v1/device/ws") {
|
||||
return g
|
||||
}
|
||||
return strings.TrimRight(g, "/") + "/api/v1/device/ws"
|
||||
}
|
||||
// http(s):// 形态:转成 ws(s)://,其余同下
|
||||
if strings.HasPrefix(g, "https://") {
|
||||
g = "wss://" + strings.TrimPrefix(g, "https://")
|
||||
} else if strings.HasPrefix(g, "http://") {
|
||||
g = "ws://" + strings.TrimPrefix(g, "http://")
|
||||
} else {
|
||||
g = "ws://" + g
|
||||
}
|
||||
if strings.Contains(g, "/api/v1/device/ws") {
|
||||
return g
|
||||
}
|
||||
return strings.TrimRight(g, "/") + "/api/v1/device/ws"
|
||||
}
|
||||
|
||||
// discoverGateway 向门户询问设备网关的**权威地址**。
|
||||
//
|
||||
// 为什么需要:设备网关现在位于 devices.<基域名> 的子域反代上,而基域名与
|
||||
// 子域标签都是**服务端配置**(webui.base_domain / 插件声明),客户端无从得知。
|
||||
// 让服务端回答「网关在哪」是唯一不会漂移的做法。
|
||||
//
|
||||
// portalURL 是用户配置的门户地址(可能带路径/尾斜杠);token 是门户 api_key。
|
||||
// 任何失败都返回错误,由调用方决定是否回退到自配地址 —— 发现是**增强**而非
|
||||
// 必需,老版本 HomeAgent 没有这个端点。
|
||||
func discoverGateway(portalURL, token string, timeout time.Duration) (string, error) {
|
||||
base := strings.TrimSpace(portalURL)
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("门户地址为空")
|
||||
}
|
||||
// http(s) → 对应的门户根;ws(s) 输入也要能问(GUI 里同一字段混用两种形态)
|
||||
switch {
|
||||
case strings.HasPrefix(base, "wss://"):
|
||||
base = "https://" + strings.TrimPrefix(base, "wss://")
|
||||
case strings.HasPrefix(base, "ws://"):
|
||||
base = "http://" + strings.TrimPrefix(base, "ws://")
|
||||
case !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://"):
|
||||
base = "http://" + base
|
||||
}
|
||||
// 用户可能填的是完整网关地址(含 /api/v1/device/ws):截到根再拼发现路径
|
||||
if i := strings.Index(base, "/api/v1/"); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
req, err := http.NewRequest(http.MethodGet, base+discoveryPath, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-API-Key", token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("发现端点返回 %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var d discoveryResponse
|
||||
if err := json.Unmarshal(body, &d); err != nil {
|
||||
return "", fmt.Errorf("发现响应无法解析: %w", err)
|
||||
}
|
||||
if !d.Available {
|
||||
msg := d.Reason
|
||||
if msg == "" {
|
||||
msg = "服务端报告设备网关不可用"
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
// 优先门户同源形态:waiter 是普通进程,走系统解析器,
|
||||
// 而 *.localhost 在系统解析器下通常解析不到(只有浏览器内置该特例)。
|
||||
if p := strings.TrimSpace(d.URLPortal); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
if p := strings.TrimSpace(d.URL); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
return "", fmt.Errorf("服务端未给出可用的网关地址")
|
||||
}
|
||||
151
cmd/waiter/gateway_discover_test.go
Normal file
151
cmd/waiter/gateway_discover_test.go
Normal file
@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeGateway(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// 已是完整端点:原样
|
||||
"ws://devices.localhost:8080/api/v1/device/ws": "ws://devices.localhost:8080/api/v1/device/ws",
|
||||
"wss://devices.example.com/api/v1/device/ws": "wss://devices.example.com/api/v1/device/ws",
|
||||
// 只有 ws 根:补路径
|
||||
"ws://127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
|
||||
"ws://devices.example.com/": "ws://devices.example.com/api/v1/device/ws",
|
||||
// 裸 host:port:补 scheme + 路径(旧行为)
|
||||
"127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
|
||||
"devices.example.com:8080": "ws://devices.example.com:8080/api/v1/device/ws",
|
||||
// http(s) → ws(s)
|
||||
"http://127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
|
||||
"https://devices.example.com": "wss://devices.example.com/api/v1/device/ws",
|
||||
// ★ 子域地址不得被改写(这正是改造后的正确形态)
|
||||
"devices.example.com": "ws://devices.example.com/api/v1/device/ws",
|
||||
// 空
|
||||
"": "",
|
||||
" ": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeGateway(in); got != want {
|
||||
t.Errorf("normalizeGateway(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发现端点返回权威地址时,必须采用它(而不是自己拼门户同源地址)。
|
||||
func TestDiscoverGatewayUsesServerAnswer(t *testing.T) {
|
||||
var gotPath, gotKey string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotKey = r.Header.Get("X-API-Key")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"available": true,
|
||||
"url": "ws://devices.localhost:8080/api/v1/device/ws",
|
||||
"url_portal": "ws://127.0.0.1:8080/api/v1/device/ws",
|
||||
"auth": "none",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
url, err := discoverGateway(srv.URL, "PORTAL-KEY", 3*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 必须优先门户同源形态:waiter 走系统解析器,*.localhost 解析不到
|
||||
if url != "ws://127.0.0.1:8080/api/v1/device/ws" {
|
||||
t.Errorf("未优先采用门户同源形态: %q", url)
|
||||
}
|
||||
if gotPath != "/api/v1/device/gateway" {
|
||||
t.Errorf("发现路径不对: %q", gotPath)
|
||||
}
|
||||
if gotKey != "PORTAL-KEY" {
|
||||
t.Errorf("未带门户凭证: %q", gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户填的是完整网关地址时,也要能正确截到门户根再问。
|
||||
func TestDiscoverGatewayFromFullEndpointInput(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"available": true, "url": "ws://devices.localhost/api/v1/device/ws",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 输入形态:完整旧端点(含 /api/v1/device/ws)与 ws:// 前缀
|
||||
for _, in := range []string{
|
||||
srv.URL + "/api/v1/device/ws",
|
||||
"ws://" + srv.Listener.Addr().String() + "/api/v1/device/ws",
|
||||
} {
|
||||
gotPath = ""
|
||||
if _, err := discoverGateway(in, "k", 3*time.Second); err != nil {
|
||||
t.Errorf("输入 %q 应成功: %v", in, err)
|
||||
continue
|
||||
}
|
||||
if gotPath != "/api/v1/device/gateway" {
|
||||
t.Errorf("输入 %q 未截到门户根,实际路径 %q", in, gotPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端明确报告不可用 → 必须返回错误(调用方据此回退),而不是给个连不上的 URL。
|
||||
func TestDiscoverGatewayUnavailableReportsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"available": false,
|
||||
"reason": "本实例没有声明设备网关反代",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
|
||||
t.Error("服务端报告不可用时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 老版本 HomeAgent 没有该端点(404)→ 返回错误而不是 panic/空成功。
|
||||
func TestDiscoverGatewayOldServerFallsBack(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
|
||||
t.Error("404 应返回错误,让调用方回退到自配地址")
|
||||
}
|
||||
// 空地址快速失败
|
||||
if _, err := discoverGateway("", "k", 3*time.Second); err == nil {
|
||||
t.Error("空门户地址应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 老版本只给 url(无 url_portal)时,仍必须能用 —— 退回子域形态。
|
||||
func TestDiscoverGatewayFallsBackToSubdomainForm(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"available": true,
|
||||
"url": "ws://devices.example.com/api/v1/device/ws",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := discoverGateway(srv.URL, "k", 3*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "ws://devices.example.com/api/v1/device/ws" {
|
||||
t.Errorf("无 url_portal 时应退回 url,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 两者都没有 → 明确报错,而不是返回空串让调用方拿着空地址去连。
|
||||
func TestDiscoverGatewayNoURLReportsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"available": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
|
||||
t.Error("两个形态都缺时应报错")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -172,10 +181,34 @@ func main() {
|
||||
if dt == "" {
|
||||
dt = cfg.DeviceToken
|
||||
}
|
||||
// 网关地址优先向门户**发现**(服务端才知道子域标签与基域名),
|
||||
// 失败再回退到用户配置 —— 老版本 HomeAgent 没有发现端点。
|
||||
// 只在用户已配置门户地址时尝试:没配门户就没有可问的对象。
|
||||
if portal := cfg.Remote; portal != "" {
|
||||
if discovered, err := discoverGateway(portal, cfg.APIKey, 5*time.Second); err == nil {
|
||||
printlnC(colorGreen, "device gateway discovered: "+discovered)
|
||||
dg = discovered
|
||||
} else if dg == "" {
|
||||
printlnC(colorYellow, "device gateway discovery failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
if dg != "" && dt != "" {
|
||||
if err := startDeviceBridge(dg, dt); err != nil {
|
||||
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
|
||||
} else {
|
||||
// 命令白名单:waiter.yaml device_cmd_allowlist,留空用内置默认集。
|
||||
//
|
||||
// ★ 在 startDeviceBridge **之后**赋值:白名单只在收到命令时才用,
|
||||
// 放在这里能保证它一定在第一帧命令到达前就绪。
|
||||
if len(cfg.DeviceCmdAllowlist) > 0 {
|
||||
deviceCmdAllowed = buildCmdMatcher(cfg.DeviceCmdAllowlist)
|
||||
printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: %d 条(来自 waiter.yaml)",
|
||||
len(cfg.DeviceCmdAllowlist)))
|
||||
} else {
|
||||
printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: 默认 %d 条(waiter.yaml 未配置 device_cmd_allowlist)",
|
||||
len(defaultCmdAllowlist)))
|
||||
}
|
||||
|
||||
// 客户端本地授权:命令行 --device-authorized 或 waiter.yaml device_authorized
|
||||
auth := *deviceAuthorized || cfg.DeviceAuthorized
|
||||
deviceBridge.SetAuthorized(auth)
|
||||
@ -185,6 +218,18 @@ func main() {
|
||||
}
|
||||
|
||||
if oneShotMsg != "" {
|
||||
// ★ -chat 是一次性问答,会立刻走到上面的 return 并触发
|
||||
// defer stopDeviceBridge(),桥的生命周期只有几百毫秒。
|
||||
//
|
||||
// 后果:设备来不及完成 hello→bind 登记就已断开,服务端列表里永远
|
||||
// 看不到它(实测:`device bridge active` 打印了、bind_ack 也收到了,
|
||||
// 但 /api/v1/device/online 始终为空)。
|
||||
// 这不是桥的错 —— 用裸客户端把 hello/bind 发完并保持连接,同一实例
|
||||
// 上设备立刻出现在列表里(已验证)。
|
||||
//
|
||||
// 等待 bind 确认(或短暂超时)再退出:既让登记完成,也不把一次性
|
||||
// 命令拖长。bind 失败要明说,而不是静默丢掉设备。
|
||||
waitDeviceBind(3 * time.Second)
|
||||
oneshot(state, oneShotMsg)
|
||||
return
|
||||
}
|
||||
@ -270,9 +315,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.")
|
||||
|
||||
@ -479,3 +524,26 @@ func printServerEventColored(rl respLine, raw string) {
|
||||
fmt.Printf("%s%s%s\n", clearLine, text, colorReset)
|
||||
}
|
||||
}
|
||||
|
||||
// waitDeviceBind 等待服务端确认 bind(最多 timeout),返回是否确认。
|
||||
//
|
||||
// 用于一次性命令(-chat):桥启动后立刻退出会让设备来不及登记。
|
||||
// 超时不报错(服务端可能只是慢),bind 明确被拒则打出来 —— 那通常意味着
|
||||
// 设备令牌不对或设备未授权,用户需要知道,而不是以为「桥起来了就好了」。
|
||||
func waitDeviceBind(timeout time.Duration) bool {
|
||||
if deviceBridge == nil {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if deviceBridge.Bound() {
|
||||
return true
|
||||
}
|
||||
if reason := deviceBridge.BindError(); reason != "" {
|
||||
printlnC(colorYellow, "device bridge bind rejected: "+reason)
|
||||
return false
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return deviceBridge.Bound()
|
||||
}
|
||||
|
||||
Binary file not shown.
125
csrc/CMakeLists.txt
Normal file
125
csrc/CMakeLists.txt
Normal file
@ -0,0 +1,125 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(ha_codec VERSION 0.1.0 LANGUAGES C)
|
||||
|
||||
# ============================================================
|
||||
# ha_codec — HomeAgent 内核编解码层(C 实现)
|
||||
#
|
||||
# 零外部依赖,纯 C99。产出静态库供 homed 经 cgo 链接,
|
||||
# 同时可独立用于其他端(鸿蒙 / 嵌入式 / C SDK)。
|
||||
#
|
||||
# 使用方式:
|
||||
# add_subdirectory(path/to/csrc)
|
||||
# target_link_libraries(my_app ha_codec)
|
||||
# target_include_directories(my_app PRIVATE ${HA_CODEC_INCLUDE_DIR})
|
||||
# ============================================================
|
||||
|
||||
option(BUILD_SHARED_LIBS "Build ha_codec as shared library" OFF)
|
||||
option(BUILD_TESTS "Build ha_codec tests" OFF)
|
||||
option(BUILD_FUZZ "Build libFuzzer targets" OFF)
|
||||
option(BUILD_BENCH "Build micro benchmarks" OFF)
|
||||
|
||||
# ★ C99 而非编译器默认档(clang 默认 gnu17)。
|
||||
# 理由:内核 C 侧的编译契约是 C99(cgo CFLAGS 与这里必须一致),
|
||||
# 在更新的默认档下编译会**静默**通过,而 Go 侧用 -std=c99 编不过
|
||||
# —— 两边同时构建、行为却分叉,是最难查的一类问题。
|
||||
# 实测:本轮 -Wpedantic 门禁就当场抓到过 C11 特性(_Static_assert)。
|
||||
# C90/C95 不支持:ha_abi.h 用了 // 注释与 stdint。
|
||||
# C11 开关保留给想验证「未来切到 C11 也不坏」的人。
|
||||
set(CMAKE_C_STANDARD 99)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_C_EXTENSIONS OFF) # 禁用 gnu99 扩展,严格 -std=c99
|
||||
|
||||
set(HA_CODEC_SRC
|
||||
src/ha_codec.c
|
||||
src/ha_json_scan.c
|
||||
)
|
||||
|
||||
if(BUILD_SHARED_LIBS)
|
||||
add_library(ha_codec SHARED ${HA_CODEC_SRC})
|
||||
if(WIN32)
|
||||
set_target_properties(ha_codec PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||
endif()
|
||||
else()
|
||||
add_library(ha_codec STATIC ${HA_CODEC_SRC})
|
||||
endif()
|
||||
|
||||
set(HA_CODEC_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||
target_include_directories(ha_codec PUBLIC ${HA_CODEC_INCLUDE})
|
||||
|
||||
# 告警门禁:零告警才允许通过(与 Makefile 的 csrc-lint 同一标准)。
|
||||
# C 侧没有 Go 的 vet 等价物,告警是唯一的静态信号。
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion)
|
||||
endif()
|
||||
|
||||
# 不链接任何外部库 —— 保持与 ha_remotedevice 同一克制标准
|
||||
target_link_libraries(ha_codec PRIVATE)
|
||||
|
||||
set(HA_CODEC_INCLUDE_DIR ${HA_CODEC_INCLUDE} CACHE INTERNAL "ha_codec include directories")
|
||||
|
||||
install(TARGETS ha_codec
|
||||
EXPORT ha_codec-targets
|
||||
LIBRARY DESTINATION lib
|
||||
ARCHIVE DESTINATION lib
|
||||
RUNTIME DESTINATION bin
|
||||
INCLUDES DESTINATION include
|
||||
)
|
||||
install(DIRECTORY include/ DESTINATION include)
|
||||
|
||||
# ============================================================
|
||||
# 测试
|
||||
# ============================================================
|
||||
if(BUILD_TESTS)
|
||||
add_executable(ha_codec_test test/test_ha_codec.c)
|
||||
target_link_libraries(ha_codec_test PRIVATE ha_codec)
|
||||
add_executable(ha_json_scan_test test/test_ha_json_scan.c)
|
||||
target_link_libraries(ha_json_scan_test PRIVATE ha_codec)
|
||||
enable_testing()
|
||||
add_test(NAME ha_codec_test COMMAND ha_codec_test)
|
||||
add_test(NAME ha_json_scan_test COMMAND ha_json_scan_test)
|
||||
endif()
|
||||
|
||||
# ============================================================
|
||||
# 模糊测试:编码语义与内存安全的持续检验
|
||||
#
|
||||
# 动机:ha_codec 声称**逐值等价于 Go 参考实现**,其中最关键的一条是
|
||||
# 「对畸形 UTF-8 的解码边界与 Go 的 utf8.DecodeRuneInString 一致」。
|
||||
# 该行为在正常输入下永远测不到 —— 只有随机字节才能覆盖
|
||||
# 截断序列 / 过长编码 / 代理对 / 超 U+10FFFF / 嵌入 NUL。
|
||||
# Go 侧已有 TestGolden_InvalidUTF8(3000 组随机字节)做等价钉死;
|
||||
# C 侧则需要独立验证两件事:
|
||||
# 1. 任何输入都不崩、不越界(内存安全)
|
||||
# 2. 返回值不违反头文件声明的不变式(0 <= keep <= len 等)
|
||||
# 两者在 libFuzzer 上是持续的,而不是等下一次手写用例。
|
||||
#
|
||||
# 需 clang + -fsanitize=fuzzer;无则明确跳过(门禁不能假装通过)。
|
||||
# ============================================================
|
||||
if(BUILD_FUZZ)
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "Clang")
|
||||
foreach(fz IN ITEMS test_fuzz_ha_codec test_fuzz_ha_json_scan)
|
||||
add_executable(${fz} test/${fz}.c)
|
||||
target_link_libraries(${fz} PRIVATE ha_codec)
|
||||
target_compile_options(${fz} PRIVATE
|
||||
-fsanitize=fuzzer,address,undefined
|
||||
-fno-omit-frame-pointer)
|
||||
target_link_options(${fz} PRIVATE
|
||||
-fsanitize=fuzzer,address,undefined)
|
||||
endforeach()
|
||||
else()
|
||||
message(WARNING
|
||||
"BUILD_FUZZ=ON 需要 clang(libFuzzer);当前编译器是 "
|
||||
"${CMAKE_C_COMPILER_ID},已跳过。")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ============================================================
|
||||
# 微基准:C 侧自身的开销(与 Go 侧 codec_bench_test.go 对照)
|
||||
#
|
||||
# 为什么 C 侧也要基准:Go 侧基准里,「C 实现省下的时间」与「cgo 边界成本」
|
||||
# 是混在一起的。若 C 侧本身在某场景很慢,改 Go 绑定无济于事;
|
||||
# 必须在能隔离处(纯 C、无边界)测出函数体成本,才知道该优化谁。
|
||||
# ============================================================
|
||||
if(BUILD_BENCH)
|
||||
add_executable(ha_codec_bench bench/bench_ha_codec.c)
|
||||
target_link_libraries(ha_codec_bench PRIVATE ha_codec)
|
||||
endif()
|
||||
175
csrc/bench/bench_ha_codec.c
Normal file
175
csrc/bench/bench_ha_codec.c
Normal file
@ -0,0 +1,175 @@
|
||||
/*
|
||||
* bench_ha_codec.c —— C 侧纯函数微基准(无 cgo 边界成本)
|
||||
*
|
||||
* ============================ 为什么 Go 侧基准不够 ============================
|
||||
* Go 侧 codec_bench_test.go 测到的数 = **函数体成本 + cgo 边界成本**(约 30ns)
|
||||
* 两项混在一起。后果:看到某个场景慢,分不清该优化 C 函数体,还是该减少
|
||||
* 跨语言调用次数(或把循环整体 C 化批量传一次)—— 而这三者的处方完全不同。
|
||||
* 只有在能隔离边界成本的地方(纯 C 循环)测,才知道该动谁。
|
||||
*
|
||||
* 用法:cmake -DBUILD_BENCH=ON && ./ha_codec_bench [reps]
|
||||
*
|
||||
* 覆盖与 Go 侧 benchInputs 对齐(empty / ascii_short / zh_short / zh_200 /
|
||||
* ascii_1k / zh_1k),便于两张表直接对读。
|
||||
*/
|
||||
|
||||
#ifndef _POSIX_C_SOURCE
|
||||
# define _POSIX_C_SOURCE 199309L
|
||||
#endif
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "ha_codec.h"
|
||||
|
||||
/* 单调时钟(纳秒)。
|
||||
*
|
||||
* ★ 必须是 clock_gettime(不是 clock()、不是 time()):基准要测的是
|
||||
* 几十纳秒级的函数体耗时,clock()/time() 的分辨率是**秒**,
|
||||
* 拿它测 ns/op 只会得到一堆 0 或被量化成整数秒的噪声。
|
||||
*
|
||||
* ★ CLOCK_MONOTONIC 与 clock_gettime 都是 POSIX 而**非 ISO C99**,
|
||||
* 而 CMake 刻意设了 CMAKE_C_EXTENSIONS OFF(严格 -std=c99)
|
||||
* ⇒ 未定义这两个符号。实测报错:
|
||||
* error: storage size of 'ts' isn't known
|
||||
* error: implicit declaration of function 'clock_gettime'
|
||||
* 这是 C 化门禁当场抓出的真实可移植性缺陷 —— 若靠 Makefile 的裸 gcc
|
||||
* (默认 gnu17)构建,它会**静默编过**;而到别人的严格 C99 工具链上就炸。
|
||||
*
|
||||
* 故显式请求 POSIX 声明。_POSIX_C_SOURCE 必须在包含任何头文件**之前**
|
||||
* 定义(否则 feature test macro 无效,这也是最常见的踩法)。
|
||||
* Windows/MSVC 走 _MSC_VER 分支(用 QueryPerformanceCounter),
|
||||
* 保证这个 bench 文件在异端也能编。 */
|
||||
#if defined(_MSC_VER)
|
||||
# include <windows.h>
|
||||
static double now_sec(void) {
|
||||
LARGE_INTEGER f, c;
|
||||
QueryPerformanceFrequency(&f);
|
||||
QueryPerformanceCounter(&c);
|
||||
return (double)c.QuadPart / (double)f.QuadPart;
|
||||
}
|
||||
#else
|
||||
# ifndef _POSIX_C_SOURCE
|
||||
# define _POSIX_C_SOURCE 199309L
|
||||
# endif
|
||||
# include <time.h>
|
||||
static double now_sec(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* 分配并填充 reps 个 'x' 的缓冲(可含 NUL 之外的任意字节)。 */
|
||||
static char *make_fill(size_t n, char ch) {
|
||||
char *p = (char *)malloc(n ? n : 1);
|
||||
if (p) memset(p, ch, n);
|
||||
return p;
|
||||
}
|
||||
|
||||
static void bench_estimate(const char *name, const char *s, size_t len, int reps) {
|
||||
/* 预热:把指令缓存与分支预测器带进稳态,否则首个样本的冷启动会
|
||||
* 均摊到很少的迭代上(reps 小的时候误差极大)。 */
|
||||
for (int i = 0; i < reps; i++) (void)ha_codec_estimate_tokens(s, len);
|
||||
|
||||
double t0 = now_sec();
|
||||
int acc = 0;
|
||||
for (int i = 0; i < reps; i++) {
|
||||
acc += ha_codec_estimate_tokens(s, len);
|
||||
}
|
||||
double dt = now_sec() - t0;
|
||||
|
||||
double ns = (reps > 0) ? (dt * 1e9 / reps) : 0.0;
|
||||
double mbs = (dt > 0) ? ((double)len * reps / dt / 1e6) : 0.0;
|
||||
printf(" %-12s len=%7zu %9.2f ns/op %8.1f MB/s (acc=%d)\n",
|
||||
name, len, ns, mbs, acc);
|
||||
}
|
||||
|
||||
static void bench_truncate(const char *name, const char *s, size_t len,
|
||||
int max_tokens, int reps) {
|
||||
for (int i = 0; i < reps; i++) {
|
||||
(void)ha_codec_truncate_by_tokens(s, len, max_tokens);
|
||||
}
|
||||
double t0 = now_sec();
|
||||
size_t acc = 0;
|
||||
for (int i = 0; i < reps; i++) {
|
||||
acc += ha_codec_truncate_by_tokens(s, len, max_tokens);
|
||||
}
|
||||
double dt = now_sec() - t0;
|
||||
double ns = (reps > 0) ? (dt * 1e9 / reps) : 0.0;
|
||||
printf(" %-12s len=%7zu %9.2f ns/op (keep=%zu)\n",
|
||||
name, len, ns, acc / (size_t)reps);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int reps = (argc > 1) ? atoi(argv[1]) : 200000;
|
||||
if (reps <= 0) reps = 200000;
|
||||
|
||||
printf("== ha_codec C 侧微基准(reps=%d,纯 C 无 cgo 边界)==\n", reps);
|
||||
printf("-- ha_codec_estimate_tokens --\n");
|
||||
|
||||
bench_estimate("empty", "", 0, reps);
|
||||
bench_estimate("ascii_short", "hello world", 11, reps);
|
||||
bench_estimate("zh_short", "用户询问了系统状态", 27, reps);
|
||||
{
|
||||
char *zh200 = make_fill(180, 'a'); /* 逐字节非 ASCII 由下方覆盖 */
|
||||
bench_estimate("ascii_200", zh200, 180, reps);
|
||||
free(zh200);
|
||||
}
|
||||
{
|
||||
char *zh = make_fill(1024, 'x');
|
||||
bench_estimate("ascii_1k", zh, 1024, reps);
|
||||
free(zh);
|
||||
}
|
||||
{
|
||||
/* 真实中文:每字 3 字节 = 1024 字节 ≈ 341 rune */
|
||||
char *zh = make_fill(1023, 'x');
|
||||
for (size_t i = 0; i + 2 < 1024; i += 3) {
|
||||
zh[i] = (char)0xE4; zh[i + 1] = (char)0xBD; zh[i + 2] = (char)0xA0;
|
||||
}
|
||||
bench_estimate("zh_1k", zh, 1024, reps);
|
||||
free(zh);
|
||||
}
|
||||
|
||||
printf("-- ha_codec_truncate_by_tokens (max_tokens=64) --\n");
|
||||
{
|
||||
char *a1k = make_fill(1024, 'x');
|
||||
bench_truncate("ascii_1k", a1k, 1024, 64, reps);
|
||||
free(a1k);
|
||||
}
|
||||
{
|
||||
char *zh = make_fill(1023, 'x');
|
||||
for (size_t i = 0; i + 2 < 1024; i += 3) {
|
||||
zh[i] = (char)0xE4; zh[i + 1] = (char)0xBD; zh[i + 2] = (char)0xA0;
|
||||
}
|
||||
bench_truncate("zh_1k", zh, 1024, 64, reps);
|
||||
free(zh);
|
||||
}
|
||||
|
||||
printf("-- ha_codec_model_context_window (含/不含匹配) --\n");
|
||||
{
|
||||
const char *models[4] = {
|
||||
"deepseek/deepseek-v4.1-flash", "gpt-4-turbo", "qwen-max", "AUTO"
|
||||
};
|
||||
for (int w = 0; w < 4; w++) {
|
||||
size_t l = strlen(models[w]);
|
||||
for (int i = 0; i < reps; i++) {
|
||||
(void)ha_codec_model_context_window(models[w], l);
|
||||
}
|
||||
double t0 = now_sec();
|
||||
int acc = 0;
|
||||
for (int i = 0; i < reps; i++) {
|
||||
acc += ha_codec_model_context_window(models[w], l);
|
||||
}
|
||||
double dt = now_sec() - t0;
|
||||
printf(" %-30s %9.2f ns/op (win=%d)\n", models[w],
|
||||
(reps > 0) ? dt * 1e9 / reps : 0.0, acc / reps);
|
||||
}
|
||||
}
|
||||
|
||||
printf("-- ABI --\n");
|
||||
printf(" ha_codec_abi_version = %d\n", ha_codec_abi_version());
|
||||
return 0;
|
||||
}
|
||||
80
csrc/include/ha_abi.h
Normal file
80
csrc/include/ha_abi.h
Normal file
@ -0,0 +1,80 @@
|
||||
#ifndef HA_ABI_H
|
||||
#define HA_ABI_H
|
||||
|
||||
/*
|
||||
* ha_abi.h — HomeAgent C 库的 ABI 版本契约
|
||||
*
|
||||
* ============================ 为什么需要它 ============================
|
||||
* ha_codec.h 声明「签名一经发布即冻结」,但**冻结只写在注释里**——注释不
|
||||
* 参与编译,Go/C 两侧对「我以为的版本」不一致时没有任何机制会报错。
|
||||
* 本头文件把冻结变成**编译期与测试期可断言的事实**:
|
||||
*
|
||||
* 1. 每个 C 库声明自己的 ABI 主/次版本(HA_CODEC_ABI_MAJOR/MINOR)。
|
||||
* 2. Go 侧(internal/agent/api/codec_cgo.go)持有一份 Go 常量副本,
|
||||
* 由 TestABIVersionMatches 比对 C 宏 —— 版本漂移**在测试里判红**,
|
||||
* 而不是等到线上表现为「插件行为诡异」才排查。
|
||||
* 3. 主版本不同 = ABI 不兼容,必须走大版本流程(与 homeagent-sdk 同一标准)。
|
||||
*
|
||||
* ============================ 改动规则 ============================
|
||||
* - 只增不改、只加不改:新增函数/字段 → MINOR+1
|
||||
* - 改签名、删函数、改结构体布局 → MAJOR+1(且所有调用方必须同步重编)
|
||||
* - 纯内部实现优化(不动任何声明)→ 不动版本号
|
||||
*
|
||||
* ⚠️ 与 homeagent-sdk 的 C ABI 不同:本项目的 C 库是**源码内联编译**
|
||||
* (Go 侧符号链接 csrc/ 权威源,见 codec_cgo.go 顶部),不存在跨版本
|
||||
* 混链的 .so/.a,所以「同批重建」是天然成立的——版本宏的作用是
|
||||
* **防语义漂移**(两侧对同一组函数的理解不一致),不是防二进制不兼容。
|
||||
*/
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ==================== 编译期断言(C99/C11 兼容) ==================== */
|
||||
|
||||
/* 静态断言:版本号写错必须在编译期就炸,不能带着荒谬版本号发布出去。
|
||||
*
|
||||
* ★ C99 没有 _Static_assert(那是 C11),而本项目 C 侧统一 -std=c99
|
||||
* (见 codec_cgo.go 的 cgo CFLAGS 与 CMakeLists 的 C_STANDARD)。故需兼容垫片:
|
||||
* C11+ 用原生 _Static_assert;C99 回退到「数组维度为 0 即编译失败」的老写法。
|
||||
* 这条垫片是 -Wall -Wextra -Wpedantic 门禁上线时**当场抓出来的**(首次编译即告警),
|
||||
* 即基础设施已经开始在发挥作用。
|
||||
*
|
||||
* 用法:第二个参数必须是**标识符**(不能是字符串)——C99 分支要用它 ## 成
|
||||
* 一个 typedef 名,而 `##` 不能拼接字符串字面量(拼接会直接编译报错)。
|
||||
* 原生 _Static_assert 分支则把它当 msg 传(此时它在诊断里显示为标识符,
|
||||
* 仍能指出是哪个断言)。同一文件内每个断言的 tag 必须不同。 */
|
||||
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
|
||||
# define HA_STATIC_ASSERT(cond, tag) _Static_assert(cond, #tag)
|
||||
#elif defined(__cplusplus) && __cplusplus >= 201103L
|
||||
# define HA_STATIC_ASSERT(cond, tag) static_assert(cond, #tag)
|
||||
#else
|
||||
# define HA_STATIC_ASSERT(cond, tag) \
|
||||
typedef char ha_sa_##tag##_line_##__LINE__[(cond) ? 1 : -1]
|
||||
#endif
|
||||
|
||||
/* ==================== ABI 版本 ==================== */
|
||||
|
||||
/* 编码语义主版本:改动任一已发布函数的语义/签名时 +1。
|
||||
* 2026-09-26:首版 1.0(上下文窗口推断 + token 估算/截断)。 */
|
||||
#define HA_CODEC_ABI_MAJOR 1
|
||||
|
||||
/* 编码语义次版本:纯新增(加函数、加枚举值)时 +1。 */
|
||||
#define HA_CODEC_ABI_MINOR 0
|
||||
|
||||
/* 合成版号,便于日志/断言单值比较:major*1000 + minor */
|
||||
#define HA_CODEC_ABI_VERSION (HA_CODEC_ABI_MAJOR * 1000 + HA_CODEC_ABI_MINOR)
|
||||
|
||||
/* 编译期锁死:ABI 版本必须落在「已知的、未被遗忘的」区间。
|
||||
* 若有人把版本号改成 0 或 999 之类(通常是手滑/拷贝粘贴出错),
|
||||
* 编译立即失败,而不是带着一个荒谬的版本号发布出去。 */
|
||||
HA_STATIC_ASSERT(HA_CODEC_ABI_MAJOR >= 1 && HA_CODEC_ABI_MAJOR <= 9,
|
||||
ha_codec_abi_major_in_range);
|
||||
HA_STATIC_ASSERT(HA_CODEC_ABI_MINOR >= 0 && HA_CODEC_ABI_MINOR <= 99,
|
||||
ha_codec_abi_minor_in_range);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_ABI_H */
|
||||
97
csrc/include/ha_codec.h
Normal file
97
csrc/include/ha_codec.h
Normal file
@ -0,0 +1,97 @@
|
||||
#ifndef HA_CODEC_H
|
||||
#define HA_CODEC_H
|
||||
|
||||
/*
|
||||
* ha_codec — HomeAgent 内核编解码层(C 实现)
|
||||
*
|
||||
* ============================ 接口冻结声明 ============================
|
||||
* 本头文件是对外契约。函数签名、语义、返回值一经发布即为冻结接口,
|
||||
* 修改必须走大版本流程(与 third_party/homeagent-sdk 同一冻结标准)。
|
||||
*
|
||||
* 设计约束(见 docs/zh/c-core/llm-orchestration-c.md §四):
|
||||
* 1. 只吃 const char* + **显式长度**,出数值/字节偏移 —— 不回调 Go、
|
||||
* 不传 Go 指针、不要求 NUL 结尾
|
||||
* 2. **不 malloc**:不需要出参缓冲区,需要「结果」时返回字节偏移/长度,
|
||||
* 由调用方在自己的缓冲上切片(零拷贝)
|
||||
* 3. 无状态、纯函数、线程安全(不写全局可变状态)
|
||||
*
|
||||
* 当前覆盖:L1 协议编解码层中的纯计算部分(第一个最小切片)。
|
||||
*
|
||||
* ============================ 为什么签名带长度 ============================
|
||||
* 初版签名用 `const char*` 隐含「NUL 结尾」,于是每次调用都要:
|
||||
* Go `C.CString` 分配+拷贝一遍 → C `strlen` 再扫一遍。
|
||||
* 实测这部分开销占单次调用的 80% 以上(cgo 边界本身仅 ~30ns,
|
||||
* 而初版 ModelContextWindow 实测 175ns)。
|
||||
* 改为「指针 + 长度」后,Go 侧用 unsafe.StringData 直接传底层数组,
|
||||
* 零分配零拷贝。这是设计约束第 1 条的字面要求。
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "ha_abi.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ==================== ABI 自述(供 Go 侧与日志核对) ==================== */
|
||||
|
||||
/* 返回 HA_CODEC_ABI_VERSION(major*1000 + minor)。
|
||||
*
|
||||
* 存在的意义:Go 侧不该靠 `#include` 宏做版本断言(cgo 头文件里的宏在
|
||||
* 预处理后不可见),而要**运行期/测试期**能问 C 侧「你自称什么版本」。
|
||||
* 由 codec_cgo.go 绑定、codec_abiversion_test.go 与 C 侧宏三方比对。 */
|
||||
int ha_codec_abi_version(void);
|
||||
|
||||
/* ==================== 模型上下文窗口推断 ==================== */
|
||||
|
||||
/* 无法从模型名推断时的哨兵值(与 Go 侧一致)。
|
||||
*
|
||||
* 为什么返回哨兵而不是直接给兜底值:调用方需要区分「真推断出了」与
|
||||
* 「推断不出、只能兜底」——后者要打一行日志(窗口被低估必须可见),
|
||||
* 并提示部署方用 per-source context_window 显式声明。
|
||||
* 若 C 侧直接返回兜底值,调用方就永远分不清这两种情况。 */
|
||||
#define HA_CODEC_CONTEXT_WINDOW_UNKNOWN (-1)
|
||||
|
||||
/* 由模型名推断最大上下文窗口(token 数);推断不出返回
|
||||
* HA_CODEC_CONTEXT_WINDOW_UNKNOWN。
|
||||
*
|
||||
* model 为 UTF-8 字节序列,**不需要 NUL 结尾**;model_len 是字节数。
|
||||
* model 为 NULL 或 model_len 为 0 时返回 UNKNOWN。
|
||||
*
|
||||
* 匹配大小写不敏感(仅对 ASCII 字母做折叠;非 ASCII 字节按原样比较,
|
||||
* 与 Go 侧对模型名的实际输入一致)。
|
||||
*
|
||||
* 语义必须与 Go 侧 modelContextWindowPure 逐值一致(黄金对照测试钉死)。 */
|
||||
int ha_codec_model_context_window(const char *model, size_t model_len);
|
||||
|
||||
/* ==================== token 估算与截断 ==================== */
|
||||
|
||||
/* 粗略估算 token 数。
|
||||
*
|
||||
* 规则(与 Go 侧 EstimateTokens 一致):保守取 max(1, runeCount * 2)。
|
||||
* 按 UTF-8 **字符数**(rune)计,不是字节数。
|
||||
* text 为 NULL 或 text_len 为 0 返回 0。
|
||||
*
|
||||
* 非法 UTF-8 序列按 Go 的 utf8 解码语义处理(每字节一个 rune),
|
||||
* 保证与 Go 侧逐值一致。 */
|
||||
int ha_codec_estimate_tokens(const char *text, size_t text_len);
|
||||
|
||||
/* 按 token 预算计算「应保留的字节数」。
|
||||
*
|
||||
* ★ 返回的是**字节数**而非字符串:截断结果必然是输入的前缀,
|
||||
* 调用方直接在自己的缓冲上切片即可(零拷贝、无出参缓冲区、无 malloc)。
|
||||
*
|
||||
* 语义与 Go 侧 TruncateByTokens 一致:从开头保留 maxTokens/2 个 rune;
|
||||
* 未超预算时返回 text_len(即整串)。
|
||||
* max_tokens <= 0 或 text 为 NULL/text_len 为 0 时返回 0。
|
||||
*
|
||||
* 返回值保证 <= text_len。 */
|
||||
size_t ha_codec_truncate_by_tokens(const char *text, size_t text_len,
|
||||
int max_tokens);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_CODEC_H */
|
||||
232
csrc/include/ha_json_scan.h
Normal file
232
csrc/include/ha_json_scan.h
Normal file
@ -0,0 +1,232 @@
|
||||
#ifndef HA_JSON_SCAN_H
|
||||
#define HA_JSON_SCAN_H
|
||||
|
||||
/*
|
||||
* ha_json_scan — HomeAgent 内核 LLM 协议层的 JSON 扫描/取值层(C 实现)
|
||||
*
|
||||
* ============================ 定位 ============================
|
||||
* 本库**不是**通用 JSON 库,是**流式协议分块解析**专用的零分配扫描层。
|
||||
* 它服务 `parseOpenAICompatibleStreamChunkFull`(每个 SSE chunk 跑一次的最热路径)。
|
||||
*
|
||||
* ★ 为什么不复用 SDK 的 remotedevice/src/ha_json.c(实测,见 plan.md §七):
|
||||
* 1. 无 `\u` 解码 —— `\u4f60\u597d` 得到 `?0?d?d?0`(LLM 内容全靠转义时直接损坏)
|
||||
* 2. 只有 `_get_int`,无浮点 —— `temperature:0.7` **静默**变 0
|
||||
* 3. `null` 与「键缺失」不可区分
|
||||
* 4. 架构是 **DOM + malloc**,与「不 malloc / 零拷贝 / 纯函数」正交
|
||||
* 它的定位是 remotedevice 设备通道,不是 LLM 协议层。
|
||||
*
|
||||
* ============================ 设计:scan / extract 两段分离 ============================
|
||||
* **scan** 只出结构 span(键 span / 值 span),零分配、零解码、零求值。
|
||||
* **extract** 按 span 取值,解码只发生在真正需要它的调用方身上。
|
||||
*
|
||||
* 为什么必须分离:`content` 可能是很大的多模态数组,而 `stringifyContent`
|
||||
* 只需要「把 text 字段拼起来」。若 scan 阶段就为每个字符串 `\u` 解码并分配
|
||||
* 缓冲,等于把解码成本付给了不需要它的调用方 —— 那正是我们要消灭的分配。
|
||||
*
|
||||
* ============================ 不可协商的约束(与 ha_codec.h 同标准) ============================
|
||||
* 1. 只吃 `const char*` + **显式长度**,不要求 NUL 结尾
|
||||
* (否则又是 `strlen` + 拷贝的老问题,见第一刀 §7.1 的 82% 自找开销)
|
||||
* 2. **不 malloc**:结果一律以 span(指针+长度)回给调用方,Go 侧零拷贝切片
|
||||
* 3. 无状态、纯函数、线程安全(不写全局可变状态)
|
||||
* 4. 语法语义必须与 Go `encoding/json` **一致**,由黄金对照测试钉死
|
||||
*
|
||||
* ============================ 语义对齐(易踩,全部实测) ============================
|
||||
* - 键匹配**大小写不敏感**(Go `encoding/json` 行为)
|
||||
* - 字符串取值时非法 UTF-8 每字节替换为 U+FFFD(与 Go 一致)
|
||||
* - 重复键**后者胜**
|
||||
* - 本层**不做类型检查**:`{"a":{}}` 对 `a` 的扫描成功,是否「类型不对应报错」
|
||||
* 由调用方按 Go 的 interface{} / 强类型语义决定(见 §三.2.2 的实测)
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "ha_abi.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ==================== ABI 版本(与 ha_abi.h 同步) ==================== */
|
||||
|
||||
#define HA_JSON_SCAN_ABI_MAJOR 1
|
||||
#define HA_JSON_SCAN_ABI_MINOR 0
|
||||
#define HA_JSON_SCAN_ABI_VERSION \
|
||||
(HA_JSON_SCAN_ABI_MAJOR * 1000 + HA_JSON_SCAN_ABI_MINOR)
|
||||
|
||||
HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MAJOR >= 1 && HA_JSON_SCAN_ABI_MAJOR <= 9,
|
||||
ha_jsonscan_abi_major_in_range);
|
||||
HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MINOR >= 0 && HA_JSON_SCAN_ABI_MINOR <= 99,
|
||||
ha_jsonscan_abi_minor_in_range);
|
||||
|
||||
/* 返回 HA_JSON_SCAN_ABI_VERSION(供 Go 侧与日志核对)。 */
|
||||
int ha_json_scan_abi_version(void);
|
||||
|
||||
/* ==================== span 与扫描器 ==================== */
|
||||
|
||||
/* 字节区间 [p, p+len)。指针指向**调用方的原缓冲**,本库从不持有或释放。 */
|
||||
typedef struct {
|
||||
const char *p;
|
||||
size_t len;
|
||||
} ha_span;
|
||||
|
||||
/* 扫描器:对一段 JSON 文本的只读游标。
|
||||
*
|
||||
* ★ 就地结构体(非指针):调用方在栈上持有,零分配。
|
||||
* 但因此**不可拷贝后混用**(拷贝出的副本与原游标各自独立推进)。
|
||||
*/
|
||||
typedef struct {
|
||||
const char *s; /* 缓冲区起点 */
|
||||
size_t n; /* 缓冲区长度 */
|
||||
size_t i; /* 当前游标偏移 */
|
||||
} ha_json_scan;
|
||||
|
||||
/* 用 (s, n) 初始化扫描器,游标置于起点。s 可为 NULL(此时按 n=0 处理)。 */
|
||||
void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n);
|
||||
|
||||
/* 跳过前导 ASCII 空白(空格 / \t / \n / \r)。返回是否已到结尾。 */
|
||||
int ha_json_scan_ws(ha_json_scan *sc);
|
||||
|
||||
/* 当前是否已到结尾(不含空白跳过)。 */
|
||||
int ha_json_scan_eof(const ha_json_scan *sc);
|
||||
|
||||
/* 跳过**一个完整的 JSON 值**(对象 / 数组 / 字符串 / 数字 / 字面量)。
|
||||
*
|
||||
* 用于二次进数组内部(如 stringifyContent 取数组元素的 text 字段):
|
||||
* 先 skip 前面的元素,再对目标元素单独扫描。
|
||||
* 返回 0 表示语法错误,1 表示成功。成功后游标停在该值之后。
|
||||
*/
|
||||
int ha_json_skip(ha_json_scan *sc);
|
||||
|
||||
/* 解析一个字符串值,出**原始字节 span**(含转义序列,未解码)。
|
||||
*
|
||||
* 入参:游标应停在 `"` 上(或之前的空白,函数自己跳过空白)。
|
||||
* 出参 raw:不含两端引号的原始内容 span(指向原缓冲,零拷贝)。
|
||||
* 返回 0 = 语法错误(未闭合 / 非字符串)。
|
||||
*
|
||||
* ★ 注意:不做 `\u` 解码、不做非法 UTF-8 替换 —— 那是 extract 阶段的事。
|
||||
*/
|
||||
int ha_json_scan_string(ha_json_scan *sc, ha_span *raw);
|
||||
|
||||
/* ==================== 顶层对象:扫描出键值对 ==================== */
|
||||
|
||||
/*
|
||||
* 顶层对象的迭代器。
|
||||
*
|
||||
* ★ 为什么由本库来切「顶层逗号」而不是让 C 侧只解析第一个键:
|
||||
* LLM 的 `content` 里常含 `{`、`}`、`,`(代码、JSON 片段、模板)。
|
||||
* 若调用方自己按逗号切开顶层,会被内容里的逗号错切。
|
||||
* 本库扫**字符串感知**的边界,保证只在真正的顶层分隔符处切分。
|
||||
*/
|
||||
typedef struct {
|
||||
ha_json_scan sc; /* 游标 */
|
||||
int started; /* 是否已消费过至少一个成员 */
|
||||
int done; /* 迭代是否已结束(正常或异常) */
|
||||
int error; /* 结束原因:1 = 输入畸形(而非正常的 '}') */
|
||||
} ha_json_members;
|
||||
|
||||
/* 初始化顶层对象迭代。非法(首个非空白字符不是 '{')时返回 0。 */
|
||||
int ha_json_members_init(ha_json_members *m, const char *s, size_t n);
|
||||
|
||||
/* 取下一个成员。
|
||||
*
|
||||
* 出参:
|
||||
* key —— 键的原始字节 span(未解码,不含引号);可为 NULL
|
||||
* val —— 值的**完整 span**(未解码);可为 NULL
|
||||
*
|
||||
* 返回: 1 = 拿到一个完整成员;0 = 结束。
|
||||
*
|
||||
* ★ 本函数**内部会完整跳过一个值**,因此:
|
||||
* 1. 返回 1 蕴含「这个成员是良构的」(值能独立被 skip)——
|
||||
* 调用方拿到的 val 一定可解析,不必自己再验一次。
|
||||
* 2. 游标在返回前已推进到值之后,下一次调用直接看下一个成员。
|
||||
* (早期版本只报值的**起始位置**、不消费值,迫使调用方自己
|
||||
* 修正游标 —— 那是个错误的设计:调用方一旦忘了推进,下一个
|
||||
* 成员就会解析到上一个值,而模糊测试立刻把它暴露了出来。)
|
||||
*
|
||||
* ★ 结束时要区分原因:用 ha_json_members_complete() 判断是否正常。
|
||||
* 返回 0 既可能是「正常扫到 '}'」也可能是「输入畸形」——
|
||||
* 要复刻 Go 的严格性(畸形 ⇒ 整块作废)就必须能分辨。
|
||||
*
|
||||
* ★ 键匹配请用 ha_json_key_eq(大小写不敏感),不要自己 memcmp。
|
||||
*/
|
||||
int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val);
|
||||
|
||||
/* 迭代是否**正常结束**(消费到闭合的 '}')。
|
||||
*
|
||||
* 语义:只有在 next() 返回 0 之后才有意义。
|
||||
* 返回 1 = 对象良构且已完整扫描;0 = 输入畸形(缺 '}' / 尾逗号 /
|
||||
* 值非法等)。调用方若要复刻 Go 的严格性,应要求它为 1。
|
||||
*/
|
||||
int ha_json_members_complete(const ha_json_members *m);
|
||||
|
||||
/* 大小写不敏感地比较键 span 与 ASCII 字面量。返回 1/0。
|
||||
*
|
||||
* ★ 必须用它而不是 memcmp:Go `encoding/json` 的键匹配**大小写不敏感**,
|
||||
* 实测 `{"delta":{"CONTENT":"up"}}` 能取出 content="up"。
|
||||
* 逐字节比对会静默漏掉这类输入。 */
|
||||
int ha_json_key_eq(ha_span key, const char *name);
|
||||
|
||||
/* ==================== 取值(extract) ==================== */
|
||||
|
||||
/* 字符串解码的**写入回调**。
|
||||
*
|
||||
* ★ 为什么用回调而不是「分配缓冲返回」:本库不 malloc。调用方把自己的
|
||||
* Go 侧 buffer / 栈缓冲 / 直接写目标的位置交给本库,解码结果逐个 rune
|
||||
* 以 UTF-8 字节写入 —— 非法序列按 Go 语义替换为 U+FFFD。
|
||||
*
|
||||
* ★ 为什么按 rune 而不是按字节:`\uXXXX` 可能产生多字节 rune(含代理对
|
||||
* 合成的 4 字节 emoji),调用方不该关心编码细节。
|
||||
*/
|
||||
typedef void (*ha_json_sink)(void *ctx, const char *utf8_bytes, size_t len);
|
||||
|
||||
/* 把字符串值 span(raw 形式,含转义)解码并经 sink 输出。
|
||||
*
|
||||
* 出参 out_len:解码后的字节总数(便于调用方预分配 / 校验)。
|
||||
* 返回 0 = 原始 span 含**语法错误**(如 \u 后不是 4 位十六进制)。
|
||||
*
|
||||
* 非法 UTF-8 处理:与 Go `encoding/json` 一致 —— 每个非法字节一个 U+FFFD
|
||||
* (**不是**按整个序列丢弃)。见真值表 §2.8。
|
||||
*/
|
||||
int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx,
|
||||
size_t *out_len);
|
||||
|
||||
/* 把字符串值 span 解码进调用方提供的缓冲(不足则失败,不截断)。
|
||||
*
|
||||
* 返回写入的字节数;缓冲不足时返回 (size_t)-1 且不写。
|
||||
* 适合长度已知且不关心「只需长度」的场景。
|
||||
*/
|
||||
size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap);
|
||||
|
||||
/* 读整数(仅接受 JSON 整数语法,可选负号;不允许小数点/指数)。
|
||||
*
|
||||
* ★ 与 Go 的对应关系:Go 里 `int` 字段会接受 `1e2`(=100)与拒绝 `1.5`;
|
||||
* 本函数**只认纯整数**,指数/小数由调用方按「类型不匹配 ⇒ 整块作废」
|
||||
* 语义处理(见真值表 §2.2)。这样职责清晰:本层只回答「这是不是整数」。
|
||||
*
|
||||
* 返回 1 = 成功且 *out 已写;0 = 不是合法整数。
|
||||
* 溢出返回 0(与 Go 报错等价)。
|
||||
*/
|
||||
int ha_json_get_int(ha_span raw, long long *out);
|
||||
|
||||
/* ==================== 字符串取值的便捷路径 ==================== */
|
||||
|
||||
/* 在对象 span 内取键 name 的字符串值,解码进 out(NUL 结尾)。
|
||||
*
|
||||
* 返回:解码后字节数(不含结尾 NUL);键缺失 / 类型不是字符串 / 缓冲不足
|
||||
* 返回 (size_t)-1。out 在成功时保证 NUL 结尾。
|
||||
*
|
||||
* 便捷函数:内部走 members 迭代 + key_eq + decode,适合调用方只取一两个键
|
||||
* 且不需要「类型不匹配 ⇒ 整块作废」细节的场景。
|
||||
*/
|
||||
size_t ha_json_object_get_string(ha_span obj, const char *name,
|
||||
char *out, size_t out_cap);
|
||||
|
||||
/* 在对象 span 内取键 name 的整数值。
|
||||
* 返回 1 = 成功;0 = 键缺失 / 非合法整数 / 溢出。 */
|
||||
int ha_json_object_get_int(ha_span obj, const char *name, long long *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_JSON_SCAN_H */
|
||||
218
csrc/include/ha_sse.h
Normal file
218
csrc/include/ha_sse.h
Normal file
@ -0,0 +1,218 @@
|
||||
#ifndef HA_SSE_H
|
||||
#define HA_SSE_H
|
||||
|
||||
/*
|
||||
* ha_sse — LLM 流式协议(SSE 分块)的「结构导航」辅助层
|
||||
*
|
||||
* ============================ 定位 ============================
|
||||
* 本层不是 JSON 库(那是 ha_json_scan),而是把 ha_json_scan 原语组合成
|
||||
* **协议层需要的几次定位**,供内核 parseOpenAICompatibleStreamChunkFull 使用。
|
||||
*
|
||||
* ★ 为什么这些函数放在 csrc/ 而不是内联在 Go 的 cgo 前言里:
|
||||
* 放在 cgo 前言里的 C 代码**逃出了全部 C 门禁**(告警 / ASan+UBSan /
|
||||
* 交叉编译 / 模糊测试),而它恰恰是本刀最容易出错的位置。
|
||||
* 移进 csrc/ 后,同一个 -Wall -Wextra -Wpedantic -Wconversion 门禁
|
||||
* 与 sanitizer 都覆盖到它 —— 这是一次真实的结构调整,不是形式主义。
|
||||
*
|
||||
* ============================ 为什么只做「导航」 ============================
|
||||
* 实测两条 wire 语义(docs/zh/c-core/sse-codec-c.md §5)使「全量 C 化」不成立:
|
||||
* §5.1 重复键是**字段级合并**(json.Unmarshal 的 SetIndex 叠加语义)
|
||||
* §5.2 stringifyContent 的 default 分支是 json.Marshal(键排序 / 浮点
|
||||
* 最短往返 / HTML 转义 / int 舍入)
|
||||
* 二者都只在**取值**阶段需要,故本层只回答「值在哪里、它的热分支结果是什么」,
|
||||
* 需要重新序列化的形态交回 Go(由 encoding/json 保证语义)。
|
||||
*
|
||||
* ============================ 键匹配:大小写敏感 ============================
|
||||
* 本层是 **map key** 语义(`m["text"]`)⇒ 大小写敏感。
|
||||
* 实测 `{"TEXT":"up"}` 取不到 `text`、`{"text":"low"}` 可以(§5.4-1)。
|
||||
*
|
||||
* ⚠️ 与 ha_json_key_eq(大小写**不**敏感,用于 struct 字段名)语义相反。
|
||||
* 两者用途不同、都必要,**不要「统一」掉**。
|
||||
* struct 字段那一跳由 encoding/json 负责,天然正确。
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "ha_abi.h"
|
||||
#include "ha_json_scan.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define HA_SSE_ABI_MAJOR 1
|
||||
#define HA_SSE_ABI_MINOR 1
|
||||
#define HA_SSE_ABI_VERSION (HA_SSE_ABI_MAJOR * 1000 + HA_SSE_ABI_MINOR)
|
||||
|
||||
HA_STATIC_ASSERT(HA_SSE_ABI_MAJOR >= 1 && HA_SSE_ABI_MAJOR <= 9,
|
||||
ha_sse_abi_major_in_range);
|
||||
HA_STATIC_ASSERT(HA_SSE_ABI_MINOR >= 0 && HA_SSE_ABI_MINOR <= 99,
|
||||
ha_sse_abi_minor_in_range);
|
||||
|
||||
int ha_sse_abi_version(void);
|
||||
|
||||
/*
|
||||
* 在对象里按**大小写敏感**的键定位值。
|
||||
*
|
||||
* 返回: 1 = 找到(*out 已写);0 = 未找到(对象良构);-1 = 对象畸形。
|
||||
* *dup 在发现**重复键**时置 1(后者胜已写入 *out)——
|
||||
* 调用方据此整体回退到 encoding/json,因为重复键的字段级合并语义
|
||||
* 见 sse-codec-c.md §5.1,本层不实现。
|
||||
*/
|
||||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
||||
ha_span *out, int *dup);
|
||||
|
||||
/*
|
||||
* 在对象里按**大小写不敏感**的键定位值(struct 字段语义)。
|
||||
*
|
||||
* ★ 为什么必须与 ha_sse_obj_find 并存(两个函数,语义相反):
|
||||
* - Go 的 `raw struct{ Choices ... \`json:"choices"\` }` 是 **struct 字段**,
|
||||
* encoding/json 对字段名做**大小写不敏感**匹配 ⇒ 实测
|
||||
* `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 能取到 content="ci"。
|
||||
* - 而 `content` 是 `interface{}` → `map[string]interface{}`,取 `m["text"]`
|
||||
* 是 **map key** 语义 ⇒ 大小写**敏感**(实测 `{"TEXT":"up"}` 取不到)。
|
||||
* 跳错层就会静默漏掉字段(或取到不该取的),故两个函数都必要,
|
||||
* 调用方必须按「这一跳在 Go 里是 struct 还是 map」来选择。
|
||||
*
|
||||
* 返回与 ha_sse_obj_find 相同:1=找到 0=未找到 -1=畸形。
|
||||
* 对 *dup:大小写不敏感语义下,`{"CHOICES":..,"choices":..}` 两次都会命中
|
||||
* 同一个 Go 字段(后者胜),故同样置 dup 让调用方回退。
|
||||
*/
|
||||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
||||
ha_span *out, int *dup);
|
||||
|
||||
/*
|
||||
* 校验 doc 是「**恰好一个**良构 JSON 对象」(尾部只允许空白)。
|
||||
*
|
||||
* 返回 1 = 是;0 = 否。
|
||||
*
|
||||
* ★ 为什么必须单独校验尾部:ha_sse_obj_find 用 members_complete 只保证
|
||||
* 对象本身闭合,**不检查尾部残留** —— 而 Go 的 json.Unmarshal 会拒绝
|
||||
* `{"a":1}{"b":2}`(trailing garbage)。少了这一步,快速路径会比 Go 宽松,
|
||||
* 把一个 Go 判为失败的块判为成功 ⇒ 静默接受垃圾块。
|
||||
*/
|
||||
int ha_sse_root_object(const ha_span *doc);
|
||||
|
||||
/*
|
||||
* 取数组**第一个元素**的 span。
|
||||
*
|
||||
* 返回: 1 = 有元素;0 = 空数组;-1 = 非数组或畸形。
|
||||
*
|
||||
* 为什么只要第一个:`choices[0]` 是协议约定(Go 侧也只读 resp.Choices[0]),
|
||||
* 本层据此避免为后续元素做无用功。
|
||||
*/
|
||||
int ha_sse_arr_first(const ha_span *arr, ha_span *out);
|
||||
|
||||
/*
|
||||
* stringifyContent 的 **C 可判定分支**:
|
||||
* - 字符串值 → 反转义后原样输出
|
||||
* - 数组值 → 逐元素取对象的 "text" 字段(精确键)拼接
|
||||
*
|
||||
* 返回: 1 = 已写入(*outlen 为字节数);0 = 需回退 Go。
|
||||
* 回退的两种情形:
|
||||
* a) 缓冲不足(调用方应给 >= val->len*3+4 的 cap)
|
||||
* b) 值类型是对象 / 数字 / 字面量 —— 那些要走 json.Marshal(§5.2)
|
||||
*
|
||||
* 数组元素的规则(§5.4-2/3 实测):
|
||||
* · 非对象元素 **静默跳过**(`["a",{"text":"b"}]` → "b")
|
||||
* · 非对象的 "text"(如 text:123)**静默跳过**
|
||||
* · 元素里出现重复的 "text" 键 ⇒ 整体回退 Go(合并语义)
|
||||
*/
|
||||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
||||
|
||||
/*
|
||||
* arguments 为**字符串**时,取出其解码结果(省掉 interface{} 与二次解析)。
|
||||
*
|
||||
* 返回 1 = 已写入;0 = 不是字符串或失败(调用方按既有路径处理)。
|
||||
* 非字符串 arguments(对象/数组/数字)**必须**回退 Go:那里的
|
||||
* `rawArgsString` 会 `json.Marshal` 重新编码,而这个**重新编码的键序
|
||||
* 可能与原文不同**(实测 {"b":2,"a":1} → {"a":1,"b":2})——
|
||||
* 逐值一致要求由 encoding/json 来做。
|
||||
*/
|
||||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen);
|
||||
|
||||
/* ==================================================================== */
|
||||
/* 批量定位:**一次调用**返回整块解析所需的全部字段 */
|
||||
/* ==================================================================== */
|
||||
/*
|
||||
* ★ 为什么需要它(第三刀实测的教训,见 sse-codec-c.md §六):
|
||||
* 逐字段往返做 5+ 次 cgo 调用,每次约 168ns 边界 + 2 allocs(out-param
|
||||
* 逃逸到堆)⇒ 约 1µs 固定成本,把全部收益吃光,结果比原实现更慢。
|
||||
*
|
||||
* 本接口把它压成 **1 次调用**,并顺带解决另外两点:
|
||||
* · **单趟键分派**:不再「每个键各扫一遍对象」,而是遍历一次成员表
|
||||
* 就分派(原来 6 次扫描 → 2 次)
|
||||
* · **解码内联**:content / reasoning_content 的解码在同一趟里写进
|
||||
* 调用方缓冲,不再各来一次往返
|
||||
*
|
||||
* 结果写在调用方的 ha_chunk_out 里(C 结构体、无 Go 指针 ⇒ 可安全传指针)。
|
||||
*/
|
||||
|
||||
/* 槽位索引(固定约定,**改动必须 bump ABI**)。 */
|
||||
#define HA_CHUNK_SLOT_DELTA 0
|
||||
#define HA_CHUNK_SLOT_CONTENT 1
|
||||
#define HA_CHUNK_SLOT_REASONING 2
|
||||
#define HA_CHUNK_SLOT_TOOL_CALLS 3
|
||||
#define HA_CHUNK_SLOT_FINISH_REASON 4
|
||||
#define HA_CHUNK_SLOT_USAGE 5
|
||||
#define HA_CHUNK_SLOT_COUNT 6
|
||||
|
||||
/* 槽位类型。与 Go 侧「该字段是什么 Go 类型」对应,而非单纯 JSON 类型。 */
|
||||
#define HA_CHUNK_KIND_ABSENT 0
|
||||
#define HA_CHUNK_KIND_NULL 1
|
||||
#define HA_CHUNK_KIND_STRING 2
|
||||
#define HA_CHUNK_KIND_OBJECT 3
|
||||
#define HA_CHUNK_KIND_ARRAY 4
|
||||
#define HA_CHUNK_KIND_OTHER 5 /* 数字 / 布尔 */
|
||||
|
||||
/* ha_sse_chunk_locate 返回码。 */
|
||||
#define HA_CHUNK_OK 0 /* 定位成功,可用快速路径 */
|
||||
#define HA_CHUNK_FALLBACK -1 /* 需回退 Go:重复键 / 畸形 / 顶层非对象 /
|
||||
* 多 choices / 缓冲不足 */
|
||||
#define HA_CHUNK_TYPE_FAIL -2 /* 与 Go 一致的「整块作废」(类型不符) */
|
||||
|
||||
typedef struct {
|
||||
ha_span span; /* 原始值 span(未解码,指向 data) */
|
||||
int kind; /* HA_CHUNK_KIND_* */
|
||||
} ha_chunk_slot;
|
||||
|
||||
typedef struct {
|
||||
ha_chunk_slot slot[HA_CHUNK_SLOT_COUNT];
|
||||
/* choices 数组本身的 span(choices_count>0 时有效) */
|
||||
ha_span choices_span;
|
||||
/* choices[0] 的 span(choices_count==1 时有效) */
|
||||
ha_span choice0_span;
|
||||
/* 解码/反转义结果(写入 sbuf,以 [off,len) 表示;kind 非字符串时为 (0,0)) */
|
||||
size_t content_off; size_t content_len;
|
||||
size_t reasoning_off; size_t reasoning_len;
|
||||
size_t finish_off; size_t finish_len;
|
||||
|
||||
int has_choices; /* choices 是否存在且非 null */
|
||||
int choices_kind; /* ABSENT / NULL / ARRAY */
|
||||
int choices_count; /* 元素个数(>1 时调用方必须回退,见下) */
|
||||
int choice0_kind; /* ABSENT / NULL / OBJECT */
|
||||
} ha_chunk_out;
|
||||
|
||||
/*
|
||||
* 一次调用定位整块解析所需的全部字段。
|
||||
*
|
||||
* data/len : SSE chunk 原始字节(不需要 NUL 结尾)
|
||||
* out : 输出(调用方持有;C 只在本调用内写它)
|
||||
* sbuf/scap : 解码输出缓冲(content / reasoning_content / finish_reason)
|
||||
* sused : 出参,缓冲区实际用量
|
||||
*
|
||||
* 返回 HA_CHUNK_OK / HA_CHUNK_FALLBACK / HA_CHUNK_TYPE_FAIL。
|
||||
*
|
||||
* ★ 调用方**必须**检查 choices_count:Go 侧是 `[]struct`,Unmarshal 会解析
|
||||
* **全部**元素,而本层只取 [0](协议约定)。若元素 >1,本层无法保证
|
||||
* 其余元素也能被 Go 解析(它们可能有类型错误)⇒ 必须回退。
|
||||
* 本函数在 choices_count>1 时**直接返回 FALLBACK**,不给调用方犯错的机会。
|
||||
*/
|
||||
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
|
||||
char *sbuf, size_t scap, size_t *sused);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* HA_SSE_H */
|
||||
345
csrc/src/ha_codec.c
Normal file
345
csrc/src/ha_codec.c
Normal file
@ -0,0 +1,345 @@
|
||||
/*
|
||||
* ha_codec.c — HomeAgent 内核编解码层(C 实现)
|
||||
*
|
||||
* ============================ 性能设计(勿回退)============================
|
||||
* 1. **不 malloc**:模型名折叠用栈缓冲(短名走快路径,超长走零分配的回退)。
|
||||
* 2. **不 strlen**:长度由调用方传入(见 ha_codec.h 签名说明)。
|
||||
* 3. **ASCII 批量快路径**:连续 ASCII 成批计数,避免逐字节函数调用。
|
||||
* 4. **截断提前短路**:数满 keep 个 rune 立即返回,不扫完整串。
|
||||
* 5. **截断返回字节数**而非字符串:结果必然是输入前缀,调用方自己切片。
|
||||
*
|
||||
* 初版的三个反例(实测代价,见 docs/zh/c-core/llm-orchestration-c.md §7.1):
|
||||
* - Go 侧 C.CString(malloc+拷贝)+ C 侧 strlen,单这一项约 75ns,
|
||||
* 而 cgo 边界本身仅约 32ns —— 即 **82% 的开销是自找的**,不是 cgo 的成本。
|
||||
* 初版由此得出「C 比 Go 慢」的结论是错的。
|
||||
* - 逐字节 utf8_next 函数调用 ⇒ 1KB ASCII 比纯 Go 慢 7 倍。
|
||||
* - 1KB 中文要先扫完整串才判断是否截断。
|
||||
*
|
||||
* 语义必须与 Go 侧实现逐值一致,由黄金对照测试钉死(含畸形 UTF-8)。
|
||||
*/
|
||||
|
||||
#include "ha_codec.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 大小写不敏感的子串匹配 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* 只折 ASCII 字母;非 ASCII 字节原样(与 Go strings.ToLower 对模型名的
|
||||
* 实际效果一致——模型名都是 ASCII,中文/日文字节不受 ToLower 影响)。 */
|
||||
static unsigned char ascii_lower(unsigned char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
||||
}
|
||||
|
||||
/* 已折叠缓冲(长度 hn)中是否含子串 sub(sub 必须已小写、ASCII)。
|
||||
* memcmp 版本:折叠一次后可向量化比较,是短名快路径。 */
|
||||
static int contains(const char *m, size_t hn, const char *sub) {
|
||||
size_t m_len = strlen(sub);
|
||||
if (m_len == 0 || hn < m_len) {
|
||||
return 0;
|
||||
}
|
||||
size_t last = hn - m_len;
|
||||
for (size_t i = 0; i <= last; i++) {
|
||||
/* 首字节过滤掉绝大多数位置,避免无谓 memcmp */
|
||||
if (m[i] == sub[0] && memcmp(m + i, sub, m_len) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 边比较边折叠:**任意长度**都正确,无需缓冲(超长模型名的回退路径)。
|
||||
* sub 中的 ASCII 字母按小写处理;非 ASCII 字节按字节精确比较
|
||||
* (因此可直接用于 "\xe9\x9b\xb6\xe4\xb8\x80" 这类多字节字面量)。 */
|
||||
static int contains_ci(const char *h, size_t hn, const char *sub) {
|
||||
size_t m_len = strlen(sub);
|
||||
if (m_len == 0 || hn < m_len) {
|
||||
return 0;
|
||||
}
|
||||
size_t last = hn - m_len;
|
||||
for (size_t i = 0; i <= last; i++) {
|
||||
size_t j = 0;
|
||||
while (j < m_len &&
|
||||
ascii_lower((unsigned char)h[i + j]) == (unsigned char)sub[j]) {
|
||||
j++;
|
||||
}
|
||||
if (j == m_len) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 模型名的不可变视图:能进栈缓冲就折叠,否则按原样(用 contains_ci 匹配)。 */
|
||||
typedef struct {
|
||||
const char *p;
|
||||
size_t n;
|
||||
int folded;
|
||||
} model_view;
|
||||
|
||||
/* 栈缓冲容量:模型名实测都是几十字节。超出则退化为不折叠 +
|
||||
* contains_ci —— 仍**零分配且语义正确**,只是少了 memcmp 的向量化优势。 */
|
||||
#define HA_MODEL_STACK 256
|
||||
|
||||
static int mv_contains(const model_view *v, const char *sub) {
|
||||
return v->folded ? contains(v->p, v->n, sub) : contains_ci(v->p, v->n, sub);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 模型上下文窗口推断 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int ha_codec_model_context_window(const char *model, size_t model_len) {
|
||||
if (model == NULL || model_len == 0) {
|
||||
return HA_CODEC_CONTEXT_WINDOW_UNKNOWN;
|
||||
}
|
||||
|
||||
char stack[HA_MODEL_STACK];
|
||||
model_view v;
|
||||
if (model_len < HA_MODEL_STACK) {
|
||||
for (size_t i = 0; i < model_len; i++) {
|
||||
stack[i] = (char)ascii_lower((unsigned char)model[i]);
|
||||
}
|
||||
stack[model_len] = '\0';
|
||||
v.p = stack;
|
||||
v.n = model_len;
|
||||
v.folded = 1;
|
||||
} else {
|
||||
v.p = model;
|
||||
v.n = model_len;
|
||||
v.folded = 0;
|
||||
}
|
||||
|
||||
/* 顺序与 Go 侧 switch 分支**严格一致**:先匹配到的分支胜出。
|
||||
* 这不是「随便一组 if」,顺序错了就会给出不同窗口
|
||||
* (例:gpt-4-turbo 必须先于裸 gpt-4 命中)。 */
|
||||
if (mv_contains(&v, "deepseek-v4") || mv_contains(&v, "deepseek-v3")) {
|
||||
return 1048576;
|
||||
}
|
||||
if (mv_contains(&v, "deepseek-r1") || mv_contains(&v, "deepseek-chat")) {
|
||||
return 65536;
|
||||
}
|
||||
if (mv_contains(&v, "gpt-4")) {
|
||||
if (mv_contains(&v, "turbo") || mv_contains(&v, "mini") || mv_contains(&v, "omni")) {
|
||||
return 128000;
|
||||
}
|
||||
return 8192;
|
||||
}
|
||||
if (mv_contains(&v, "gpt-3.5")) {
|
||||
return 16384;
|
||||
}
|
||||
if (mv_contains(&v, "claude-3.5") || mv_contains(&v, "claude-3")) {
|
||||
return 200000;
|
||||
}
|
||||
if (mv_contains(&v, "claude")) {
|
||||
return 100000;
|
||||
}
|
||||
if (mv_contains(&v, "gemini-1.5") || mv_contains(&v, "gemini-2")) {
|
||||
return 1048576;
|
||||
}
|
||||
if (mv_contains(&v, "gemini")) {
|
||||
return 32768;
|
||||
}
|
||||
if (mv_contains(&v, "qwen")) {
|
||||
return 131072;
|
||||
}
|
||||
if (mv_contains(&v, "glm") || mv_contains(&v, "chatglm")) {
|
||||
return 131072;
|
||||
}
|
||||
if (mv_contains(&v, "llama-3")) {
|
||||
return 8192;
|
||||
}
|
||||
if (mv_contains(&v, "llama-2")) {
|
||||
return 4096;
|
||||
}
|
||||
if (mv_contains(&v, "mistral") || mv_contains(&v, "mixtral")) {
|
||||
return 32768;
|
||||
}
|
||||
/* "yi-" 与 "零一"(UTF-8 字面量)——contains_ci 对字节精确比较,
|
||||
* 故中文部分不受折叠影响,与 Go 的 strings.Contains 一致。 */
|
||||
if (mv_contains(&v, "yi-") || mv_contains(&v, "\xe9\x9b\xb6\xe4\xb8\x80")) {
|
||||
return 200000;
|
||||
}
|
||||
if (mv_contains(&v, "moonshot") || mv_contains(&v, "kimi")) {
|
||||
return 131072;
|
||||
}
|
||||
|
||||
return HA_CODEC_CONTEXT_WINDOW_UNKNOWN;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* UTF-8 解码(与 Go utf8.DecodeRuneInString 逐值等价) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* 返回 s[0] 起始字符的字节长度(1..4)。
|
||||
*
|
||||
* 必须与 Go 的 utf8.DecodeRuneInString 语义一致——**包括无效序列只前进
|
||||
* 1 字节**(Go 对无效/截断序列返回 RuneError 且 size=1),否则 rune 计数
|
||||
* 会与 Go 分叉。这正是黄金对照测试用畸形输入能抓到的地方。
|
||||
*
|
||||
* remaining 是当前可读字节数。 */
|
||||
static inline size_t utf8_char_len(const char *s, size_t remaining) {
|
||||
unsigned char c0 = (unsigned char)s[0];
|
||||
|
||||
if (c0 < 0x80) {
|
||||
return 1; /* ASCII */
|
||||
}
|
||||
if (c0 < 0xC2) {
|
||||
return 1; /* 0x80..0xC1:续字节或过长编码 → Go 判无效,size=1 */
|
||||
}
|
||||
|
||||
if (c0 < 0xE0) { /* 2 字节:0xC2..0xDF */
|
||||
if (remaining < 2) {
|
||||
return 1;
|
||||
}
|
||||
if (((unsigned char)s[1] & 0xC0) != 0x80) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (c0 < 0xF0) { /* 3 字节:0xE0..0xEF */
|
||||
if (remaining < 3) {
|
||||
return 1;
|
||||
}
|
||||
/* 用 (c & 0xC0) == 0x80 走单条 AND+CMP(而非两条范围比较),
|
||||
* 并用 & 而非 && 避免短路分支——这是 CJK 主路径,须最短。 */
|
||||
unsigned char c1 = (unsigned char)s[1];
|
||||
unsigned char c2 = (unsigned char)s[2];
|
||||
if (((c1 & 0xC0) == 0x80) & ((c2 & 0xC0) == 0x80)) {
|
||||
/* 常见情形:既非 0xE0(防过长编码)也非 0xED(防代理对) */
|
||||
if (c0 != 0xE0 && c0 != 0xED) {
|
||||
return 3;
|
||||
}
|
||||
if ((c0 == 0xE0 && c1 >= 0xA0) || (c0 == 0xED && c1 <= 0x9F)) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (c0 < 0xF5) { /* 4 字节:0xF0..0xF4 */
|
||||
if (remaining < 4) {
|
||||
return 1;
|
||||
}
|
||||
unsigned char c1 = (unsigned char)s[1];
|
||||
unsigned char c2 = (unsigned char)s[2];
|
||||
unsigned char c3 = (unsigned char)s[3];
|
||||
if (((c1 & 0xC0) == 0x80) & ((c2 & 0xC0) == 0x80) & ((c3 & 0xC0) == 0x80)) {
|
||||
if (c0 != 0xF0 && c0 != 0xF4) {
|
||||
return 4;
|
||||
}
|
||||
if ((c0 == 0xF0 && c1 >= 0x90) || (c0 == 0xF4 && c1 <= 0x8F)) {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1; /* 0xF5..0xFF:无效 */
|
||||
}
|
||||
|
||||
/* ASCII 批量扫描:返回从 text[i] 起连续 ASCII 的字节数(扫到串尾)。
|
||||
*
|
||||
* ★ 字(word)级探测:一次读 8 字节,用单条掩码判断「8 字节是否全为 ASCII」。
|
||||
* 逐字节比较会让 1KB ASCII 明显慢于纯 Go(后者内部有 8 字节快路径)。
|
||||
* 实测:逐字节版 ascii_1k 约 2318ns(比 Go 慢 7×),改字级后大幅收敛。 */
|
||||
#define HA_HIGH_BITS 0x8080808080808080ULL
|
||||
|
||||
static size_t ascii_run(const char *text, size_t i, size_t len) {
|
||||
size_t j = i;
|
||||
while (j + 8 <= len) {
|
||||
uint64_t v;
|
||||
memcpy(&v, text + j, 8); /* memcpy 让编译器按需生成未对齐安全加载 */
|
||||
if (v & HA_HIGH_BITS) {
|
||||
break;
|
||||
}
|
||||
j += 8;
|
||||
}
|
||||
while (j < len && (unsigned char)text[j] < 0x80) {
|
||||
j++;
|
||||
}
|
||||
return j - i;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* token 估算 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int ha_codec_estimate_tokens(const char *text, size_t text_len) {
|
||||
if (text == NULL || text_len == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t runes = 0;
|
||||
size_t i = 0;
|
||||
while (i < text_len) {
|
||||
if ((unsigned char)text[i] < 0x80) {
|
||||
size_t n = ascii_run(text, i, text_len);
|
||||
runes += n;
|
||||
i += n;
|
||||
} else {
|
||||
i += utf8_char_len(text + i, text_len - i);
|
||||
runes++;
|
||||
}
|
||||
}
|
||||
|
||||
/* 与 Go 侧一致:t = runeCount * 2;t < 1 时取 1。
|
||||
* runes > 0 时 t >= 2,故只需处理溢出与下限。 */
|
||||
if (runes > (size_t)0x3FFFFFFF) { /* 防 int 溢出 */
|
||||
return 0x7FFFFFFF;
|
||||
}
|
||||
int t = (int)(runes * 2);
|
||||
if (t < 1) {
|
||||
return 1;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 按 token 预算计算应保留的字节数 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
size_t ha_codec_truncate_by_tokens(const char *text, size_t text_len,
|
||||
int max_tokens) {
|
||||
if (text == NULL || text_len == 0 || max_tokens <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 要保留的 rune 数(与 Go 一致:整数除法)。keep==0 时循环首轮即返回 0。 */
|
||||
size_t keep = (size_t)(max_tokens / 2);
|
||||
|
||||
/* 提前短路:keep 个 rune 数满而串仍有剩余 ⇒ 必然截断,直接返回该字节边界,
|
||||
* 不必扫完整串(长文本上的主要收益)。
|
||||
* 若数完整串仍未数满 keep ⇒ 未超预算,返回全长(= 不截断)。 */
|
||||
size_t runes = 0;
|
||||
size_t i = 0;
|
||||
while (i < text_len) {
|
||||
if (runes == keep) {
|
||||
return i;
|
||||
}
|
||||
if ((unsigned char)text[i] < 0x80) {
|
||||
size_t n = ascii_run(text, i, text_len);
|
||||
if (runes + n >= keep) {
|
||||
/* keep 落在这批 ASCII 内:批内每字节一个 rune */
|
||||
return i + (keep - runes);
|
||||
}
|
||||
runes += n;
|
||||
i += n;
|
||||
} else {
|
||||
runes++;
|
||||
i += utf8_char_len(text + i, text_len - i);
|
||||
}
|
||||
}
|
||||
return text_len; /* 未超预算:整串都留 */
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* ABI 自述 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int ha_codec_abi_version(void) {
|
||||
return HA_CODEC_ABI_VERSION;
|
||||
}
|
||||
825
csrc/src/ha_json_scan.c
Normal file
825
csrc/src/ha_json_scan.c
Normal file
@ -0,0 +1,825 @@
|
||||
/*
|
||||
* ha_json_scan.c — HomeAgent 内核 LLM 协议层 JSON 扫描/取值(C 实现)
|
||||
*
|
||||
* ============================ 性能设计(勿回退) ============================
|
||||
* 1. **不 malloc**:一切结果以 span 回传,Go 侧零拷贝切片
|
||||
* 2. **不 strlen**:长度由调用方传入
|
||||
* 3. **不预扫**:scan 只在需要时前进一步;「找键」靠 members 迭代单趟,
|
||||
* 不先扫一遍收集全部键(那会缓存踩踏 + 二次遍历)
|
||||
* 4. **整数不走 strtoll**:strtoll 要 NUL 结尾或处理 locale,
|
||||
* 自写定点解析只认 JSON 整数语法,顺带把溢出判掉
|
||||
* 5. **字符串不建索引**:不记录转义位置。需要时按需解码
|
||||
*
|
||||
* 参照第一刀的教训(docs/zh/c-core/llm-orchestration-c.md §7.1):
|
||||
* 初版每次调用 C.CString(malloc+拷贝)+ C 侧 strlen,单这两项就吃掉
|
||||
* 82% 的时间 —— 那不是 cgo 的固有成本,是自找的。本库从设计上排除这类开销。
|
||||
*
|
||||
* 语义必须与 Go `encoding/json` 一致,由黄金对照测试钉死
|
||||
* (真值表见 docs/zh/c-core/sse-codec-c.md §二)。
|
||||
*/
|
||||
|
||||
#include "ha_json_scan.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* ABI 自述 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int ha_json_scan_abi_version(void) {
|
||||
return HA_JSON_SCAN_ABI_VERSION;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 基础工具 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* JSON 空白:Go 的 encoding/json 只认这四个(不是 isspace)。
|
||||
* 差一个字符就会与 Go 分叉,故显式列举而非用 ctype。 */
|
||||
static int is_ws(unsigned char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
static unsigned char ascii_lower(unsigned char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
||||
}
|
||||
|
||||
void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n) {
|
||||
if (sc == NULL) {
|
||||
return;
|
||||
}
|
||||
sc->s = (s != NULL) ? s : "";
|
||||
sc->n = (s != NULL) ? n : 0;
|
||||
sc->i = 0;
|
||||
}
|
||||
|
||||
int ha_json_scan_ws(ha_json_scan *sc) {
|
||||
if (sc == NULL) {
|
||||
return 1;
|
||||
}
|
||||
while (sc->i < sc->n && is_ws((unsigned char)sc->s[sc->i])) {
|
||||
sc->i++;
|
||||
}
|
||||
return (sc->i < sc->n) ? 0 : 1;
|
||||
}
|
||||
|
||||
int ha_json_scan_eof(const ha_json_scan *sc) {
|
||||
if (sc == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return (sc->i >= sc->n) ? 1 : 0;
|
||||
}
|
||||
|
||||
/* 当前字符;到结尾返回 '\0'(0)。调用方需先判 eof。 */
|
||||
static char peek(const ha_json_scan *sc) {
|
||||
return (sc->i < sc->n) ? sc->s[sc->i] : '\0';
|
||||
}
|
||||
|
||||
/* 前进一字节;越界时不动(保持 eof 语义稳定)。 */
|
||||
static void bump(ha_json_scan *sc) {
|
||||
if (sc->i < sc->n) {
|
||||
sc->i++;
|
||||
}
|
||||
}
|
||||
|
||||
static int expect(ha_json_scan *sc, char c) {
|
||||
if (ha_json_scan_ws(sc) || peek(sc) != c) {
|
||||
return 0;
|
||||
}
|
||||
bump(sc);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 值扫描(skip 一个完整值) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
static int scan_value(ha_json_scan *sc, int depth);
|
||||
static int hex_val(unsigned char c);
|
||||
|
||||
/* 扫描字符串(含引号),出原始内容 span。
|
||||
* depth 传入是因为 scan_value 会递归;字符串本身不递归但需要限额。 */
|
||||
static int scan_string_raw(ha_json_scan *sc, ha_span *raw, int depth) {
|
||||
if (depth > 128) {
|
||||
return 0; /* 深度保险,正常文档远小于此 */
|
||||
}
|
||||
if (ha_json_scan_ws(sc) || peek(sc) != '"') {
|
||||
return 0;
|
||||
}
|
||||
bump(sc); /* 开引号 */
|
||||
size_t start = sc->i;
|
||||
while (sc->i < sc->n) {
|
||||
char c = sc->s[sc->i];
|
||||
if (c == '"') {
|
||||
if (raw != NULL) {
|
||||
raw->p = sc->s + start;
|
||||
raw->len = sc->i - start;
|
||||
}
|
||||
bump(sc); /* 闭引号 */
|
||||
return 1;
|
||||
}
|
||||
if (c == '\\') {
|
||||
bump(sc);
|
||||
if (sc->i >= sc->n) {
|
||||
return 0; /* 末尾悬空反斜杠 */
|
||||
}
|
||||
/* ★ 必须校验转义字符本身合法:Go 的 unquoteBytes 对未知转义
|
||||
* (\q、\x、单独 \p)返回错误 ⇒ 整个 Unmarshal 失败。
|
||||
* 初版只 bump 不校验,于是 `{"a":"\q"}` 被 C 判为合法,
|
||||
* 而 json.Valid=false —— 黄金对照当场抓到。
|
||||
* (`\u` 的 4 位十六进制在解码阶段校验:那是**值**层面的
|
||||
* 错误,与扫描阶段的「转义序列形状」是两回事。) */
|
||||
char e = sc->s[sc->i];
|
||||
if (e != '"' && e != '\\' && e != '/' && e != 'b' && e != 'f' &&
|
||||
e != 'n' && e != 'r' && e != 't' && e != 'u') {
|
||||
return 0;
|
||||
}
|
||||
/* ★ `\u` 必须紧跟 **4 位十六进制**,且这一校验属于**扫描**阶段:
|
||||
* Go 的 json.Valid 会拒绝 `{"a":"\u00"}`(不足 4 位),
|
||||
* 而初版把它留到解码阶段 ⇒ scan 判合法、json.Valid 判非法,
|
||||
* 黄金对照当场抓到这条分叉。
|
||||
* 校验放在扫描阶段还有一个好处:畸形的 wire 数据在
|
||||
* 「找键」阶段就被拒,不必等到取值。 */
|
||||
if (e == 'u') {
|
||||
/* 用 size_t 递推偏移,避免 int 与 size_t 混算
|
||||
* (-Wconversion/-Wsign-conversion 会拦下 sign-change)。 */
|
||||
if (sc->n - sc->i < 5u) {
|
||||
return 0; /* 位数不足:还需 'u' 之后 4 位 */
|
||||
}
|
||||
for (size_t k = 1; k <= 4u; k++) {
|
||||
if (hex_val((unsigned char)sc->s[sc->i + k]) < 0) {
|
||||
return 0; /* 非十六进制 */
|
||||
}
|
||||
}
|
||||
}
|
||||
bump(sc); /* 被转义的字符;\u 的 4 位十六进制由上面的循环覆盖 */
|
||||
continue;
|
||||
}
|
||||
if ((unsigned char)c < 0x20) {
|
||||
return 0; /* Go 拒绝字符串里的裸控制字符 */
|
||||
}
|
||||
bump(sc);
|
||||
}
|
||||
return 0; /* 未闭合 */
|
||||
}
|
||||
|
||||
/* 扫描字面量:true / false / null。 */
|
||||
static int scan_literal(ha_json_scan *sc) {
|
||||
static const char kTrue[] = "true";
|
||||
static const char kFalse[] = "false";
|
||||
static const char kNull[] = "null";
|
||||
size_t rest = sc->n - sc->i;
|
||||
const char *p = sc->s + sc->i;
|
||||
|
||||
if (rest >= 4 && memcmp(p, kTrue, 4) == 0) {
|
||||
sc->i += 4;
|
||||
return 1;
|
||||
}
|
||||
if (rest >= 5 && memcmp(p, kFalse, 5) == 0) {
|
||||
sc->i += 5;
|
||||
return 1;
|
||||
}
|
||||
if (rest >= 4 && memcmp(p, kNull, 4) == 0) {
|
||||
sc->i += 4;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 数字:只校验**语法**(不求值)。求值由 ha_json_get_int / 调用方负责。
|
||||
* 这与 Go 的分工一致:Go 在 unmarshal 时求值并做范围检查,
|
||||
* 而本层的取整数是独立的一步。 */
|
||||
static int scan_number(ha_json_scan *sc) {
|
||||
size_t start = sc->i;
|
||||
if (sc->i < sc->n && peek(sc) == '-') {
|
||||
bump(sc);
|
||||
}
|
||||
/* 整数部分:0 或 [1-9][0-9]*(禁止前导零,与 Go 一致) */
|
||||
if (sc->i >= sc->n) {
|
||||
return 0;
|
||||
}
|
||||
if (peek(sc) == '0') {
|
||||
bump(sc);
|
||||
} else if (peek(sc) >= '1' && peek(sc) <= '9') {
|
||||
while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') {
|
||||
bump(sc);
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
/* 小数部分 */
|
||||
if (sc->i < sc->n && peek(sc) == '.') {
|
||||
bump(sc);
|
||||
if (sc->i >= sc->n || peek(sc) < '0' || peek(sc) > '9') {
|
||||
return 0; /* "1." 与 "1.e3" 非法 */
|
||||
}
|
||||
while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') {
|
||||
bump(sc);
|
||||
}
|
||||
}
|
||||
/* 指数部分 */
|
||||
if (sc->i < sc->n && (peek(sc) == 'e' || peek(sc) == 'E')) {
|
||||
bump(sc);
|
||||
if (sc->i < sc->n && (peek(sc) == '+' || peek(sc) == '-')) {
|
||||
bump(sc);
|
||||
}
|
||||
if (sc->i >= sc->n || peek(sc) < '0' || peek(sc) > '9') {
|
||||
return 0;
|
||||
}
|
||||
while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') {
|
||||
bump(sc);
|
||||
}
|
||||
}
|
||||
return (sc->i > start) ? 1 : 0;
|
||||
}
|
||||
|
||||
/* 扫描数组/对象。用显式 depth 递归(不用堆栈,零分配)。 */
|
||||
static int scan_container(ha_json_scan *sc, char open, char close, int depth) {
|
||||
if (!expect(sc, open)) {
|
||||
return 0;
|
||||
}
|
||||
if (ha_json_scan_ws(sc)) {
|
||||
return 0; /* 未闭合 */
|
||||
}
|
||||
if (peek(sc) == close) {
|
||||
bump(sc);
|
||||
return 1; /* 空容器 */
|
||||
}
|
||||
for (;;) {
|
||||
if (open == '{') {
|
||||
ha_span k;
|
||||
if (!scan_string_raw(sc, &k, depth + 1)) {
|
||||
return 0;
|
||||
}
|
||||
if (!expect(sc, ':')) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (!scan_value(sc, depth + 1)) {
|
||||
return 0;
|
||||
}
|
||||
if (ha_json_scan_ws(sc)) {
|
||||
return 0;
|
||||
}
|
||||
if (peek(sc) == ',') {
|
||||
bump(sc);
|
||||
continue;
|
||||
}
|
||||
if (peek(sc) == close) {
|
||||
bump(sc);
|
||||
return 1;
|
||||
}
|
||||
return 0; /* 缺 '}' 或多余的 ',' 之后没有键 */
|
||||
}
|
||||
}
|
||||
|
||||
static int scan_value(ha_json_scan *sc, int depth) {
|
||||
if (depth > 128) {
|
||||
return 0;
|
||||
}
|
||||
if (ha_json_scan_ws(sc)) {
|
||||
return 0;
|
||||
}
|
||||
char c = peek(sc);
|
||||
switch (c) {
|
||||
case '{': return scan_container(sc, '{', '}', depth);
|
||||
case '[': return scan_container(sc, '[', ']', depth);
|
||||
case '"': {
|
||||
ha_span tmp;
|
||||
return scan_string_raw(sc, &tmp, depth);
|
||||
}
|
||||
case 't': case 'f': case 'n': return scan_literal(sc);
|
||||
default:
|
||||
if (c == '-' || (c >= '0' && c <= '9')) {
|
||||
return scan_number(sc);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int ha_json_skip(ha_json_scan *sc) {
|
||||
if (sc == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return scan_value(sc, 0);
|
||||
}
|
||||
|
||||
int ha_json_scan_string(ha_json_scan *sc, ha_span *raw) {
|
||||
if (sc == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return scan_string_raw(sc, raw, 0);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 顶层对象成员迭代 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int ha_json_members_init(ha_json_members *m, const char *s, size_t n) {
|
||||
if (m == NULL) {
|
||||
return 0;
|
||||
}
|
||||
ha_json_scan_init(&m->sc, s, n);
|
||||
m->started = 0;
|
||||
m->done = 0;
|
||||
m->error = 0;
|
||||
if (ha_json_scan_ws(&m->sc) || peek(&m->sc) != '{') {
|
||||
return 0;
|
||||
}
|
||||
bump(&m->sc);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val) {
|
||||
if (m == NULL || m->done) {
|
||||
return 0;
|
||||
}
|
||||
if (ha_json_scan_ws(&m->sc)) {
|
||||
m->done = 1;
|
||||
m->error = 1; /* 未闭合 */
|
||||
return 0;
|
||||
}
|
||||
if (peek(&m->sc) == '}') {
|
||||
bump(&m->sc);
|
||||
m->done = 1;
|
||||
m->error = 0; /* 正常结束 */
|
||||
return 0; /* 没有更多成员 */
|
||||
}
|
||||
/* ★ 不接受尾逗号:Go 的 decoder 在 ',' 之后要求必有下一个键。
|
||||
* `{"a":1,}` 在 Go 侧是语法错误,故这里也必须拒绝。 */
|
||||
if (m->started) {
|
||||
if (peek(&m->sc) != ',') {
|
||||
m->done = 1;
|
||||
m->error = 1;
|
||||
return 0;
|
||||
}
|
||||
bump(&m->sc);
|
||||
if (ha_json_scan_ws(&m->sc)) {
|
||||
m->done = 1;
|
||||
m->error = 1;
|
||||
return 0;
|
||||
}
|
||||
if (peek(&m->sc) == '}') {
|
||||
m->done = 1;
|
||||
m->error = 1; /* 尾逗号 */
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
ha_span k;
|
||||
if (!scan_string_raw(&m->sc, &k, 0)) {
|
||||
m->done = 1;
|
||||
m->error = 1;
|
||||
return 0;
|
||||
}
|
||||
if (!expect(&m->sc, ':')) {
|
||||
m->done = 1;
|
||||
m->error = 1;
|
||||
return 0;
|
||||
}
|
||||
if (ha_json_scan_ws(&m->sc)) {
|
||||
m->done = 1;
|
||||
m->error = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ★ 就地完整跳过一个值,得到它的精确 span。
|
||||
* 这样「返回 1」就蕴含「该成员良构」,且游标已推进到值之后。 */
|
||||
size_t vstart = m->sc.i;
|
||||
if (!scan_value(&m->sc, 0)) {
|
||||
m->done = 1;
|
||||
m->error = 1;
|
||||
return 0;
|
||||
}
|
||||
size_t vend = m->sc.i;
|
||||
|
||||
m->started = 1;
|
||||
if (key != NULL) {
|
||||
*key = k;
|
||||
}
|
||||
if (val != NULL) {
|
||||
val->p = m->sc.s + vstart;
|
||||
val->len = vend - vstart;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ha_json_members_complete(const ha_json_members *m) {
|
||||
if (m == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return (m->done && !m->error) ? 1 : 0;
|
||||
}
|
||||
|
||||
int ha_json_key_eq(ha_span key, const char *name) {
|
||||
if (name == NULL) {
|
||||
return 0;
|
||||
}
|
||||
size_t nl = 0;
|
||||
while (name[nl] != '\0') {
|
||||
nl++;
|
||||
}
|
||||
if (key.len != nl) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t i = 0; i < nl; i++) {
|
||||
if (ascii_lower((unsigned char)key.p[i]) !=
|
||||
ascii_lower((unsigned char)name[i])) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 字符串解码 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* U+FFFD 的 UTF-8 编码(Go 对非法字节的替换目标)。 */
|
||||
static const char kReplacement[3] = { (char)0xEF, (char)0xBF, (char)0xBD };
|
||||
|
||||
/* 十六进制值;非十六进制返回 -1。 */
|
||||
static int hex_val(unsigned char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 把码点编码成 UTF-8 写给 sink。返回写入字节数。 */
|
||||
static size_t emit_rune(unsigned long cp, ha_json_sink sink, void *ctx) {
|
||||
unsigned char buf[4];
|
||||
size_t len;
|
||||
if (cp < 0x80) {
|
||||
buf[0] = (unsigned char)cp;
|
||||
len = 1;
|
||||
} else if (cp < 0x800) {
|
||||
buf[0] = (unsigned char)(0xC0 | (cp >> 6));
|
||||
buf[1] = (unsigned char)(0x80 | (cp & 0x3F));
|
||||
len = 2;
|
||||
} else if (cp < 0x10000) {
|
||||
buf[0] = (unsigned char)(0xE0 | (cp >> 12));
|
||||
buf[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
buf[2] = (unsigned char)(0x80 | (cp & 0x3F));
|
||||
len = 3;
|
||||
} else {
|
||||
buf[0] = (unsigned char)(0xF0 | (cp >> 18));
|
||||
buf[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F));
|
||||
buf[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
buf[3] = (unsigned char)(0x80 | (cp & 0x3F));
|
||||
len = 4;
|
||||
}
|
||||
sink(ctx, (const char *)buf, len);
|
||||
return len;
|
||||
}
|
||||
|
||||
/* 解码一段 raw(已定位转义与续字节的边界)。
|
||||
*
|
||||
* 非法 UTF-8 语义必须与 Go 逐字节一致:
|
||||
* Go 的 unquoteBytes 遇到非法序列时,把**能构成前缀的最长合法部分**先解出,
|
||||
* 再对**第一个坏字节**产出单个 U+FFFD,然后从坏字节**之后**继续。
|
||||
* 即:一个坏字节 = 一个 U+FFFD(不是整个序列变一个)。
|
||||
* 典型:`\xff\xfe` → 两个 U+FFFD(真值表 §2.8 实测确认)。
|
||||
*/
|
||||
static size_t decode_body(ha_span raw, ha_json_sink sink, void *ctx, int *err) {
|
||||
size_t out = 0;
|
||||
size_t i = 0;
|
||||
*err = 0;
|
||||
|
||||
while (i < raw.len) {
|
||||
unsigned char c = (unsigned char)raw.p[i];
|
||||
|
||||
/* --- 转义 --- */
|
||||
if (c == '\\') {
|
||||
if (i + 1 >= raw.len) {
|
||||
*err = 1;
|
||||
return out;
|
||||
}
|
||||
unsigned char e = (unsigned char)raw.p[i + 1];
|
||||
switch (e) {
|
||||
case '"': sink(ctx, "\"", 1); out += 1; i += 2; continue;
|
||||
case '\\': sink(ctx, "\\", 1); out += 1; i += 2; continue;
|
||||
case '/': sink(ctx, "/", 1); out += 1; i += 2; continue;
|
||||
case 'b': sink(ctx, "\b", 1); out += 1; i += 2; continue;
|
||||
case 'f': sink(ctx, "\f", 1); out += 1; i += 2; continue;
|
||||
case 'n': sink(ctx, "\n", 1); out += 1; i += 2; continue;
|
||||
case 'r': sink(ctx, "\r", 1); out += 1; i += 2; continue;
|
||||
case 't': sink(ctx, "\t", 1); out += 1; i += 2; continue;
|
||||
case 'u': {
|
||||
/* 需要 4 位十六进制:i+2 .. i+5 */
|
||||
if (i + 6 > raw.len) {
|
||||
*err = 1;
|
||||
return out;
|
||||
}
|
||||
int h0 = hex_val((unsigned char)raw.p[i + 2]);
|
||||
int h1 = hex_val((unsigned char)raw.p[i + 3]);
|
||||
int h2 = hex_val((unsigned char)raw.p[i + 4]);
|
||||
int h3 = hex_val((unsigned char)raw.p[i + 5]);
|
||||
if (h0 < 0 || h1 < 0 || h2 < 0 || h3 < 0) {
|
||||
*err = 1;
|
||||
return out;
|
||||
}
|
||||
unsigned long cp = (unsigned long)((h0 << 12) | (h1 << 8) |
|
||||
(h2 << 4) | h3);
|
||||
size_t adv = 6;
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
||||
/* 高代理:尝试与紧随的 \uDC00-\uDFFF 合成 4 字节 rune。
|
||||
*
|
||||
* ★ 必须用 combined 标志,而不是「合成成功就直接落到底部」:
|
||||
* 本块末尾有一段**无条件的** replacement 发射(处理合成
|
||||
* 失败的情形)。若成功的分支只设 cp/adv 而不跳过那一段,
|
||||
* 会先把合成好的码点丢掉、再发一个 U+FFFD ——
|
||||
* 实测症状:`\ud83d\ude00`(😀)得到 `\xef\xbf\xbd\xef\xbf\xbd`。
|
||||
* 这个 bug 只有**真的代理对**才会触发(`\u4f60` 这类
|
||||
* 非代理码点根本不进本块),是黄金对照最容易漏的一类。
|
||||
*
|
||||
* 下界必须是 'i + 6 < raw.len'(而非一次判 i+12 <= len):
|
||||
* 后者会连带拒绝「合法高代理位于字符串末尾」的正确输入。 */
|
||||
int combined = 0;
|
||||
if (i + 6 < raw.len && raw.p[i + 6] == '\\' &&
|
||||
raw.p[i + 7] == 'u') {
|
||||
int g0 = hex_val((unsigned char)raw.p[i + 8]);
|
||||
int g1 = hex_val((unsigned char)raw.p[i + 9]);
|
||||
int g2 = hex_val((unsigned char)raw.p[i + 10]);
|
||||
int g3 = hex_val((unsigned char)raw.p[i + 11]);
|
||||
if (g0 >= 0 && g1 >= 0 && g2 >= 0 && g3 >= 0) {
|
||||
unsigned long lo = (unsigned long)(
|
||||
(g0 << 12) | (g1 << 8) | (g2 << 4) | g3);
|
||||
if (lo >= 0xDC00 && lo <= 0xDFFF) {
|
||||
cp = 0x10000UL + ((cp - 0xD800UL) << 10) +
|
||||
(lo - 0xDC00UL);
|
||||
adv = 12;
|
||||
combined = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!combined) {
|
||||
/* 高代理后面不是合法低代理:发一个 U+FFFD,
|
||||
* 只消费掉这个 6 字节 \uXXXX,让后面的内容按原样
|
||||
* 继续解析(与 Go unquote 的行为一致)。 */
|
||||
sink(ctx, kReplacement, 3);
|
||||
out += 3;
|
||||
i += adv;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
||||
/* 孤立低代理 → U+FFFD */
|
||||
sink(ctx, kReplacement, 3);
|
||||
out += 3;
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
out += emit_rune(cp, sink, ctx);
|
||||
i += adv;
|
||||
continue;
|
||||
}
|
||||
default:
|
||||
/* Go 对未知转义(如 \q)报错 */
|
||||
*err = 1;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 普通字节 / 多字节序列 --- */
|
||||
if (c < 0x80) {
|
||||
char ch = (char)c;
|
||||
sink(ctx, &ch, 1);
|
||||
out += 1;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 尝试解析一个合法多字节序列。
|
||||
*
|
||||
* ★ 过长编码(overlong)检查**必须在续字节全部并入之后**做。
|
||||
* 初版把它写在这里、只用首字节的 cp:
|
||||
* else if ((b0 & 0xF0) == 0xE0) { need = 3; cp = b0 & 0x0Fu; }
|
||||
* if (need == 3 && cp < 0x800) valid = 0; // ← 此时 cp 只有首字节的位
|
||||
* 而 0xE4 恰好满足 0x0F 掩码 ⇒ cp = 4 ⇒ 4 < 0x800 ⇒ 误判非法
|
||||
* ⇒ 正常的「你」(e4 bd a0)被逐字节换成 6 个 U+FFFD(实测症状)。
|
||||
* 过长的真实判据是「完整码点 < 该长度的最小值」,
|
||||
* 即 0xC0/0x80、0xE0 0x80、0xF0 0x80/0x90 这几类前缀。 */
|
||||
size_t need;
|
||||
unsigned long cp;
|
||||
unsigned char b0 = c;
|
||||
if ((b0 & 0xE0) == 0xC0) { need = 2; cp = b0 & 0x1Fu; }
|
||||
else if ((b0 & 0xF0) == 0xE0) { need = 3; cp = b0 & 0x0Fu; }
|
||||
else if ((b0 & 0xF8) == 0xF0) { need = 4; cp = b0 & 0x07u; }
|
||||
else { need = 0; cp = 0; }
|
||||
|
||||
int valid = (need != 0);
|
||||
if (valid) {
|
||||
for (size_t k = 1; k < need; k++) {
|
||||
if (i + k >= raw.len) { valid = 0; break; }
|
||||
unsigned char nb = (unsigned char)raw.p[i + k];
|
||||
if ((nb & 0xC0) != 0x80) { valid = 0; break; }
|
||||
cp = (cp << 6) | (unsigned long)(nb & 0x3F);
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
/* 过长编码:按**完整码点**比该长度的最小合法值
|
||||
* (2B:0x80 / 3B:0x800 / 4B:0x10000) */
|
||||
if (need == 2 && cp < 0x80) valid = 0;
|
||||
if (need == 3 && cp < 0x800) valid = 0;
|
||||
if (need == 4 && cp < 0x10000) valid = 0;
|
||||
/* 代理区编码(CESU-8 / WTF-8)Go 判非法 */
|
||||
if (cp >= 0xD800 && cp <= 0xDFFF) valid = 0;
|
||||
if (cp > 0x10FFFF) valid = 0;
|
||||
}
|
||||
if (valid) {
|
||||
sink(ctx, raw.p + i, need);
|
||||
out += need;
|
||||
i += need;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 非法:单个字节 → 一个 U+FFFD,然后继续(与 Go 逐字节一致) */
|
||||
sink(ctx, kReplacement, 3);
|
||||
out += 3;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx,
|
||||
size_t *out_len) {
|
||||
if (sink == NULL) {
|
||||
return 0;
|
||||
}
|
||||
int err = 0;
|
||||
size_t n = decode_body(raw, sink, ctx, &err);
|
||||
if (out_len != NULL) {
|
||||
*out_len = n;
|
||||
}
|
||||
return err ? 0 : 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 写入缓冲的 sink */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
char *out;
|
||||
size_t cap;
|
||||
size_t len;
|
||||
} buf_sink;
|
||||
|
||||
static void buf_write(void *ctx, const char *b, size_t n) {
|
||||
buf_sink *s = (buf_sink *)ctx;
|
||||
/* 缓冲不足时,**绝不再往后写**,并标记溢出(len > cap 即可辨认)。
|
||||
*
|
||||
* ★ 契约是「不越界写」,不是「一个字节都不写」:本函数是流式的,
|
||||
* 写到这里才知道放不下,之前已写出的部分无法撤销。
|
||||
* 调用方拿到 (size_t)-1 时**必须丢弃整个结果**(Go 侧就是这么做的)。
|
||||
* 若真需要 all-or-nothing,调用方应先测得长度再分配(两趟)。
|
||||
* 这个取舍是有意的:单趟更快,而丢弃结果对调用方是廉价的。 */
|
||||
if (s->len + n > s->cap) {
|
||||
s->len = s->cap + 1; /* 标记溢出 */
|
||||
return;
|
||||
}
|
||||
memcpy(s->out + s->len, b, n);
|
||||
s->len += n;
|
||||
}
|
||||
|
||||
size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap) {
|
||||
if (out == NULL || out_cap == 0) {
|
||||
return (size_t)-1;
|
||||
}
|
||||
buf_sink s;
|
||||
s.out = out;
|
||||
s.cap = out_cap - 1; /* 留一位给结尾 NUL */
|
||||
s.len = 0;
|
||||
int err = 0;
|
||||
(void)decode_body(raw, buf_write, &s, &err);
|
||||
if (err || s.len > s.cap) {
|
||||
return (size_t)-1;
|
||||
}
|
||||
out[s.len] = '\0';
|
||||
return s.len;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 整数 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int ha_json_get_int(ha_span raw, long long *out) {
|
||||
if (raw.len == 0 || out == NULL) {
|
||||
return 0;
|
||||
}
|
||||
size_t i = 0;
|
||||
int neg = 0;
|
||||
if (raw.p[0] == '-') {
|
||||
neg = 1;
|
||||
i = 1;
|
||||
if (raw.len == 1) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* 只接受 **JSON 整数语法**:可选 '-' + (0 | [1-9][0-9]*)。
|
||||
* 小数点 / 指数一律判「不是整数」,由调用方按 Go 的
|
||||
* 「类型不匹配 ⇒ 整块作废」语义处理。
|
||||
*
|
||||
* ★ 必须禁前导零:JSON 里 `007` / `00` 是**非法数字**,
|
||||
* 而 strconv.ParseInt 会接受它。若这里跟着接受,
|
||||
* 就会出现「C 认得、json.Unmarshal 报错」的分叉 ——
|
||||
* 黄金对照当场抓到(实测分歧:"007"、"00")。
|
||||
* 本层的职责是回答「这是不是 JSON 整数」,不是「能不能转成数字」。 */
|
||||
if (raw.p[i] == '0' && raw.len - i > 1) {
|
||||
return 0; /* 前导零:00 / 01 / 007 均非法 */
|
||||
}
|
||||
for (size_t k = i; k < raw.len; k++) {
|
||||
if (raw.p[k] < '0' || raw.p[k] > '9') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
unsigned long long acc = 0;
|
||||
const unsigned long long limit =
|
||||
neg ? 9223372036854775807ULL + 1ULL : 9223372036854775807ULL;
|
||||
for (size_t k = i; k < raw.len; k++) {
|
||||
unsigned d = (unsigned)(raw.p[k] - '0');
|
||||
if (acc > (limit - d) / 10ULL) {
|
||||
return 0; /* 溢出(与 Go 报错等价) */
|
||||
}
|
||||
acc = acc * 10ULL + d;
|
||||
}
|
||||
if (neg) {
|
||||
*out = (acc == 9223372036854775808ULL)
|
||||
? (-9223372036854775807LL - 1)
|
||||
: -(long long)acc;
|
||||
} else {
|
||||
*out = (long long)acc;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 便捷取值 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* 在对象里定位键 name 的值 span。
|
||||
*
|
||||
* 找到返回 1 且 *val 覆盖该值的原始字节(未解码);未找到 / 语法错返回 0。
|
||||
* 重复键取**最后一次**(与 Go 的后者胜一致)。
|
||||
*
|
||||
* 实现要点:members 迭代器只报「值的起始位置」,值本身由本函数用
|
||||
* ha_json_skip 消费并算出 span —— 这样两种便捷取值共用同一套定位逻辑,
|
||||
* 不会因各自实现而分叉。 */
|
||||
static int find_value(ha_span obj, const char *name, ha_span *val) {
|
||||
ha_json_members m;
|
||||
if (!ha_json_members_init(&m, obj.p, obj.len)) {
|
||||
return 0;
|
||||
}
|
||||
ha_span key;
|
||||
ha_span v;
|
||||
int found = 0;
|
||||
ha_span last = { NULL, 0 };
|
||||
|
||||
while (ha_json_members_next(&m, &key, &v)) {
|
||||
if (ha_json_key_eq(key, name)) {
|
||||
last = v;
|
||||
found = 1; /* 重复键后者胜:继续扫,只保留最后一次 */
|
||||
}
|
||||
}
|
||||
/* ★ 严格性:畸形输入必须判「找不到键」——
|
||||
* Go 侧语法错误会让 json.Unmarshal 失败、整块作废,
|
||||
* 若这里放宽成「扫到哪算哪」,就会比 Go 宽松(见真值表 §2.2)。 */
|
||||
if (!ha_json_members_complete(&m)) {
|
||||
return 0;
|
||||
}
|
||||
if (found && val != NULL) {
|
||||
*val = last;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
size_t ha_json_object_get_string(ha_span obj, const char *name,
|
||||
char *out, size_t out_cap) {
|
||||
if (out == NULL || out_cap == 0) {
|
||||
return (size_t)-1;
|
||||
}
|
||||
ha_span val;
|
||||
if (!find_value(obj, name, &val)) {
|
||||
return (size_t)-1;
|
||||
}
|
||||
/* 只接受字符串值;其他类型视为「取不到」(类型判断由调用方按
|
||||
* Go 的 interface{}/强类型语义决定,见 sse-codec-c.md §2.2) */
|
||||
ha_json_scan sc;
|
||||
ha_json_scan_init(&sc, val.p, val.len);
|
||||
ha_span raw;
|
||||
if (!ha_json_scan_string(&sc, &raw)) {
|
||||
return (size_t)-1;
|
||||
}
|
||||
return ha_json_decode_string_into(raw, out, out_cap);
|
||||
}
|
||||
|
||||
int ha_json_object_get_int(ha_span obj, const char *name, long long *out) {
|
||||
if (out == NULL) {
|
||||
return 0;
|
||||
}
|
||||
ha_span val;
|
||||
if (!find_value(obj, name, &val)) {
|
||||
return 0;
|
||||
}
|
||||
return ha_json_get_int(val, out);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user