mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e75859668b | |||
| b6a66c57fe | |||
| 1b49365d46 | |||
| 17ea7fd5f0 | |||
| ffcfaf46e2 | |||
| cd88b2dfe5 | |||
| 1d46c6c0f6 | |||
| 4707b05498 | |||
| f5df904d02 | |||
| 8537577123 | |||
| 918f29899c | |||
| c4659342b3 | |||
| 02600a3b90 | |||
| 02861f5d64 | |||
| cea8011f3d | |||
| 68835c18db | |||
| 89db544671 | |||
| a021055011 | |||
| d17c18665c |
10
.gitignore
vendored
10
.gitignore
vendored
@ -26,8 +26,7 @@ cmd/gui/dist/
|
|||||||
#
|
#
|
||||||
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only),
|
# 外部插件与工具链维护在独立 SDK 仓(决策 sdk_repo_only),
|
||||||
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
|
# 本仓经 go.mod 的 replace => ./third_party/homeagent-sdk 引用。
|
||||||
# example/ 下已跟踪的 21 个文件(10 个示例的 plg.json + plugin.go,
|
# example/ 下已跟踪的 20 个文件(plg.json + plugin.go)早于本规则,
|
||||||
# 加 qq/plugin_test.go)早于本规则,
|
|
||||||
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
|
# 靠「已跟踪文件不受 .gitignore 影响」保留——这是有意的,不要「修」。
|
||||||
third_party/homeagent-sdk/bin/
|
third_party/homeagent-sdk/bin/
|
||||||
third_party/homeagent-sdk/tools/
|
third_party/homeagent-sdk/tools/
|
||||||
@ -36,13 +35,6 @@ third_party/homeagent-sdk/scripts/
|
|||||||
third_party/homeagent-sdk/.gitignore
|
third_party/homeagent-sdk/.gitignore
|
||||||
third_party/homeagent-sdk/README*
|
third_party/homeagent-sdk/README*
|
||||||
third_party/homeagent-sdk/example/
|
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/
|
||||||
|
|
||||||
# codegraph 本地索引配置(含嵌套 SDK 仓放行,仅本地生效)
|
# codegraph 本地索引配置(含嵌套 SDK 仓放行,仅本地生效)
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
# 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]
|
|
||||||
322
Makefile
322
Makefile
@ -1,22 +1,4 @@
|
|||||||
.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test csrc-lint csrc-abi csrc-headers csrc-sanitize csrc-cross csrc-fuzz check-csrc check-csrc-full
|
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt
|
||||||
# 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
|
BINARY=homed
|
||||||
CLI_BINARY=waiter
|
CLI_BINARY=waiter
|
||||||
@ -33,230 +15,15 @@ LDFLAGS = -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=$(VERSION) -
|
|||||||
|
|
||||||
all: build build-cli
|
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\` 而不是只看输出:只有「告警即失败」才是门禁,
|
|
||||||
# 否则它只是打印给人看,而人会累。
|
|
||||||
.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:
|
build:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')"
|
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||||
@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:
|
build-cli:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))"
|
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
|
||||||
|
|
||||||
build-gui:
|
build-gui:
|
||||||
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
|
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
|
||||||
@ -267,36 +34,13 @@ build-static:
|
|||||||
CGO_ENABLED=1 $(GO) build -tags netgo -installsuffix dynlink -ldflags '-extldflags "-static" $(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
|
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"
|
@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:
|
build-linux-arm64:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@for f in cmd/homed/*.syso cmd/waiter/*.syso; do \
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/
|
||||||
[ -f "$$f" ] || continue; \
|
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64"
|
||||||
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:
|
clean:
|
||||||
rm -rf $(BUILD_DIR) $(BINARY) $(CSRC_BUILD)
|
rm -rf $(BUILD_DIR) $(BINARY)
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
-systemctl stop homeagent 2>/dev/null
|
-systemctl stop homeagent 2>/dev/null
|
||||||
@ -308,37 +52,6 @@ install: build
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
$(GO) test ./...
|
$(GO) test ./...
|
||||||
@$(MAKE) csrc-test
|
|
||||||
@$(MAKE) check-csrc
|
|
||||||
@$(MAKE) check-codec-cgo-only
|
|
||||||
|
|
||||||
# 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
|
run: build
|
||||||
./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent
|
./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent
|
||||||
@ -348,22 +61,3 @@ fmt:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
$(GO) vet ./...
|
$(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
|
|
||||||
|
|||||||
81
README.md
81
README.md
@ -26,16 +26,6 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
|||||||
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
||||||
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
- **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)。
|
|
||||||
|
|
||||||
## 架构图
|
## 架构图
|
||||||
|
|
||||||
### 一、消息处理时序
|
### 一、消息处理时序
|
||||||
@ -44,7 +34,6 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as 用户/插件
|
participant U as 用户/插件
|
||||||
participant IO as IOManager
|
participant IO as IOManager
|
||||||
participant SCH as 输入调度器
|
|
||||||
participant EV as eventLoop
|
participant EV as eventLoop
|
||||||
participant CTX as RelevanceContext
|
participant CTX as RelevanceContext
|
||||||
participant LLM as LLM+工具循环
|
participant LLM as LLM+工具循环
|
||||||
@ -52,14 +41,7 @@ sequenceDiagram
|
|||||||
participant MEM as 三层记忆
|
participant MEM as 三层记忆
|
||||||
|
|
||||||
U->>IO: InjectInput(type, payload)
|
U->>IO: InjectInput(type, payload)
|
||||||
IO->>SCH: inputCh
|
IO->>EV: inputCh
|
||||||
rect lavender
|
|
||||||
Note over SCH: 两类别 + 四级中断(L1~L4)
|
|
||||||
SCH->>SCH: 同级不抢占 → 入就绪队列/中断队列
|
|
||||||
SCH->>SCH: 更高级 → 抢占(现场压中断栈,稍后可恢复)
|
|
||||||
SCH->>SCH: 转投(主 agent 忙久了 → 交给临时分诊助手)
|
|
||||||
end
|
|
||||||
SCH->>EV: 选中一个任务开始跑
|
|
||||||
rect lavender
|
rect lavender
|
||||||
Note over EV: processTextInput
|
Note over EV: processTextInput
|
||||||
EV->>ST: StageOnInput 插件可改写/短路
|
EV->>ST: StageOnInput 插件可改写/短路
|
||||||
@ -73,7 +55,7 @@ sequenceDiagram
|
|||||||
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
|
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
|
||||||
EV->>ST: StagePreAction 插件可预拦截
|
EV->>ST: StagePreAction 插件可预拦截
|
||||||
loop 工具循环
|
loop 工具循环
|
||||||
LLM->>LLM: 安全点:中断求值/让位
|
LLM->>LLM: drainInterrupts
|
||||||
LLM->>LLM: LLM Chat
|
LLM->>LLM: LLM Chat
|
||||||
LLM->>ST: StagePostAction 插件可修改/短路
|
LLM->>ST: StagePostAction 插件可修改/短路
|
||||||
alt 无tool call
|
alt 无tool call
|
||||||
@ -128,7 +110,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
subgraph D[② Document 文件记忆]
|
subgraph D[② Document 文件记忆]
|
||||||
DS[DocStore JSON+TF-IDF]
|
DS[DocStore JSON+TF-IDF]
|
||||||
Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP
|
Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP
|
||||||
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
|
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
|
||||||
Q2 -->|原始时间戳写入上下文| RC
|
Q2 -->|原始时间戳写入上下文| RC
|
||||||
CD[FindColdDocs 72h] -->|docToTriples| G
|
CD[FindColdDocs 72h] -->|docToTriples| G
|
||||||
@ -198,44 +180,27 @@ API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQ
|
|||||||
cmd/homed/ 守护进程入口,组装所有子系统
|
cmd/homed/ 守护进程入口,组装所有子系统
|
||||||
cmd/waiter/ CLI 客户端(Unix socket)
|
cmd/waiter/ CLI 客户端(Unix socket)
|
||||||
internal/
|
internal/
|
||||||
├── agent/core/ Agent 核心:输入调度器(两类别+四级中断)、事件循环、LLM 工具循环、7 阶段管道、驻留子
|
├── agent/core/ Agent 核心:事件循环、LLM 工具循环、7 阶段管道
|
||||||
├── agent/api/ LLM Provider(Lua 适配层:provider.go 调 vm)
|
├── agent/api/ LLM Provider + 8 个 Lua 适配器
|
||||||
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
||||||
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||||
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
||||||
├── plugins/ 内置 18 个插件(webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data 等)
|
├── plugins/ 内置 11 个插件(webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||||
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||||
├── config/ SQLite 配置中心
|
├── config/ SQLite 配置中心
|
||||||
├── events/ 事件总线
|
├── events/ 事件总线
|
||||||
└── internal/lua/adapters/ 10 个 LLM 协议适配器脚本
|
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
外部插件开发见 [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** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
||||||
|
|
||||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
||||||
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
||||||
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0;实测加载峰值 1.59GB、静置回收后稳态约 0.89GB);
|
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB);
|
||||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||||
这是已知并写进文档的代价。
|
这是已知并写进文档的代价。
|
||||||
@ -256,19 +221,6 @@ internal/
|
|||||||
|
|
||||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
> 「媒体以 `[<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 解引用崩溃)。
|
**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 解引用崩溃)。
|
||||||
|
|
||||||
@ -313,12 +265,7 @@ make test # go test ./...
|
|||||||
make install # 安装到系统
|
make install # 安装到系统
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux。
|
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||||
|
|
||||||
> `homed` 需 Linux(依赖 fd 继承与共享内存段的段内偏移解引用,见
|
|
||||||
> `cmd/homed/platform_windows.go`);Windows 上只构建 `waiter.exe`,
|
|
||||||
> `homed` 跑在 WSL2 里(见「下载」)。macOS 可构建 `waiter`/`initconfig`,
|
|
||||||
> `homed` 需在原生 macOS 构建。
|
|
||||||
|
|
||||||
## 许可
|
## 许可
|
||||||
|
|
||||||
@ -328,14 +275,8 @@ make install # 安装到系统
|
|||||||
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
|
||||||
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
即:任何人把改过的 HomeAgent 对外提供网络服务,都必须让该服务的使用者拿到改动后的源码。
|
||||||
|
|
||||||
插件与本项目通过公开 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 静态链接
|
插件与本项目通过公开 SDK **静态链接**(SDK 源码会进入插件二进制),因此插件是本项目的
|
||||||
(SDK 源码会进入插件二进制),但那个仓**以 MIT 发布**——MIT 是宽松许可,拿到授权的代码不继承
|
衍生作品,需以相同许可发布;子进程隔离不改变这一点,因为被链接的是 SDK 代码本身。
|
||||||
本项目的 AGPL。因此**外部插件不是本项目的衍生作品**,作者可自行选择许可(含闭源、商业、私有),
|
|
||||||
既不必同许可、也不受 §13 网络条款约束。第三方插件生态的安全与活跃正建立在这条之上。
|
|
||||||
|
|
||||||
边界很清楚:**AGPL 覆盖内核与随包内置插件**(`homed`、`internal/`、`internal/plugins/` 下 18 个内置插件);
|
|
||||||
**MIT 覆盖公开 SDK**(`sdk/`,`go.mod` 零外部依赖、只依赖 Go 标准库,不引用内核任何代码)。
|
|
||||||
子进程隔离在这里不重要了——决定许可的是被链接的 SDK 代码本身,而它是 MIT。
|
|
||||||
|
|
||||||
### 随包分发的第三方组件
|
### 随包分发的第三方组件
|
||||||
|
|
||||||
|
|||||||
100
README_EN.md
100
README_EN.md
@ -32,19 +32,6 @@ 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
|
- **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
|
- **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
|
## Architecture Diagrams
|
||||||
|
|
||||||
### 1. Message Processing Sequence
|
### 1. Message Processing Sequence
|
||||||
@ -53,7 +40,6 @@ See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as User/Plugin
|
participant U as User/Plugin
|
||||||
participant IO as IOManager
|
participant IO as IOManager
|
||||||
participant SCH as Input Scheduler
|
|
||||||
participant EV as eventLoop
|
participant EV as eventLoop
|
||||||
participant CTX as RelevanceContext
|
participant CTX as RelevanceContext
|
||||||
participant LLM as LLM+Tool Loop
|
participant LLM as LLM+Tool Loop
|
||||||
@ -61,14 +47,7 @@ sequenceDiagram
|
|||||||
participant MEM as Three-Layer Memory
|
participant MEM as Three-Layer Memory
|
||||||
|
|
||||||
U->>IO: InjectInput(type, payload)
|
U->>IO: InjectInput(type, payload)
|
||||||
IO->>SCH: inputCh
|
IO->>EV: 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
|
rect lavender
|
||||||
Note over EV: processTextInput
|
Note over EV: processTextInput
|
||||||
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
|
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
|
||||||
@ -82,7 +61,7 @@ sequenceDiagram
|
|||||||
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
|
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
|
||||||
EV->>ST: StagePreAction Plugin can pre-intercept
|
EV->>ST: StagePreAction Plugin can pre-intercept
|
||||||
loop Tool loop
|
loop Tool loop
|
||||||
LLM->>LLM: safe point: interrupt eval / yield
|
LLM->>LLM: drainInterrupts
|
||||||
LLM->>LLM: LLM Chat
|
LLM->>LLM: LLM Chat
|
||||||
LLM->>ST: StagePostAction Plugin can modify/short-circuit
|
LLM->>ST: StagePostAction Plugin can modify/short-circuit
|
||||||
alt No tool call
|
alt No tool call
|
||||||
@ -190,53 +169,28 @@ API keys are configured via WebUI `http://localhost:8080` settings page, persist
|
|||||||
cmd/homed/ Daemon entry, assembles all subsystems
|
cmd/homed/ Daemon entry, assembles all subsystems
|
||||||
cmd/waiter/ CLI client (Unix socket)
|
cmd/waiter/ CLI client (Unix socket)
|
||||||
internal/
|
internal/
|
||||||
├── agent/core/ Agent core: input scheduler (2 classes + 4 levels), event loop, LLM tool loop, 7-stage pipeline, residents
|
├── agent/core/ Agent core: event loop, LLM tool loop, 7-stage pipeline
|
||||||
├── agent/api/ LLM Provider (Lua adapter layer: provider.go drives the vm)
|
├── agent/api/ LLM Provider + 8 Lua adapters
|
||||||
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
|
├── 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)
|
├── knowledge/ Knowledge base (filesystem + TF-IDF)
|
||||||
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
|
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
|
||||||
├── plugins/ 18 built-in plugins (webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data, ...)
|
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||||
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
||||||
├── config/ SQLite config center
|
├── config/ SQLite config center
|
||||||
├── events/ Event bus
|
├── events/ Event bus
|
||||||
└── internal/lua/adapters/ 10 LLM protocol adapter scripts
|
└── internal/lua/adapters/ 8 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/`
|
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
|
## 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.
|
**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.
|
- **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}` /
|
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
|
||||||
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
|
`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**
|
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
|
||||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0; measured ~1.59GB peak on load, settling to ~0.89GB steady-state);
|
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
|
||||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
`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
|
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.
|
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||||
@ -263,23 +217,6 @@ 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
|
> 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.
|
> 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
|
**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
|
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||||
@ -342,12 +279,7 @@ make test # go test ./...
|
|||||||
make install # Install to system
|
make install # Install to system
|
||||||
```
|
```
|
||||||
|
|
||||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux.
|
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||||
|
|
||||||
> `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
|
## License
|
||||||
|
|
||||||
@ -359,19 +291,9 @@ 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
|
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.
|
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
|
Plugins are **statically linked** against this project through the public SDK (the SDK source
|
||||||
[homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) (the SDK source ends up inside the
|
ends up inside the plugin binary), so plugins are derivative works and must be released under
|
||||||
plugin binary), but that repository is released under **MIT** — a permissive license, so code
|
the same license. Process isolation does not change this — what is linked is the SDK code itself.
|
||||||
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
|
### Third-party components shipped with the packages
|
||||||
|
|
||||||
|
|||||||
@ -455,74 +455,7 @@ Extended fields:
|
|||||||
- Relation extension: Confidence
|
- Relation extension: Confidence
|
||||||
|
|
||||||
|
|
||||||
## Input Scheduler & Interrupt Mechanism
|
## 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)
|
interceptLoop (goroutine)
|
||||||
@ -532,28 +465,15 @@ interceptLoop (goroutine)
|
|||||||
└── (c) InjectInput() → Trigger new processing when idle
|
└── (c) InjectInput() → Trigger new processing when idle
|
||||||
```
|
```
|
||||||
|
|
||||||
Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
|
Three delivery paths:
|
||||||
|
|
||||||
## Context Budget
|
| 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 |
|
||||||
|
|
||||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
Code: `internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||||
|
|
||||||
```
|
|
||||||
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
|
## Configuration System
|
||||||
|
|||||||
@ -648,11 +648,11 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
|
|||||||
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
|
||||||
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
|
| `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_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(source, channel, text)` | Inject synchronously and wait for this turn's reply; returns `(reply, err)`, reply is nil when there is none |
|
||||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same (unavailable) |
|
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same, with flags |
|
||||||
| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks |
|
| `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_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_input_media_sync` / `..._sync_opts(...)` | Synchronous media injection; returns `(reply, err)` |
|
||||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media |
|
| `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.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) |
|
| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) |
|
||||||
@ -790,14 +790,7 @@ 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.
|
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()` returns an interface with **only 3 methods** (`ReloadOne` /
|
> **Note**: `PluginMgr()` is only available to built-in plugins; external dynamic plugins cannot call it directly.
|
||||||
> `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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -811,7 +804,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
|||||||
|---------|------|----------|
|
|---------|------|----------|
|
||||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
| [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 |
|
| [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, 20 tools, full input/output channel wiring |
|
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 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 |
|
| [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 |
|
| [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) |
|
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
|
||||||
@ -824,12 +817,6 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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
|
### Built-in Plugins
|
||||||
|
|
||||||
|
|||||||
@ -444,64 +444,7 @@ type Plugin interface {
|
|||||||
- Relation 扩展:Confidence
|
- 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)
|
interceptLoop (goroutine)
|
||||||
@ -511,26 +454,15 @@ interceptLoop (goroutine)
|
|||||||
└── (c) InjectInput() → 空闲时触发新处理
|
└── (c) InjectInput() → 空闲时触发新处理
|
||||||
```
|
```
|
||||||
|
|
||||||
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
|
三种投递路径:
|
||||||
|
|
||||||
## 上下文预算
|
| 路径 | 效果 | 时机 |
|
||||||
|
|------|------|------|
|
||||||
|
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
|
||||||
|
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
|
||||||
|
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
|
||||||
|
|
||||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
代码:`internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||||
|
|
||||||
```
|
|
||||||
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 时实际注入仍只有几百字符。
|
|
||||||
|
|
||||||
|
|
||||||
## 配置系统
|
## 配置系统
|
||||||
|
|||||||
@ -641,11 +641,11 @@ end)
|
|||||||
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
|
||||||
| `sdk.inject_text_no_memory(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_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(source, channel, text)` | 同步注入并等本轮回复;返回 `(reply, err)`,无回复时 reply 为 nil |
|
||||||
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上(不可用) |
|
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上带标志位 |
|
||||||
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
|
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
|
||||||
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||||
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | ⚠️ **Lua 中不可用**(同 `inject_input_sync`) |
|
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | 带媒体的同步注入;返回 `(reply, err)` |
|
||||||
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
|
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
|
||||||
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
|
||||||
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
|
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
|
||||||
@ -783,12 +783,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|
|||||||
|
|
||||||
内部机制:禁用记录存储在 SQLite `disabled_plugins` 表(`name`, `disabled_at`, `disabled_by`),禁用立即生效(插件不再接收输入),完全卸载需重启内核。
|
内部机制:禁用记录存储在 SQLite `disabled_plugins` 表(`name`, `disabled_at`, `disabled_by`),禁用立即生效(插件不再接收输入),完全卸载需重启内核。
|
||||||
|
|
||||||
> **注意**:`PluginMgr()` 返回的接口**只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` /
|
> **注意**:`PluginMgr()` 仅内置插件可用,外部动态插件无法直接调用。
|
||||||
> `IsPluginDisabled`)。启用/禁用/卸载/重载全部(`EnablePlugin` / `DisablePlugin` /
|
|
||||||
> `RemovePlugin` / `ReloadPlugins`)以及内置插件判定(`IsBuiltinPlugin`)只在内核内部
|
|
||||||
> 的 `internal/sdk.PluginManager` 上,外部插件拿不到——需重载插件请调 `ReloadOne`。
|
|
||||||
> 两个接口叫相似的名字但**不是同一个**:`sdk.PluginMgrAPI`(公开,3 方法)与
|
|
||||||
> `internal/sdk.PluginManager`(内部,9 方法)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -802,7 +797,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
| [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 面 |
|
| [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 对接,20 个工具,输入/输出通道完整对接 |
|
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 |
|
||||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 |
|
| [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 种写入模式,沙箱隔离 |
|
| [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) |
|
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) |
|
||||||
@ -815,12 +810,6 @@ pmgr.ReloadPlugins() // 重载所有插件
|
|||||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
|
| [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 图片生成 |
|
| [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 | 音乐播放 |
|
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
|
||||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD) |
|
|
||||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
|
|
||||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
|
|
||||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent) |
|
|
||||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序),failback 模式的核心插件 |
|
|
||||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
|
|
||||||
|
|
||||||
### 内置插件
|
### 内置插件
|
||||||
|
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
# 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 未启动时它们只是闲置文件)。
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
---
|
|
||||||
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 是增量维护的,不必重启)。
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "knowledge-base",
|
|
||||||
"description": "按分类树检索 HomeAgent 知识库:树形导航、分类内检索、以图搜知识",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"author": "HomeAgent"
|
|
||||||
}
|
|
||||||
@ -810,11 +810,6 @@ const devOs = require("os");
|
|||||||
let deviceBridge = null; // 当前活动设备桥
|
let deviceBridge = null; // 当前活动设备桥
|
||||||
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
||||||
let deviceBridgeAddr = ""; // 设备桥网关地址
|
let deviceBridgeAddr = ""; // 设备桥网关地址
|
||||||
|
|
||||||
// 设备桥的**登记**状态(与"连接已建立"是两件事)。
|
|
||||||
// 连接成功但 bind 被拒时设备是失联的,只看 connected 会给出假阳性。
|
|
||||||
let deviceBridgeBound = false;
|
|
||||||
let deviceBridgeBindError = "";
|
|
||||||
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
||||||
let speechAccum = null;
|
let speechAccum = null;
|
||||||
|
|
||||||
@ -1257,33 +1252,9 @@ function onDeviceMsg(msg) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[device-bridge] exec error: " + e.message);
|
console.log("[device-bridge] exec error: " + e.message);
|
||||||
}
|
}
|
||||||
} else if (op === "bind_ack") {
|
} else if (op === "hello_ack" || 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(
|
console.log(
|
||||||
"[device-bridge] hello_ack device=" + (msg.device || deviceBridgeId) + " online=" + msg.online,
|
"[device-bridge] " + op + " device=" + (msg.device || deviceBridgeId),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
@ -2109,8 +2080,6 @@ function argsSafe(cmd) {
|
|||||||
// url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。
|
// url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。
|
||||||
async function startDeviceBridge(cfg) {
|
async function startDeviceBridge(cfg) {
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
deviceBridgeBound = false;
|
|
||||||
deviceBridgeBindError = "";
|
|
||||||
const url = cfg.url || cfg.gateway || "";
|
const url = cfg.url || cfg.gateway || "";
|
||||||
const token = cfg.apiKey || cfg.token || "";
|
const token = cfg.apiKey || cfg.token || "";
|
||||||
if (!url || !token) {
|
if (!url || !token) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homeagent-gui",
|
"name": "homeagent-gui",
|
||||||
"version": "1.4.0",
|
"version": "1.0.0",
|
||||||
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
|
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
|
||||||
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
|
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
|
||||||
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
|
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
|
||||||
|
|||||||
@ -593,31 +593,6 @@ async function api(p, o) {
|
|||||||
return body;
|
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 =====
|
// ===== Navigation =====
|
||||||
function switchView(n) {
|
function switchView(n) {
|
||||||
document.querySelectorAll(".view").forEach((e) => {
|
document.querySelectorAll(".view").forEach((e) => {
|
||||||
@ -721,10 +696,6 @@ async function refreshDataOnly() {
|
|||||||
try {
|
try {
|
||||||
state.kernel = await api("/kernel");
|
state.kernel = await api("/kernel");
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
|
||||||
// 运行态快照:只给指标与队列/阶段展示用,不影响其他卡片。
|
|
||||||
state.runtime = await api("/runtime");
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
try {
|
||||||
var s = await api("/settings");
|
var s = await api("/settings");
|
||||||
state.settings = s.settings || {};
|
state.settings = s.settings || {};
|
||||||
@ -748,7 +719,6 @@ async function refreshDataOnly() {
|
|||||||
state.currentConn.type === "webui" &&
|
state.currentConn.type === "webui" &&
|
||||||
state.currentConn.url
|
state.currentConn.url
|
||||||
) {
|
) {
|
||||||
await loadDiscoveredGateway();
|
|
||||||
var d = await api("/device/online");
|
var d = await api("/device/online");
|
||||||
state.devices = (d && d.devices) || [];
|
state.devices = (d && d.devices) || [];
|
||||||
} else {
|
} else {
|
||||||
@ -805,10 +775,6 @@ async function refreshAll() {
|
|||||||
try {
|
try {
|
||||||
state.kernel = await api("/kernel");
|
state.kernel = await api("/kernel");
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
|
||||||
// 运行态快照(调度器/驻留子/通道),供总览的运行态面板使用。
|
|
||||||
state.runtime = await api("/runtime");
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
try {
|
||||||
var s = await api("/settings");
|
var s = await api("/settings");
|
||||||
state.settings = s.settings || {};
|
state.settings = s.settings || {};
|
||||||
@ -833,7 +799,6 @@ async function refreshAll() {
|
|||||||
state.currentConn.type === "webui" &&
|
state.currentConn.type === "webui" &&
|
||||||
state.currentConn.url
|
state.currentConn.url
|
||||||
) {
|
) {
|
||||||
await loadDiscoveredGateway();
|
|
||||||
var d = await api("/device/online");
|
var d = await api("/device/online");
|
||||||
state.devices = (d && d.devices) || [];
|
state.devices = (d && d.devices) || [];
|
||||||
} else {
|
} else {
|
||||||
@ -1039,249 +1004,6 @@ function statCard(l, v) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 运行态面板:阶段管道 + 中断队列 =====
|
|
||||||
//
|
|
||||||
// 与 WebUI 总览**同一套设计语言:等大表框**。此前桌面版总览只有四个数字卡,
|
|
||||||
// 既看不到「这一轮走到哪一步」,也看不到四级中断队列的积压。
|
|
||||||
// 数据来自 /api/v1/runtime(KernelStatus 的运行态子集)。
|
|
||||||
var RT_LEVELS = [
|
|
||||||
{ lv: 4, name: "L4", zh: "内核独占", en: "kernel only", cls: "rt-lv-4" },
|
|
||||||
{ lv: 3, name: "L3", zh: "交互", en: "interactive", cls: "rt-lv-3" },
|
|
||||||
{ lv: 2, name: "L2", zh: "消息", en: "message", cls: "rt-lv-2" },
|
|
||||||
{ lv: 1, name: "L1", zh: "后台", en: "background", cls: "rt-lv-1" },
|
|
||||||
];
|
|
||||||
// 七阶段归并成五格(与内核 sdk.Stage 的顺序一致):
|
|
||||||
// 一轮里工具调用会反复回到「行动后」,线性滑块本身就是错的表述,
|
|
||||||
// 所以画成 输入 → 行动 ⇄(工具) → 输出 → 结束,工具那格带循环标记。
|
|
||||||
var RT_PIPE_GROUPS = [
|
|
||||||
{ zh: "输入", en: "in", ico: "in" },
|
|
||||||
{ zh: "行动", en: "act", ico: "act" },
|
|
||||||
{ zh: "工具", en: "tool", ico: "tool", loop: true },
|
|
||||||
{ zh: "输出", en: "out", ico: "out" },
|
|
||||||
{ zh: "结束", en: "done", ico: "done" },
|
|
||||||
];
|
|
||||||
// 图标一律内联 SVG(24x24 / currentColor),不用 emoji/符号字符充当图标。
|
|
||||||
var RT_ICO = {
|
|
||||||
in: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M21 12H8"/><path d="M13 6l-6 6 6 6"/></svg>',
|
|
||||||
act: '<svg class="rt-ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"/></svg>',
|
|
||||||
tool: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M14.5 6.5a3.8 3.8 0 0 1 5 5L10 21l-5-5z"/><path d="M14.5 6.5 17.5 9.5"/></svg>',
|
|
||||||
out: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></svg>',
|
|
||||||
done: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
||||||
loop: '<svg class="rt-ico" viewBox="0 0 24 24"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M21 3.5V9h-5.5"/></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
function rtPhaseGroup(phase) {
|
|
||||||
switch (phase) {
|
|
||||||
case "on_input":
|
|
||||||
return 0;
|
|
||||||
case "pre_action":
|
|
||||||
case "post_action":
|
|
||||||
return 1;
|
|
||||||
case "before_toolcall":
|
|
||||||
case "after_toolcall":
|
|
||||||
return 2;
|
|
||||||
case "before_output":
|
|
||||||
return 3;
|
|
||||||
case "after_output":
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
function rtShortTool(name) {
|
|
||||||
var n = String(name || "");
|
|
||||||
var i = n.lastIndexOf("__");
|
|
||||||
if (i >= 0) n = n.slice(i + 2);
|
|
||||||
return n.length > 14 ? n.slice(0, 13) + "…" : n;
|
|
||||||
}
|
|
||||||
// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。
|
|
||||||
// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩文字,看上去就是「这块空着」。
|
|
||||||
function rtSlots(depth, slots, cls) {
|
|
||||||
var n = Math.max(5, Math.min(16, slots || 5));
|
|
||||||
var d = depth || 0;
|
|
||||||
var out = '<span class="rt-slots ' + (cls || "") + '">';
|
|
||||||
for (var i = 0; i < n; i++) out += '<i class="' + (i < d ? "on" : "") + '"></i>';
|
|
||||||
// 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去
|
|
||||||
if (d > n) out += '<b class="rt-slots-more">+' + (d - n) + "</b>";
|
|
||||||
return out + "</span>";
|
|
||||||
}
|
|
||||||
// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里;
|
|
||||||
// 同一阶段重复的同一条(如同一工具连调 3 次)只累加计数,不刷屏。
|
|
||||||
function rtTrailPush(g, kind, label, short) {
|
|
||||||
if (!state.stageTrail) state.stageTrail = [];
|
|
||||||
var arr = state.stageTrail;
|
|
||||||
var last = arr.length ? arr[arr.length - 1] : null;
|
|
||||||
if (last && last.g === g && last.kind === kind && last.short === short) {
|
|
||||||
last.n = (last.n || 1) + 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
arr.push({ g: g, kind: kind, label: label, short: short, n: 1 });
|
|
||||||
if (arr.length > 24) arr.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRuntimePanel() {
|
|
||||||
var title = __("运行态", "Runtime");
|
|
||||||
var rt = state.runtime;
|
|
||||||
// 工具格的滑入动画只在「新到一条工具调用」那一次播放:overview 是整块
|
|
||||||
// innerHTML 重建,节点每次都是新的;若无条件带动画类,任何重渲染都会闪一下。
|
|
||||||
var toolFlash = !!state.toolFlash;
|
|
||||||
state.toolFlash = false;
|
|
||||||
if (!rt) {
|
|
||||||
return (
|
|
||||||
'<div class="card"><h2>' + title + '</h2><p class="rt-empty">' +
|
|
||||||
__("运行态数据不可用", "runtime unavailable") + "</p></div>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
var sc = rt.scheduler || {};
|
|
||||||
var q = sc.interrupt_queues || [0, 0, 0, 0, 0];
|
|
||||||
var pending = sc.pending_interrupts || 0;
|
|
||||||
var ready = sc.ready_queue_depth || 0;
|
|
||||||
var stack = sc.suspend_stack || 0;
|
|
||||||
var maxStack = sc.max_suspend_depth || 4;
|
|
||||||
var residents = rt.residents || [];
|
|
||||||
var byLv = sc.interrupts_by_level || [];
|
|
||||||
var preLv = sc.preempts_by_level || [];
|
|
||||||
var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0);
|
|
||||||
// 至少 5 格:0 时也有可见形状
|
|
||||||
var qSlots = Math.max(5, Math.min(16, maxQ));
|
|
||||||
|
|
||||||
var html = '<div class="card"><h2>' + title + "</h2>";
|
|
||||||
// 四个数字块(沿用本 app 的 statCard 风格)
|
|
||||||
html +=
|
|
||||||
'<div class="grid-4">' +
|
|
||||||
statCard(__("排队", "Ready"), ready, "") +
|
|
||||||
statCard(__("中断", "Pending"), pending, "") +
|
|
||||||
statCard(__("栈", "Stack"), stack + "/" + maxStack, "") +
|
|
||||||
statCard(__("子代理", "Subagents"), residents.length, "") +
|
|
||||||
"</div>";
|
|
||||||
|
|
||||||
// ---- 阶段管道:五个等大表框 ----
|
|
||||||
var g = rtPhaseGroup(state.pipelinePhase || "");
|
|
||||||
html +=
|
|
||||||
'<div class="rt-section-title">' + __("阶段管道", "Stage pipeline") +
|
|
||||||
(g < 0 ? " " + __("(空闲)", "(idle)") : "") + "</div>";
|
|
||||||
html += '<div class="rt-pipe-row' + (g < 0 ? " rt-pipe-idle" : "") + '">';
|
|
||||||
html += RT_PIPE_GROUPS.map(function (s, i) {
|
|
||||||
var items = (state.stageTrail || []).filter(function (t) {
|
|
||||||
return (t.g | 0) === i;
|
|
||||||
});
|
|
||||||
// 「工具」是循环格:一轮里可能调几十次工具/输出通道,全部追加会把这一格
|
|
||||||
// 撑成长条,反而看不出「现在在调什么」。只留**最新一条**,右侧给本轮累计
|
|
||||||
// 次数(与 WebUI 同一口径,见 internal/plugins/webui/dashboard.js)。
|
|
||||||
var cls = "rt-pipe-events";
|
|
||||||
var body;
|
|
||||||
if (!items.length) {
|
|
||||||
body = '<span class="rt-chip rt-chip-none">' + __("无", "none") + "</span>";
|
|
||||||
} else if (s.loop) {
|
|
||||||
cls += " rt-pipe-scroll";
|
|
||||||
var total = 0;
|
|
||||||
for (var k = 0; k < items.length; k++) total += items[k].n || 1;
|
|
||||||
var latest = items[items.length - 1];
|
|
||||||
var lkind = latest.kind || "stage";
|
|
||||||
var lico = lkind === "output" ? RT_ICO.out : lkind === "tool" ? RT_ICO.tool : "";
|
|
||||||
body =
|
|
||||||
'<span class="rt-chip rt-chip-' + lkind + (toolFlash ? " rt-chip-enter" : "") +
|
|
||||||
'" title="' + escHtml(latest.label) + '">' + lico +
|
|
||||||
'<b class="rt-chip-t">' + escHtml(latest.short || latest.label) + "</b>" +
|
|
||||||
(latest.n > 1 ? '<i class="rt-chip-n">x' + latest.n + "</i>" : "") +
|
|
||||||
"</span>" +
|
|
||||||
'<i class="rt-scroll-count" title="' +
|
|
||||||
__("本轮工具调用累计次数", "tool calls this turn") + '">x' + total + "</i>";
|
|
||||||
} else {
|
|
||||||
body = items
|
|
||||||
.map(function (t) {
|
|
||||||
var kind = t.kind || "stage";
|
|
||||||
var ico =
|
|
||||||
kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : "";
|
|
||||||
return (
|
|
||||||
'<span class="rt-chip rt-chip-' + kind + '" title="' +
|
|
||||||
escHtml(t.label) + '">' + ico +
|
|
||||||
'<b class="rt-chip-t">' + escHtml(t.short || t.label) + "</b>" +
|
|
||||||
(t.n > 1 ? '<i class="rt-chip-n">x' + t.n + "</i>" : "") +
|
|
||||||
"</span>"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
'<div class="rt-pipe-cell' + (i === g ? " active" : "") + '">' +
|
|
||||||
'<div class="rt-pipe-head">' + RT_ICO[s.ico] +
|
|
||||||
"<b>" + __(s.zh, s.en) + "</b>" +
|
|
||||||
(s.loop
|
|
||||||
? '<em class="rt-loop" title="' +
|
|
||||||
__("工具调用会回到行动后,可多次", "tool calls loop back; may repeat") +
|
|
||||||
'">' + RT_ICO.loop + "</em>"
|
|
||||||
: "") +
|
|
||||||
'</div><div class="' + cls + '">' + body + "</div></div>"
|
|
||||||
);
|
|
||||||
}).join("");
|
|
||||||
html += "</div>";
|
|
||||||
|
|
||||||
// ---- 中断队列:五个等大表框(L4/L3/L2/L1 + 排队)----
|
|
||||||
html += '<div class="rt-section-title">' + __("队列", "Queues") + "</div>";
|
|
||||||
html += '<div class="rt-queues">';
|
|
||||||
RT_LEVELS.forEach(function (L) {
|
|
||||||
var depth = q[L.lv] || 0;
|
|
||||||
var reg = byLv[L.lv] || 0;
|
|
||||||
var pre = preLv[L.lv] || 0;
|
|
||||||
var desc = __(L.zh, L.en);
|
|
||||||
html +=
|
|
||||||
'<div class="rt-qcell ' + L.cls + (depth ? " rt-active" : "") +
|
|
||||||
'" title="' + escHtml(desc) + '">' +
|
|
||||||
'<div class="rt-qhead"><b>' + L.name + "</b><span>" + escHtml(desc) + "</span></div>" +
|
|
||||||
'<div class="rt-qnum">' + depth + "</div>" +
|
|
||||||
rtSlots(depth, qSlots, L.cls) +
|
|
||||||
'<div class="rt-qmeta">' + reg + " " + __("登记", "reg") + " · " +
|
|
||||||
pre + " " + __("抢占", "pre") + "</div></div>";
|
|
||||||
});
|
|
||||||
// 排队队列无级别:用虚线框与四级中断区分(另一**类别**,不是另一优先级)
|
|
||||||
html +=
|
|
||||||
'<div class="rt-qcell rt-qcell-queued rt-lv-q' + (ready ? " rt-active" : "") +
|
|
||||||
'" title="' + __("排队(无级别,纯 FIFO)", "queued (no priority, FIFO)") + '">' +
|
|
||||||
'<div class="rt-qhead"><b>' + __("排队", "queued") + "</b><span>FIFO</span></div>" +
|
|
||||||
'<div class="rt-qnum">' + ready + "</div>" +
|
|
||||||
rtSlots(ready, qSlots, "rt-lv-q") +
|
|
||||||
'<div class="rt-qmeta">' + __("无级别", "no priority") + "</div></div>";
|
|
||||||
html += "</div></div>";
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开源许可卡:协议标识 + 协议全文 + 源码仓库。
|
|
||||||
// AGPL-3.0 §13 的义务是「向网络使用者提供取得 Corresponding Source 的机会」——
|
|
||||||
// 只给一个仓库链接、不写协议名,使用者看不出这受什么许可约束。
|
|
||||||
function renderLegalCard() {
|
|
||||||
var b = ((state.kernel || {}).build) || {};
|
|
||||||
var src = b.source_url || "";
|
|
||||||
var lic = b.license || "";
|
|
||||||
var licURL = b.license_url || "";
|
|
||||||
if (!lic && !src) return "";
|
|
||||||
function row(key, val) {
|
|
||||||
return (
|
|
||||||
'<div class="kv-row"><span class="key">' + escHtml(key) +
|
|
||||||
'</span><span class="val">' + val + "</span></div>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function a(href, text) {
|
|
||||||
return (
|
|
||||||
'<a href="' + escHtml(href) +
|
|
||||||
'" target="_blank" rel="noopener noreferrer">' + escHtml(text) + "</a>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
var rows = "";
|
|
||||||
if (lic) rows += row(__("许可协议", "License"), licURL ? a(licURL, lic) : escHtml(lic));
|
|
||||||
if (src) rows += row(__("源码仓库", "Source"), a(src, src));
|
|
||||||
// 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。
|
|
||||||
var note =
|
|
||||||
lic && lic.toUpperCase().indexOf("AGPL") >= 0
|
|
||||||
? '<p class="rt-empty">' +
|
|
||||||
__(
|
|
||||||
"网络服务条款(§13):把修改后的版本作为网络服务对外提供时,必须向使用者提供取得对应源码的途径。",
|
|
||||||
"Network clause (section 13): offering a modified version as a network service requires giving users a way to obtain the Corresponding Source.",
|
|
||||||
) +
|
|
||||||
"</p>"
|
|
||||||
: "";
|
|
||||||
return '<div class="card"><h2>' + __("开源许可", "License") + "</h2>" + rows + note + "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOverview() {
|
function renderOverview() {
|
||||||
var s = state.status || {};
|
var s = state.status || {};
|
||||||
var k = state.kernel;
|
var k = state.kernel;
|
||||||
@ -1296,25 +1018,7 @@ function renderOverview() {
|
|||||||
"uptime",
|
"uptime",
|
||||||
) +
|
) +
|
||||||
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
statCard(__("插件", "Plugins"), (k?.plugins || []).length || 0, "plugin") +
|
||||||
statCard(
|
statCard(__("版本", "Version"), s.version || "0.1.0", "version") +
|
||||||
__("版本", "Version"),
|
|
||||||
(function () {
|
|
||||||
// 构建身份取自 /kernel 的 build(-ldflags 注入的真实版本/commit)。
|
|
||||||
// 旧实现用的是 /status 的 version 加一个凭空写死的 "0.1.0" 兑底 ——
|
|
||||||
// 拿不到数据时会向用户展示一个不存在的版本号。
|
|
||||||
var b = (k && k.build) || {};
|
|
||||||
var v = b.version || s.version || "";
|
|
||||||
if (!v) return "-";
|
|
||||||
var sha =
|
|
||||||
b.commit && b.commit !== "unknown" ? String(b.commit).slice(0, 7) : "";
|
|
||||||
return (
|
|
||||||
"v" + escHtml(v) +
|
|
||||||
'<div class="stat-sub">' + escHtml(b.kernel_name || "HomeAgent") +
|
|
||||||
(sha ? " · " + escHtml(sha) : "") + "</div>"
|
|
||||||
);
|
|
||||||
})(),
|
|
||||||
"version",
|
|
||||||
) +
|
|
||||||
"</div>";
|
"</div>";
|
||||||
if (k) {
|
if (k) {
|
||||||
html +=
|
html +=
|
||||||
@ -1378,7 +1082,6 @@ function renderOverview() {
|
|||||||
"</span></div>" +
|
"</span></div>" +
|
||||||
"</div></div>";
|
"</div></div>";
|
||||||
}
|
}
|
||||||
html += renderRuntimePanel();
|
|
||||||
html +=
|
html +=
|
||||||
'<div class="card"><h2>' +
|
'<div class="card"><h2>' +
|
||||||
__("运行时", "Runtime") +
|
__("运行时", "Runtime") +
|
||||||
@ -1391,7 +1094,6 @@ function renderOverview() {
|
|||||||
) +
|
) +
|
||||||
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
statCard("Go " + __("版本", "Version"), k?.runtime?.go_version || "-", "") +
|
||||||
"</div></div>";
|
"</div></div>";
|
||||||
html += renderLegalCard();
|
|
||||||
document.getElementById("view-overview").innerHTML = html;
|
document.getElementById("view-overview").innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5509,48 +5211,17 @@ async function connectFetchSSE(url) {
|
|||||||
var phase = p.phase || "";
|
var phase = p.phase || "";
|
||||||
var tool = p.tool || "";
|
var tool = p.tool || "";
|
||||||
if (p.channel !== "_consolidation_") {
|
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...");
|
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 || "");
|
state.chatStage = __("工具调用: ", "Tool: ") + (tool || "");
|
||||||
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
if (tool && (state.pendingTools || []).indexOf(tool) === -1) {
|
||||||
if (!state.pendingTools) state.pendingTools = [];
|
if (!state.pendingTools) state.pendingTools = [];
|
||||||
state.pendingTools.push(tool);
|
state.pendingTools.push(tool);
|
||||||
rerenderChatIfActive();
|
rerenderChatIfActive();
|
||||||
}
|
}
|
||||||
} else if (phase === "before_output") {
|
} else if (phase === "before_output")
|
||||||
rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen"));
|
|
||||||
state.chatStage = __("生成回复中...", "Generating response...");
|
state.chatStage = __("生成回复中...", "Generating response...");
|
||||||
} else if (phase === "after_output") {
|
|
||||||
rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done"));
|
|
||||||
}
|
|
||||||
state.pipelinePhase = phase;
|
|
||||||
// 阶段停留一会儿就回空闲,避免留下一个永远停在 after_output 的假状态。
|
|
||||||
if (state.pipelineTimer) clearTimeout(state.pipelineTimer);
|
|
||||||
state.pipelineTimer = setTimeout(function () {
|
|
||||||
state.pipelinePhase = "";
|
|
||||||
if (state.currentView === "overview") renderOverview();
|
|
||||||
}, 2500);
|
|
||||||
if (state.currentView === "overview") renderOverview();
|
|
||||||
}
|
}
|
||||||
var badge = document.getElementById("chat-stage");
|
var badge = document.getElementById("chat-stage");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
@ -5732,21 +5403,12 @@ function renderDevices() {
|
|||||||
}
|
}
|
||||||
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
||||||
var dbc = state.dbConfig || {};
|
var dbc = state.dbConfig || {};
|
||||||
// 网关地址优先用**服务端发现的权威值**(state.discoveredGateway),
|
var webuiUrl = "";
|
||||||
// 其次才是用户手填 / 本地推导。
|
|
||||||
//
|
|
||||||
// 为什么不能继续用「门户 URL 同 host 拼 /api/v1/device/ws」:
|
|
||||||
// 网关改造为子域反代后位于 devices.<基域名>,而**基域名与子域标签都是
|
|
||||||
// 服务端配置**,客户端无从得知。硬拼的结果是连到门户自己的路由上。
|
|
||||||
// 服务端 /api/v1/device/gateway 是唯一不会漂移的来源。
|
|
||||||
var webuiUrl = state.discoveredGateway || "";
|
|
||||||
if (
|
if (
|
||||||
!webuiUrl &&
|
|
||||||
state.currentConn &&
|
state.currentConn &&
|
||||||
state.currentConn.type === "webui" &&
|
state.currentConn.type === "webui" &&
|
||||||
state.currentConn.url
|
state.currentConn.url
|
||||||
) {
|
) {
|
||||||
// 回退:老部署(无发现端点)仍按旧口径推导,保持向后兼容。
|
|
||||||
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
||||||
}
|
}
|
||||||
var curGateway = dbc.gateway || webuiUrl || "";
|
var curGateway = dbc.gateway || webuiUrl || "";
|
||||||
@ -5891,8 +5553,8 @@ function renderDevices() {
|
|||||||
html +=
|
html +=
|
||||||
'<p style="color:var(--text-muted)">' +
|
'<p style="color:var(--text-muted)">' +
|
||||||
__(
|
__(
|
||||||
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认经 HomeAgent 反代到 devices.<基域名>,或直连 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认 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.",
|
"No devices yet. Devices connect via WebSocket (default 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
||||||
) +
|
) +
|
||||||
"</p>";
|
"</p>";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -2191,284 +2191,3 @@ td .switch {
|
|||||||
.kv-row .val .switch {
|
.kv-row .val .switch {
|
||||||
margin-right: 4px;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,931 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
agent := agentCore.New(agentCore.AgentConfig{
|
|
||||||
ID: "main",
|
|
||||||
SystemPrompt: sysPrompt,
|
|
||||||
Provider: provider,
|
|
||||||
ProviderManager: providerMgr,
|
|
||||||
IO: iom,
|
|
||||||
Memory: memDB,
|
|
||||||
Indexer: memIdx,
|
|
||||||
Tracker: trk,
|
|
||||||
DocStore: docStore,
|
|
||||||
Knowledge: ks,
|
|
||||||
SocialStore: socialStore,
|
|
||||||
TextMemory: textMem,
|
|
||||||
MediaStore: mediaStore,
|
|
||||||
Personality: personality,
|
|
||||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
|
||||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
|
||||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
|
||||||
PluginReg: pluginReg,
|
|
||||||
PluginDir: cfg.Plugin.Dir,
|
|
||||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
|
||||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
|
||||||
DataDir: cfg.Daemon.DataDir,
|
|
||||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
|
||||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
|
||||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
|
||||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
|
||||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
|
||||||
Offload: agentCore.OffloadOptions{
|
|
||||||
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
|
|
||||||
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
|
|
||||||
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
|
|
||||||
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
|
|
||||||
},
|
|
||||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
|
||||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
|
||||||
Embedder: embedder,
|
|
||||||
MultimodalSpace: multimodalSpace,
|
|
||||||
EmbeddingProvider: mmProviderName,
|
|
||||||
EmbeddingError: mmErr,
|
|
||||||
StageHost: stageHost,
|
|
||||||
EventBus: evBus,
|
|
||||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
|
||||||
InputProcessing: cfg.InputProcessing,
|
|
||||||
})
|
|
||||||
|
|
||||||
return agent
|
|
||||||
}
|
|
||||||
|
|
||||||
// initONNXParser 初始化依存句法分析器(内嵌 ONNX 模型,失败则退回规则引擎)。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func initONNXParser(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry) {
|
|
||||||
modelPath := cfgReg.GetString("core.agent.onnx_model_path", "")
|
|
||||||
onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{
|
|
||||||
ModelPath: modelPath,
|
|
||||||
DataDir: filepath.Join(cfg.Daemon.DataDir, "nlp"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[homed] warn: ONNX parser init: %v, using fallback", err)
|
|
||||||
} else {
|
|
||||||
nlp.SetDefaultParser(onnxParser)
|
|
||||||
log.Printf("[homed] dep parser initialized (model: %s)", modelPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPlugins 建插件目录、按启动模式决定 allowlist,然后加载全部插件。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
|
||||||
stageHost *agentCore.StageHost, bootMode, dataDir string) {
|
|
||||||
// Auto-create plugins directory (without hardcoding plugin names)
|
|
||||||
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
|
||||||
|
|
||||||
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
|
||||||
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
|
||||||
if bootMode == "failback" {
|
|
||||||
list := cfgReg.GetString("core.agent.failback_plugins", "webui,pluginmgr,recoverydiag")
|
|
||||||
// 优先使用 guard.yaml 经过 recovery 任务下发的插件集(guard 是 failback 权威)
|
|
||||||
if task, terr := recovery.LoadTask(recovery.TaskPath(dataDir)); terr == nil && len(task.Plugins()) > 0 {
|
|
||||||
list = strings.Join(task.Plugins(), ",")
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, s := range strings.Split(list, ",") {
|
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
|
||||||
names = append(names, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pluginReg.SetLoadAllowlist(names)
|
|
||||||
log.Printf("[homed] failback boot: plugin allowlist = %v", names)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
|
||||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
|
||||||
log.Printf("[homed] warning: load plugins: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
// startAgentRuntime 接线技能索引、起日志管理、启动 agent,返回逆序关停的 cleanup。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry,
|
|
||||||
agent *agentCore.Agent, logDir string, ctx context.Context) func() {
|
|
||||||
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
|
||||||
if sp := pluginReg.Get("skillmgr"); sp != nil {
|
|
||||||
if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
|
|
||||||
agent.SetSkillIndexProvider(prov)
|
|
||||||
log.Printf("[homed] skill index wired from skillmgr plugin")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日志管理:层级压缩 + 保留策略
|
|
||||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
|
||||||
go logManager.Start(ctx)
|
|
||||||
|
|
||||||
agent.Start()
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
// 与原 main 的两个 defer 同序(LIFO):先停 agent,再停日志管理。
|
|
||||||
agent.Stop()
|
|
||||||
logManager.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startIPCServer 起 PING/ACK 心跳服务(含 kernel 状态快照),返回仅在启动成功后生效的 cleanup。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func startIPCServer(dataDir, bootMode string, agent *agentCore.Agent) (*ipc.Server, func()) {
|
|
||||||
started := false
|
|
||||||
ipcServer := ipc.NewServer(dataDir, func() *ipc.Status {
|
|
||||||
st := agent.GetKernelStatus()
|
|
||||||
llmOK := st != nil && st.LLM.Available
|
|
||||||
tools := 0
|
|
||||||
if st != nil {
|
|
||||||
tools = len(st.Tools)
|
|
||||||
}
|
|
||||||
uptime := int64(0)
|
|
||||||
if st != nil {
|
|
||||||
if d, err := time.ParseDuration(st.Uptime); err == nil {
|
|
||||||
uptime = int64(d.Seconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &ipc.Status{
|
|
||||||
PID: os.Getpid(),
|
|
||||||
Boot: bootMode,
|
|
||||||
UptimeSec: uptime,
|
|
||||||
LLMOK: &llmOK,
|
|
||||||
Tools: tools,
|
|
||||||
LastDiag: lastDiagSummary(dataDir),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err := ipcServer.Start(); err != nil {
|
|
||||||
log.Printf("[homed] warning: ipc heartbeat server: %v", err)
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
|
|
||||||
return ipcServer, func() {
|
|
||||||
if started {
|
|
||||||
ipcServer.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startSupervisorRuntime 把真实存活源与重启通道接到 supervisor 上,返回重启请求通道。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func startSupervisorRuntime(sup *supervisor.Daemon, trk *tracker.Tracker, agent *agentCore.Agent) chan struct{} {
|
|
||||||
sup.SetTracker(trk)
|
|
||||||
sup.RegisterAgent("main")
|
|
||||||
|
|
||||||
// 真实存活源 + 重启通道:daemon 心跳语义由此修正(lastHB 只在确认存活时更新),
|
|
||||||
// 重启动作不再空转——清理后以特殊退出码交给 guard/systemd 重建。
|
|
||||||
restartCh := make(chan struct{}, 1)
|
|
||||||
sup.SetHeartbeatSource(func(id types.AgentID) (time.Time, types.HealthStatus, error) {
|
|
||||||
st := agent.GetKernelStatus()
|
|
||||||
if st == nil {
|
|
||||||
return time.Time{}, types.HealthDown, fmt.Errorf("no kernel status")
|
|
||||||
}
|
|
||||||
h := types.HealthHealthy
|
|
||||||
if !st.LLM.Available {
|
|
||||||
h = types.HealthDegraded
|
|
||||||
}
|
|
||||||
return time.Now(), h, nil
|
|
||||||
})
|
|
||||||
sup.SetRestartHandler(func(id types.AgentID) {
|
|
||||||
select {
|
|
||||||
case restartCh <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return restartCh
|
|
||||||
}
|
|
||||||
|
|
||||||
// startHeartbeat 每 5s 触碰 <data>/heartbeat(guard 据此判定 worker 存活/卡死),返回停止函数。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func startHeartbeat(dataDir string, ctx context.Context) func() {
|
|
||||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
|
||||||
hbPath := filepath.Join(dataDir, "heartbeat")
|
|
||||||
hbStop := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
t := time.NewTicker(5 * time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
writeHB := func() {
|
|
||||||
if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
|
||||||
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeHB()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-t.C:
|
|
||||||
writeHB()
|
|
||||||
case <-hbStop:
|
|
||||||
return
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return func() { close(hbStop) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForShutdown 阻塞至 SIGINT/SIGTERM 或 supervisor 请求重启,然后按原 main 的顺序清理,需要重建时以退出码交回 guard。
|
|
||||||
//
|
|
||||||
// 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变,
|
|
||||||
// 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。
|
|
||||||
func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{}, stopHeartbeat func(),
|
|
||||||
pluginReg *plugin.Registry, trk *tracker.Tracker, cfgReg *internalConfig.ConfigRegistry, sup *supervisor.Daemon) {
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
restartRequested := false
|
|
||||||
select {
|
|
||||||
case <-sigCh:
|
|
||||||
log.Printf("[homed] shutting down...")
|
|
||||||
case <-restartCh:
|
|
||||||
restartRequested = true
|
|
||||||
log.Printf("[homed] restart requested, shutting down cleanly then exiting with code %d", exitRestartRequested)
|
|
||||||
}
|
|
||||||
|
|
||||||
stopHeartbeat()
|
|
||||||
pluginReg.StopAll()
|
|
||||||
if trk != nil {
|
|
||||||
trk.Stop()
|
|
||||||
}
|
|
||||||
if err := cfgReg.Flush(); err != nil {
|
|
||||||
log.Printf("[homed] flush config: %v", err)
|
|
||||||
}
|
|
||||||
sup.Shutdown()
|
|
||||||
log.Printf("[homed] stopped")
|
|
||||||
|
|
||||||
if restartRequested {
|
|
||||||
os.Exit(exitRestartRequested)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initLuaVM 起 Lua VM(LLM 协议适配);启动失败只告警,cleanup 为 no-op。
|
|
||||||
func initLuaVM(cfg *types.Config) (*luapkg.VM, func()) {
|
|
||||||
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
|
||||||
if err := luaVM.Start(); err != nil {
|
|
||||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
|
||||||
return luaVM, func() {}
|
|
||||||
}
|
|
||||||
return luaVM, luaVM.Stop
|
|
||||||
}
|
|
||||||
|
|
||||||
// initSupervisor 起守护管理(代理生命周期管理);起不来是致命错误。
|
|
||||||
func initSupervisor(cfg *types.Config) *supervisor.Daemon {
|
|
||||||
sup := supervisor.New(cfg)
|
|
||||||
if err := sup.Start(); err != nil {
|
|
||||||
log.Fatalf("start supervisor: %v", err)
|
|
||||||
}
|
|
||||||
return sup
|
|
||||||
}
|
|
||||||
|
|
||||||
// initTracker 起 overlayfs 变更追踪;无 overlayfs 支持时降级为非致命告警。
|
|
||||||
func initTracker(cfg *types.Config, agentWorkDir string) *tracker.Tracker {
|
|
||||||
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir,
|
|
||||||
tracker.WithKeepChangesets(100),
|
|
||||||
tracker.WithMaxChangesetAge(30*24*time.Hour),
|
|
||||||
)
|
|
||||||
if err := trk.Init(); err != nil {
|
|
||||||
log.Printf("[homed] warning: tracker init: %v", err)
|
|
||||||
} else {
|
|
||||||
if err := trk.Start(); err != nil {
|
|
||||||
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trk
|
|
||||||
}
|
|
||||||
|
|
||||||
// initKernelAPI 建内核与插件之间的两个通道:IOManager(IO 抽象层)+ EventBus(事件总线)。
|
|
||||||
func initKernelAPI() (*agentIO.IOManager, *events.Bus) {
|
|
||||||
iom := agentIO.NewIOManager()
|
|
||||||
evBus := events.NewBus()
|
|
||||||
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
|
||||||
return iom, evBus
|
|
||||||
}
|
|
||||||
|
|
||||||
// initTextMemory 起文本记忆;启动失败只告警,cleanup 为 no-op。
|
|
||||||
func initTextMemory(cfg *types.Config) (*text.Memory, func()) {
|
|
||||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
|
||||||
if err := textMem.Start(); err != nil {
|
|
||||||
log.Printf("[homed] warning: text memory start: %v", err)
|
|
||||||
return textMem, func() {}
|
|
||||||
}
|
|
||||||
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
|
||||||
return textMem, textMem.Stop
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveBaseAPIKey 解析兜底 API key:配置项 > LLM_API_KEY > DEEPSEEK_API_KEY。
|
|
||||||
func resolveBaseAPIKey(cfg *types.Config) string {
|
|
||||||
apiKey := cfg.LLM.APIKey
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = os.Getenv("LLM_API_KEY")
|
|
||||||
}
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
|
||||||
}
|
|
||||||
return apiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// wirePluginSDK 把内核各面注入每个插件的 PluginSDK(阶段6 将替换遗留的 util.Configure)。
|
|
||||||
func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey string, sup *supervisor.Daemon,
|
|
||||||
trk *tracker.Tracker, cfg *types.Config, stageHost *agentCore.StageHost, memIdx *memory.Indexer,
|
|
||||||
agent *agentCore.Agent) {
|
|
||||||
pluginReg.SetLuaVM(luaVM)
|
|
||||||
pluginReg.SetBaseAPIKey(baseAPIKey)
|
|
||||||
pluginReg.SetSupervisor(supervisor.NewSDKAdapter(sup))
|
|
||||||
pluginReg.SetTracker(trk)
|
|
||||||
pluginReg.SetConfig(cfg)
|
|
||||||
pluginReg.SetStageHost(stageHost)
|
|
||||||
pluginReg.SetIndexer(memIdx)
|
|
||||||
pluginReg.SetStatusProvider(agent)
|
|
||||||
pluginReg.SetTerminalAPI(agent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
|
|
||||||
//
|
|
||||||
// 优先级:CLI --webui > 核心配置 webui.listen_addr(仅当它被改成非内置默认值)。
|
|
||||||
// 两者都不给时由 webui 插件自己的 settings["addr"] 决定。
|
|
||||||
//
|
|
||||||
// 为什么不写成“内核在插件加载前 Set 插件 settings['addr']”:那时
|
|
||||||
// config_webui 表还没建(表只在插件注册 def 时创建),PluginSettings.Set 的
|
|
||||||
// INSERT 会失败而错误被忽略,随后插件 Start 里 RegisterDef 才建表并写入默认
|
|
||||||
// :8080 —— 于是 CLI --webui 与 webui.listen_addr **一直是死配置**,
|
|
||||||
// 无论怎么传都监听 :8080。覆盖值改由插件自己接收(webui.SetListenOverride)。
|
|
||||||
func resolveWebUIOverride(cfgReg *internalConfig.ConfigRegistry, httpAddr string) string {
|
|
||||||
if strings.TrimSpace(httpAddr) != "" {
|
|
||||||
return strings.TrimSpace(httpAddr)
|
|
||||||
}
|
|
||||||
// webui.listen_addr 的播种默认值就是 ":8080";与默认值相同视为“未配置”,
|
|
||||||
// 否则会把用户在设置页里改过的插件 addr 顶掉。
|
|
||||||
if v := strings.TrimSpace(cfgReg.GetString("webui.listen_addr", ":8080")); v != "" && v != ":8080" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// options 是 worker 的命令行参数。
|
|
||||||
type options struct {
|
|
||||||
dataDir string
|
|
||||||
httpAddr string
|
|
||||||
cliSocket string
|
|
||||||
role string
|
|
||||||
boot string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFlags 解析命令行参数。
|
|
||||||
func parseFlags() options {
|
|
||||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
|
||||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
|
||||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
|
||||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
|
||||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
|
||||||
flag.Parse()
|
|
||||||
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
|
|
||||||
}
|
|
||||||
|
|
||||||
// compactConfigDB 在空闲页够多时压缩配置库;失败只告警(不影响启动)。
|
|
||||||
//
|
|
||||||
// 触发条件(见 internal/config.MaybeCompact):空闲页 >= 1MB 且占页数 >= 25%。
|
|
||||||
// 放在插件加载之后调用——迁移/清理大值发生在插件 Start 里,之前调用没有意义。
|
|
||||||
func compactConfigDB(cfgReg *internalConfig.ConfigRegistry) {
|
|
||||||
before := int64(-1)
|
|
||||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
|
||||||
before = st.Size()
|
|
||||||
}
|
|
||||||
done, err := cfgReg.MaybeCompact(1<<20, 0.25)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[homed] warning: 配置库压缩失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !done {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
after := before
|
|
||||||
if st, err := os.Stat(cfgReg.DBPath()); err == nil {
|
|
||||||
after = st.Size()
|
|
||||||
}
|
|
||||||
log.Printf("[homed] 配置库已压缩: %d -> %d 字节", before, after)
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
|
|
||||||
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
|
|
||||||
//
|
|
||||||
// 历史 bug:main() 拆分时函数体内残留一句 `defer distiller.Stop()`,
|
|
||||||
// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。
|
|
||||||
// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调
|
|
||||||
// distillOnce,绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的
|
|
||||||
// 真实调用方式断言,才照得出来。
|
|
||||||
func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
// NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。
|
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
st, cleanup := initMemoryStack(dir)
|
|
||||||
if st == nil || st.distiller == nil {
|
|
||||||
cleanup()
|
|
||||||
t.Fatal("initMemoryStack 未返回蒸馏器")
|
|
||||||
}
|
|
||||||
if st.db == nil {
|
|
||||||
cleanup()
|
|
||||||
t.Skip("图库未初始化,无法验证蒸馏接线")
|
|
||||||
}
|
|
||||||
if st.distiller.Stopped() {
|
|
||||||
cleanup()
|
|
||||||
t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉(defer Stop 残留?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup 是唯一的停机点:先停蒸馏器、再关图库。
|
|
||||||
cleanup()
|
|
||||||
if !st.distiller.Stopped() {
|
|
||||||
t.Fatal("cleanup 之后蒸馏器应已停止")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,20 +2,49 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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"
|
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"
|
||||||
|
"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/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"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter"
|
||||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
_ "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/pluginmgr"
|
||||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
_ "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"
|
||||||
|
|
||||||
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
// 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。
|
||||||
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。
|
||||||
@ -23,167 +52,699 @@ import (
|
|||||||
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
|
||||||
)
|
)
|
||||||
|
|
||||||
// main 是 worker 进程的启动序列。
|
|
||||||
//
|
|
||||||
// 形状约定:本函数只保留「顺序编排 + 就地交接」——
|
|
||||||
// - 每个阶段一行调用,参数即该阶段的全部依赖(依赖顺序即调用顺序);
|
|
||||||
// - 阶段实现体在同包 bootstrap.go,与这里的调用一一对应;
|
|
||||||
// - 需要逆序释放的资源由阶段函数返回 cleanup,在**原位** defer 注册,
|
|
||||||
// 因此释放顺序与拆分前完全一致。
|
|
||||||
func main() {
|
func main() {
|
||||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||||
// platform_windows.go。
|
// platform_windows.go。
|
||||||
requireSupportedPlatform()
|
requireSupportedPlatform()
|
||||||
|
|
||||||
opt := parseFlags()
|
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()
|
||||||
|
|
||||||
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
// 父守护模式:只负责拉起/守护 worker,不初始化 agent 内核
|
||||||
if opt.role == "guard" {
|
if *role == "guard" {
|
||||||
runGuard(resolveDataDir(opt.dataDir))
|
runGuard(resolveDataDir(*dataDir))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[homed] role=agent boot=%s", opt.boot)
|
log.Printf("[homed] role=agent boot=%s", *boot)
|
||||||
|
|
||||||
if opt.dataDir == "" {
|
if *dataDir == "" {
|
||||||
opt.dataDir = resolveDataDir(opt.dataDir)
|
*dataDir = resolveDataDir(*dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if opt.cliSocket == "" {
|
if *cliSocket == "" {
|
||||||
opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock")
|
*cliSocket = filepath.Join(*dataDir, "cli.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
logDir := setupLogging(opt.dataDir)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[homed] starting %s", meta.FullVersion())
|
log.Printf("[homed] starting %s", meta.FullVersion())
|
||||||
|
|
||||||
agentWorkDir := ensureDataDirs(opt.dataDir)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 基础设施层:记忆、技能 ----
|
// ========================================================================
|
||||||
|
// 基础设施层:记忆、技能
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
mem, closeMem := initMemoryStack(opt.dataDir)
|
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db"))
|
||||||
defer closeMem()
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 配置中心(SQLite 持久化,唯一配置源) ----
|
memIdx := memory.NewIndexer(memDB)
|
||||||
|
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||||
|
socialStore := social.New(memDB)
|
||||||
|
|
||||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db"))
|
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"))
|
||||||
defer cfgReg.Close()
|
defer cfgReg.Close()
|
||||||
cfgReg.SeedDefaults(opt.dataDir)
|
cfgReg.SeedDefaults(*dataDir)
|
||||||
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
// LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线
|
||||||
cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json"))
|
cfgReg.SetLLMSnapshotFile(filepath.Join(*dataDir, "llm_snapshot.json"))
|
||||||
cfg := cfgReg.ToConfig()
|
cfg := cfgReg.ToConfig()
|
||||||
|
|
||||||
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
// 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例,
|
||||||
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
// 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。
|
||||||
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...)
|
||||||
mem.distiller.SetEmbedder(embedder)
|
distiller.SetEmbedder(embedder)
|
||||||
|
|
||||||
// ---- Lua VM(LLM 协议适配) ----
|
// ========================================================================
|
||||||
|
// Lua VM(LLM 协议适配)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
luaVM, closeLuaVM := initLuaVM(cfg)
|
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||||
defer closeLuaVM()
|
if err := luaVM.Start(); err != nil {
|
||||||
|
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||||
|
} else {
|
||||||
|
defer luaVM.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 守护管理(代理生命周期管理) ----
|
// ========================================================================
|
||||||
|
// 守护管理(代理生命周期管理)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
sup := initSupervisor(cfg)
|
sup := supervisor.New(cfg)
|
||||||
|
if err := sup.Start(); err != nil {
|
||||||
|
log.Fatalf("start supervisor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 变更追踪(overlayfs) ----
|
// ========================================================================
|
||||||
|
// 变更追踪(overlayfs)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
trk := initTracker(cfg, agentWorkDir)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 内核 API:IOManager(IO 抽象层) + EventBus(事件总线)
|
// 内核 API:IOManager(IO 抽象层) + EventBus(事件总线)
|
||||||
// 所有插件通过这两个通道与核心交互
|
// 所有插件通过这两个通道与核心交互
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
iom, evBus := initKernelAPI()
|
iom := agentIO.NewIOManager()
|
||||||
|
evBus := events.NewBus()
|
||||||
|
log.Printf("[homed] kernel API ready: IOManager + EventBus")
|
||||||
|
|
||||||
// ---- 文本记忆 + 记忆蒸馏管线 ----
|
// ========================================================================
|
||||||
|
// 文本记忆 + 记忆蒸馏管线
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
textMem, closeTextMem := initTextMemory(cfg)
|
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
||||||
defer closeTextMem()
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := context.WithCancel(context.Background())
|
ctx, stop := context.WithCancel(context.Background())
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
startMemoryCandidateConsumer(ctx, iom, textMem, mem.db, mem.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)
|
||||||
|
|
||||||
// ---- LLM Provider 管理(多源,通过 Lua 适配器协议转换) ----
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
baseAPIKey := resolveBaseAPIKey(cfg)
|
if input != "" && memDB != nil {
|
||||||
|
distiller.Append("agent", "user", input)
|
||||||
|
}
|
||||||
|
if response != "" && memDB != nil {
|
||||||
|
distiller.Append("agent", "assistant", response)
|
||||||
|
}
|
||||||
|
|
||||||
providerMgr := initLLMProviders(cfg, luaVM, baseAPIKey)
|
// 工具输出接入蒸馏管线
|
||||||
|
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)
|
||||||
|
}
|
||||||
provider := providerMgr.Default()
|
provider := providerMgr.Default()
|
||||||
|
|
||||||
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
// L1 failback:受限 worker 启动即跑恢复梯子(probe→还原DNS/proxy→还原config+ReloadFromConfig→probe),
|
||||||
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
// 结果以退出码 exitRecovered=43 / exitRecoveryFailed=44 交回 guard,不进入主 agent 循环。
|
||||||
if opt.boot == "failback" {
|
if *boot == "failback" {
|
||||||
runFailbackRecovery(opt.dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
runFailbackRecovery(*dataDir, cfgReg, luaVM, providerMgr, baseAPIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 文档记忆 + 知识库 ----
|
// ========================================================================
|
||||||
|
// 文档记忆 + 知识库
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
docStore, closeDocStore := initDocStore(cfg)
|
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
|
||||||
defer closeDocStore()
|
if err := docStore.Start(); err != nil {
|
||||||
|
log.Printf("[homed] warning: document store: %v", err)
|
||||||
|
}
|
||||||
|
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||||
|
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||||
|
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||||
|
defer docStore.Stop()
|
||||||
|
|
||||||
mediaStore, closeMediaStore := initMediaStore(cfgReg, cfg)
|
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||||
defer closeMediaStore()
|
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||||
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg)
|
// 统一多模态向量空间。
|
||||||
defer closeMultimodal()
|
//
|
||||||
|
// 核心**不**知道任何具体模型:它只按配置里的 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 迁移必须在 store 扫盘**之前**:改名后扫盘一次到位,
|
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
|
||||||
// 避免先以旧名建索引、再改名造成内存键与盘上目录短暂不一致。
|
if err := ks.Start(); err != nil {
|
||||||
initKnowledgeMigration(cfg)
|
log.Printf("[homed] warning: knowledge store: %v", err)
|
||||||
ks := initKnowledgeStore(cfg)
|
} else {
|
||||||
|
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 人格设定 ----
|
// ========================================================================
|
||||||
|
// 人格设定
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
personality := loadPersonality(cfg, cfgReg)
|
// 人格来源优先级: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] 人格来源=无(配置项为空且无人格文件)")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 阶段管道(StageHost)+ 插件系统(Registry) ----
|
// ========================================================================
|
||||||
|
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
stageHost, pluginReg := newStageAndRegistry(cfg, cfgReg, iom, evBus, mem.db, textMem,
|
stageHost := agentCore.NewStageHost()
|
||||||
docStore, mediaStore, ks, providerMgr, opt.dataDir)
|
|
||||||
|
|
||||||
// ---- Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) ----
|
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 := newMainAgent(cfg, cfgReg, provider, providerMgr, iom, mem.db, mem.indexer, trk,
|
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||||
docStore, ks, mem.social, textMem, mediaStore, personality, pluginReg, embedder,
|
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
multimodalSpace, mmProviderName, mmErr, stageHost, evBus)
|
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)
|
||||||
|
|
||||||
wirePluginSDK(pluginReg, luaVM, baseAPIKey, sup, trk, cfg, stageHost, mem.indexer, agent)
|
// ========================================================================
|
||||||
|
// 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)
|
||||||
|
|
||||||
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
||||||
cli.DefaultSocket = opt.cliSocket
|
cli.DefaultSocket = *cliSocket
|
||||||
// webui 监听地址覆盖:必须在 loadPlugins 之前设置,插件 Start 时会读它。
|
// webui 插件作为内置插件经 Registry 启动,读取自身 settings["addr"](默认 :8080)。
|
||||||
webui.SetListenOverride(resolveWebUIOverride(cfgReg, opt.httpAddr))
|
// 保留 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 依存句法分析器(内嵌 ONNX 模型 / 规则引擎) ----
|
// ========================================================================
|
||||||
|
// 依存句法分析器(内嵌 ONNX 模型 / 规则引擎)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
initONNXParser(cfg, cfgReg)
|
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(cfg, cfgReg, pluginReg, stageHost, opt.boot, opt.dataDir)
|
// Auto-create plugins directory (without hardcoding plugin names)
|
||||||
|
os.MkdirAll(cfg.Plugin.Dir, 0755)
|
||||||
|
|
||||||
// 插件加载完成后再回收空闲页:大值(如老版聊天记录)可能在这一步被搬走/删除,
|
// failback 受限启动:仅装载 failback 插件集(webfetch/files/cmd 为内核内置,
|
||||||
// 而 SQLite 的 DELETE 不会缩小文件。
|
// 此处仅控制外部插件,默认含 recoverydiag 以便直接在受限态产出恢复结论)
|
||||||
compactConfigDB(cfgReg)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
|
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||||
defer stopRuntime()
|
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())
|
||||||
|
|
||||||
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
|
// 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入)
|
||||||
defer closeIPC()
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
restartCh := startSupervisorRuntime(sup, trk, agent)
|
// 日志管理:层级压缩 + 保留策略
|
||||||
|
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:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
|
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()))
|
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||||
|
|
||||||
// ---- 等待退出信号 ----
|
// ========================================================================
|
||||||
|
// 等待退出信号
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
stopHeartbeat := startHeartbeat(opt.dataDir, ctx)
|
sigCh := make(chan os.Signal, 1)
|
||||||
waitForShutdown(ctx, opt.dataDir, restartCh, stopHeartbeat, pluginReg, trk, cfgReg, sup)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
// 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": {
|
"app": {
|
||||||
"bundleName": "com.example.homeagent",
|
"bundleName": "com.example.homeagent",
|
||||||
"vendor": "HomeAgent",
|
"vendor": "HomeAgent",
|
||||||
"versionCode": 1004000,
|
"versionCode": 1001001,
|
||||||
"versionName": "1.4.0",
|
"versionName": "1.1.1",
|
||||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||||
"icon": "$media:layered_image",
|
"icon": "$media:layered_image",
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
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 '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* 附件的字节获取与解码(网络 / 沙箱 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* 附件的解析与格式化(纯函数,无 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,9 +5,6 @@ import { deviceInfo } from '@kit.BasicServicesKit';
|
|||||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||||
import { componentSnapshot } from '@kit.ArkUI';
|
import { componentSnapshot } from '@kit.ArkUI';
|
||||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||||
import { camera, cameraPicker } from '@kit.CameraKit';
|
|
||||||
import { fileIo, fileUri } from '@kit.CoreFileKit';
|
|
||||||
import { appVersion } from './AppVersion';
|
|
||||||
|
|
||||||
// ===== 能力结果 =====
|
// ===== 能力结果 =====
|
||||||
|
|
||||||
@ -20,17 +17,12 @@ export const LOCAL_DEVICE_CAPS: string[] = [
|
|||||||
'clipboardsee',
|
'clipboardsee',
|
||||||
'clipboardsue',
|
'clipboardsue',
|
||||||
'speakeruse',
|
'speakeruse',
|
||||||
'camerasue',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface CapResult {
|
export interface CapResult {
|
||||||
status: string; // 'ok' | 'error'
|
status: string; // 'ok' | 'error'
|
||||||
output: string;
|
output: string;
|
||||||
error: string;
|
error: string;
|
||||||
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
|
|
||||||
// DeviceBridge 不要再发 cmd_result;否则网关会把后续分块挂在一条已完成的
|
|
||||||
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
|
|
||||||
chunked?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeviceStatusPayload {
|
interface DeviceStatusPayload {
|
||||||
@ -79,19 +71,6 @@ function errResult(errMsg: string): CapResult {
|
|||||||
return r;
|
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:截取本应用当前画面(前台时为整屏可见内容)=====
|
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
||||||
|
|
||||||
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
||||||
@ -150,142 +129,6 @@ 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 =====
|
// ===== clipboardsee / clipboardsue =====
|
||||||
|
|
||||||
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||||
@ -406,7 +249,7 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
|||||||
platform: 'OpenHarmony',
|
platform: 'OpenHarmony',
|
||||||
arch: deviceInfo.abiList,
|
arch: deviceInfo.abiList,
|
||||||
os_release: deviceInfo.osFullName,
|
os_release: deviceInfo.osFullName,
|
||||||
version: appVersion(),
|
version: '1.1.1',
|
||||||
cpus: 0,
|
cpus: 0,
|
||||||
brand: deviceInfo.brand,
|
brand: deviceInfo.brand,
|
||||||
manufacturer: deviceInfo.manufacture,
|
manufacturer: deviceInfo.manufacture,
|
||||||
@ -426,3 +269,29 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
|||||||
};
|
};
|
||||||
return okResult(JSON.stringify(payload));
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设备桥协议:消息结构、帧构造与分块切片。
|
|
||||||
*
|
|
||||||
* 与 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,16 +1,15 @@
|
|||||||
import { deviceBridge } from './DeviceBridge';
|
import { deviceBridge } from './DeviceBridge';
|
||||||
import {
|
import {
|
||||||
CapResult,
|
CapResult,
|
||||||
DataChunkSender,
|
|
||||||
capScreensee,
|
capScreensee,
|
||||||
capCamerasue,
|
|
||||||
capClipboardSee,
|
capClipboardSee,
|
||||||
capClipboardsue,
|
capClipboardsue,
|
||||||
capSpeakerUse,
|
capSpeakerUse,
|
||||||
capDeviceInfo,
|
capDeviceInfo,
|
||||||
capStatus,
|
capStatus,
|
||||||
|
parseScreensue,
|
||||||
|
ScreensuePayload,
|
||||||
} from './BridgeCaps';
|
} from './BridgeCaps';
|
||||||
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
|
|
||||||
import { connStore } from './ConnStore';
|
import { connStore } from './ConnStore';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
|
|
||||||
@ -85,17 +84,6 @@ async function executeCommand(reqId: string, command: string): Promise<CapResult
|
|||||||
}
|
}
|
||||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
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 (name === 'clipboardsee') {
|
||||||
if (hasArgs(args)) {
|
if (hasArgs(args)) {
|
||||||
return errRes('clipboardsee 不接受额外参数');
|
return errRes('clipboardsee 不接受额外参数');
|
||||||
|
|||||||
@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* 聊天页的纯格式化/判定逻辑(无 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%)';
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* /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;
|
|
||||||
}
|
|
||||||
@ -1,206 +0,0 @@
|
|||||||
/**
|
|
||||||
* 发送 / 中断(无 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();
|
|
||||||
}
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,602 +0,0 @@
|
|||||||
/**
|
|
||||||
* 聊天流状态源(单例)。
|
|
||||||
*
|
|
||||||
* 从 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,18 +17,6 @@ export const DEFAULT_WS_PORT: number = 9890;
|
|||||||
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
||||||
export const CHAT_PAGE_SIZE: number = 40;
|
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) =====
|
// ===== sakura / frost palette (style.css :root) =====
|
||||||
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
|
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
|
||||||
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
|
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
|
||||||
|
|||||||
@ -1,8 +1,67 @@
|
|||||||
import { webSocket } from '@kit.NetworkKit';
|
import { webSocket } from '@kit.NetworkKit';
|
||||||
import { CapResult } from './BridgeCaps';
|
import { CapResult } from './BridgeCaps';
|
||||||
import { BridgeCmdHandler, bridgeHelloFrame, bridgeBindFrame, bridgeResultFrame,
|
|
||||||
bridgeDataStartFrame, bridgeDataEndFrame, bridgeEventFrame, bridgeStatusFrame,
|
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||||
bridgeChunkSlices } from './BridgeProtocol';
|
|
||||||
|
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>;
|
||||||
|
|
||||||
export class DeviceBridgeClient {
|
export class DeviceBridgeClient {
|
||||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||||
@ -188,12 +247,33 @@ export class DeviceBridgeClient {
|
|||||||
|
|
||||||
private sendHello(authorized: boolean): void {
|
private sendHello(authorized: boolean): void {
|
||||||
this.lastAuthorized = authorized;
|
this.lastAuthorized = authorized;
|
||||||
this.send(bridgeHelloFrame(this.deviceId, this.name, this.kind, this.caps,
|
const info: HelloDeviceInfo = {
|
||||||
this.hostname, authorized));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendBind(): void {
|
private sendBind(): void {
|
||||||
this.send(bridgeBindFrame(this.deviceId, this.token));
|
const bind: BindMessage = {
|
||||||
|
op: 'bind',
|
||||||
|
device_id: this.deviceId,
|
||||||
|
token: this.token,
|
||||||
|
};
|
||||||
|
this.send(JSON.stringify(bind));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 命令处理 =====
|
// ===== 命令处理 =====
|
||||||
@ -247,11 +327,6 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||||
handler(reqId, command).then((res: CapResult) => {
|
handler(reqId, command).then((res: CapResult) => {
|
||||||
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
|
|
||||||
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
|
|
||||||
if (res.chunked === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.sendResult(reqId, res.status, res.output, res.error);
|
this.sendResult(reqId, res.status, res.output, res.error);
|
||||||
}).catch((e: Object) => {
|
}).catch((e: Object) => {
|
||||||
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||||
@ -259,16 +334,32 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
||||||
this.send(bridgeResultFrame(reqId, status, output, errMsg));
|
const result: CmdReply = {
|
||||||
|
op: 'cmd_result',
|
||||||
|
req_id: reqId,
|
||||||
|
status: status,
|
||||||
|
output: output,
|
||||||
|
error: errMsg,
|
||||||
|
};
|
||||||
|
this.send(JSON.stringify(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||||
|
|
||||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||||
this.send(bridgeDataStartFrame(reqId, kind, mime, bytes.byteLength));
|
const startMsg: DataStartMessage = {
|
||||||
const chunks: Uint8Array[] = bridgeChunkSlices(bytes);
|
op: 'cmd_data_start',
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
req_id: reqId,
|
||||||
const ab: ArrayBuffer = chunks[i].buffer as ArrayBuffer;
|
kind: kind,
|
||||||
|
mime: mime,
|
||||||
|
total: bytes.byteLength,
|
||||||
|
chunk_size: CHUNK_SIZE,
|
||||||
|
};
|
||||||
|
this.send(JSON.stringify(startMsg));
|
||||||
|
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||||
|
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||||
|
const view: Uint8Array = bytes.slice(off, end);
|
||||||
|
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
|
||||||
try {
|
try {
|
||||||
this.ws.send(ab).catch(() => {
|
this.ws.send(ab).catch(() => {
|
||||||
// ignore per-chunk failure; end frame reports error below
|
// ignore per-chunk failure; end frame reports error below
|
||||||
@ -277,15 +368,32 @@ export class DeviceBridgeClient {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.send(bridgeDataEndFrame(reqId));
|
const endMsg: DataEndMessage = {
|
||||||
|
op: 'cmd_data_end',
|
||||||
|
req_id: reqId,
|
||||||
|
status: 'ok',
|
||||||
|
};
|
||||||
|
this.send(JSON.stringify(endMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent(eventType: string, detail: string): void {
|
sendEvent(eventType: string, detail: string): void {
|
||||||
this.send(bridgeEventFrame(this.deviceId, eventType, detail));
|
const payload: Record<string, string> = { 'detail': detail };
|
||||||
|
const msg: Record<string, Object> = {
|
||||||
|
'op': 'event',
|
||||||
|
'device_id': this.deviceId,
|
||||||
|
'type': eventType,
|
||||||
|
'payload': payload,
|
||||||
|
};
|
||||||
|
this.send(JSON.stringify(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendStatus(status: string): void {
|
sendStatus(status: string): void {
|
||||||
this.send(bridgeStatusFrame(this.deviceId, status));
|
const msg: Record<string, Object> = {
|
||||||
|
'op': 'status',
|
||||||
|
'device_id': this.deviceId,
|
||||||
|
'status': status,
|
||||||
|
};
|
||||||
|
this.send(JSON.stringify(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
send(text: string): void {
|
send(text: string): void {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
import { http } from '@kit.NetworkKit';
|
|
||||||
import { deviceBridge } from './DeviceBridge';
|
import { deviceBridge } from './DeviceBridge';
|
||||||
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||||
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||||
@ -25,14 +24,7 @@ function ensureBridgeStateTracking(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||||
* 把门户 HTTP 地址转换为同源设备桥 WebSocket 地址(**回退路径**)。
|
|
||||||
*
|
|
||||||
* 网关改造为子域反代后位于 devices.<基域名>,而基域名与子域标签都是**服务端
|
|
||||||
* 配置**,客户端拼不出来。正常路径是先调 discoverGateway() 问服务端;
|
|
||||||
* 本函数只在「服务端没有发现端点」(老版本 HomeAgent)时兜底,
|
|
||||||
* 保留旧部署的可用性。
|
|
||||||
*/
|
|
||||||
export function deviceGatewayUrl(base: string): string {
|
export function deviceGatewayUrl(base: string): string {
|
||||||
let trimmed: string = base.trim();
|
let trimmed: string = base.trim();
|
||||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||||
@ -58,72 +50,6 @@ export function deviceGatewayUrl(base: string): string {
|
|||||||
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||||
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
* 因而 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> {
|
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||||
foregroundActive = true;
|
foregroundActive = true;
|
||||||
setBridgeAppContext(context);
|
setBridgeAppContext(context);
|
||||||
@ -141,9 +67,8 @@ export async function startForegroundBridge(context: common.UIAbilityContext): P
|
|||||||
const generation: number = bridgeGeneration;
|
const generation: number = bridgeGeneration;
|
||||||
const deviceId: string = connStore.ensureDeviceId();
|
const deviceId: string = connStore.ensureDeviceId();
|
||||||
try {
|
try {
|
||||||
const gatewayUrl: string = await resolveGatewayUrl(cur.url, cur.apiKey);
|
|
||||||
await deviceBridge.connect(
|
await deviceBridge.connect(
|
||||||
gatewayUrl, cur.apiKey, deviceId,
|
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||||
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||||
if (!foregroundActive || generation !== bridgeGeneration) {
|
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||||
deviceBridge.disconnect();
|
deviceBridge.disconnect();
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设备页的纯逻辑:本机 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;
|
|
||||||
}
|
|
||||||
@ -1,282 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* 插件数据获取(无 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;
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* 插件状态展示口径(纯函数,无 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(' · ');
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设置数据模型与纯解析逻辑(无 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';
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* 阶段轨迹(单例):本轮对话在七个内核阶段里真实发生过什么。
|
|
||||||
*
|
|
||||||
* 由 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,68 +27,6 @@ export interface StatGroup {
|
|||||||
fields: StatField[];
|
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 键:摘要卡与明细页共用 =====
|
// ===== AppStorage 键:摘要卡与明细页共用 =====
|
||||||
export const K_UP: string = 'statUp';
|
export const K_UP: string = 'statUp';
|
||||||
export const K_VERSION: string = 'statVersion';
|
export const K_VERSION: string = 'statVersion';
|
||||||
@ -99,14 +37,10 @@ export const K_TOOLS: string = 'statTools';
|
|||||||
export const K_ERR: string = 'statErr';
|
export const K_ERR: string = 'statErr';
|
||||||
export const K_LOADING: string = 'statLoading';
|
export const K_LOADING: string = 'statLoading';
|
||||||
export const K_REV: string = 'statRev';
|
export const K_REV: string = 'statRev';
|
||||||
/** 内核身份副行:内核名 · commit。版本号光有一个号码分不清是哪个内核、哪次构建。 */
|
|
||||||
export const K_BUILD: string = 'statBuildSub';
|
|
||||||
|
|
||||||
class StatusStore {
|
class StatusStore {
|
||||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||||
private groups: StatGroup[] = [];
|
private groups: StatGroup[] = [];
|
||||||
/** 运行态快照:同上,靠 K_REV 触发订阅组件重取 */
|
|
||||||
private runtimeSnapshot: RuntimeSnapshot | undefined = undefined;
|
|
||||||
|
|
||||||
init(): void {
|
init(): void {
|
||||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||||
@ -118,17 +52,12 @@ class StatusStore {
|
|||||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||||
AppStorage.setOrCreate<string>(K_BUILD, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroups(): StatGroup[] {
|
getGroups(): StatGroup[] {
|
||||||
return this.groups;
|
return this.groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRuntime(): RuntimeSnapshot | undefined {
|
|
||||||
return this.runtimeSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
isUp(): boolean {
|
isUp(): boolean {
|
||||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||||
}
|
}
|
||||||
@ -172,7 +101,6 @@ class StatusStore {
|
|||||||
];
|
];
|
||||||
|
|
||||||
await this.collectKernel(groups);
|
await this.collectKernel(groups);
|
||||||
await this.collectRuntime();
|
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
this.bump();
|
this.bump();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -205,66 +133,8 @@ class StatusStore {
|
|||||||
if (startTime.length > 0) {
|
if (startTime.length > 0) {
|
||||||
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
|
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 });
|
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 / 可用源 / 是否可用
|
// LLM:provider / 可用源 / 是否可用
|
||||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||||
if (llm !== undefined && llm !== null) {
|
if (llm !== undefined && llm !== null) {
|
||||||
@ -351,91 +221,11 @@ 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 {
|
private fail(msg: string): void {
|
||||||
AppStorage.set<string>(K_ERR, msg);
|
AppStorage.set<string>(K_ERR, msg);
|
||||||
AppStorage.set<boolean>(K_UP, false);
|
AppStorage.set<boolean>(K_UP, false);
|
||||||
AppStorage.set<boolean>(K_LOADING, false);
|
AppStorage.set<boolean>(K_LOADING, false);
|
||||||
this.groups = [];
|
this.groups = [];
|
||||||
this.runtimeSnapshot = undefined;
|
|
||||||
this.bump();
|
this.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,265 +0,0 @@
|
|||||||
/**
|
|
||||||
* 「外观」二级页面:主题三选一 + 自定义背景图。
|
|
||||||
*
|
|
||||||
* 从 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,20 +4,107 @@ import { common } from '@kit.AbilityKit';
|
|||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { userMessage } from '../common/UserError';
|
import { userMessage } from '../common/UserError';
|
||||||
import { ChatAttachment } from '../model/Model';
|
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 { 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 { COLOR_ERROR } from '../common/Constants';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { PlainCard } from './SubPage';
|
import { PlainCard } from './SubPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 附件 UI:气泡内的附件卡 + 附件详情二级页内容。
|
* 附件解析与展示。
|
||||||
*
|
*
|
||||||
* 解析/格式化(parseAttachment、formatBytes、extLabel…)在 common/AttachmentMeta.ets,
|
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||||
* 字节获取与解码在 common/AttachmentImage.ets —— 这里只留 UI。
|
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||||
|
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||||
*
|
*
|
||||||
* 附件卡:图片显示缩略图,文件显示一枚文件条。
|
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
|
||||||
|
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
|
||||||
|
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
|
||||||
|
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||||
|
const url: string = o['url'] as string ?? '';
|
||||||
|
if (url.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const t: string = o['type'] as string ?? 'file';
|
||||||
|
const a: ChatAttachment = {
|
||||||
|
type: t === 'image' ? 'image' : 'file',
|
||||||
|
url: url,
|
||||||
|
size: o['size'] as number ?? 0,
|
||||||
|
name: o['name'] as string ?? fileNameOf(url),
|
||||||
|
};
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
|
||||||
|
export function attachmentFromChannelOutput(
|
||||||
|
outputType: string, url: string, size: number): ChatAttachment | undefined {
|
||||||
|
if (url.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (outputType !== 'image' && outputType !== 'file') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const a: ChatAttachment = {
|
||||||
|
type: outputType,
|
||||||
|
url: url,
|
||||||
|
size: size,
|
||||||
|
name: fileNameOf(url),
|
||||||
|
};
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
|
||||||
|
export function fileNameOf(url: string): string {
|
||||||
|
let s: string = url;
|
||||||
|
const q: number = s.indexOf('?');
|
||||||
|
if (q >= 0) {
|
||||||
|
s = s.substring(0, q);
|
||||||
|
}
|
||||||
|
const i: number = s.lastIndexOf('/');
|
||||||
|
const name: string = i >= 0 ? s.substring(i + 1) : s;
|
||||||
|
return name.length > 0 ? name : '附件';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人类可读字节数,口径对齐后端 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 是新开标签页,移动端改为二级页)。
|
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ -341,3 +428,36 @@ export struct AttachmentDetailContent {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* 输入区上方的两个悬浮条:加号菜单(图片/文件)与待发送附件预览。
|
|
||||||
*
|
|
||||||
* 从 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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
/**
|
|
||||||
* 单条聊天气泡(含头像、渠道名、思考卡、工具卡、附件卡、正文)。
|
|
||||||
*
|
|
||||||
* 从 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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,361 +0,0 @@
|
|||||||
/**
|
|
||||||
* 悬浮输入区:加号菜单 + 待发送附件预览 + 输入行(含发送/中断)。
|
|
||||||
*
|
|
||||||
* 从 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%')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
/**
|
|
||||||
* 聊天消息流:列表 + 顶栏遮罩 + 底部淡出遮罩 + 滚动/懒加载。
|
|
||||||
*
|
|
||||||
* 从 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%')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
/**
|
|
||||||
* 气泡内的两张"面板"卡:思考过程卡 + 工具调用卡。
|
|
||||||
*
|
|
||||||
* 从 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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,356 +0,0 @@
|
|||||||
/**
|
|
||||||
* 「后端连接」二级页面。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,316 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设备页一级入口列表(本机 / 通道两组)。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,291 +0,0 @@
|
|||||||
/**
|
|
||||||
* 插件详情(二级页面内容)。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 插件一级列表(列表卡 + 三种占位态)。
|
|
||||||
*
|
|
||||||
* 从 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%')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* 安装表单悬浮卡。
|
|
||||||
*
|
|
||||||
* 从 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 }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,385 +0,0 @@
|
|||||||
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,24 +2,12 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo
|
|||||||
import { GradientBackground } from './GradientBackground';
|
import { GradientBackground } from './GradientBackground';
|
||||||
import { PageTopBar } from './PageTopBar';
|
import { PageTopBar } from './PageTopBar';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
|
|
||||||
import { webview } from '@kit.ArkWeb';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* agent 主动推送的前台内容页。
|
* agent 主动推送的前台内容页。
|
||||||
*
|
*
|
||||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||||
*
|
|
||||||
* 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 ScreensueHtml)。
|
|
||||||
*
|
|
||||||
* HTML 走 **Web 组件**(用户明确要求):agent 推的常是完整文档 —— 带 <style>
|
|
||||||
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
|
|
||||||
* 对这些一律不渲染,实测只能看到满屏源码。
|
|
||||||
*
|
|
||||||
* 安全:内容来自 agent(第三方),所以显式关掉 JS 与本地文件访问 ——
|
|
||||||
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
|
|
||||||
* 纯文本仍走 Text(无需开销,也不该把文本塞进 Web)。
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
export struct ScreensuePage {
|
export struct ScreensuePage {
|
||||||
@ -29,16 +17,6 @@ export struct ScreensuePage {
|
|||||||
onClose: () => void = () => {
|
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() {
|
build() {
|
||||||
Stack({ alignContent: Alignment.Bottom }) {
|
Stack({ alignContent: Alignment.Bottom }) {
|
||||||
GradientBackground()
|
GradientBackground()
|
||||||
@ -66,22 +44,13 @@ export struct ScreensuePage {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
|
|
||||||
Column() {
|
Column() {
|
||||||
if (this.htmlMode()) {
|
Text(this.pushedText)
|
||||||
// HTML:整篇交给 Web 渲染(base64 loadData,见 screensueWebData)。
|
.fontSize(16)
|
||||||
// 高度固定 420vp:Web 不参与父级自适应测量,给 height('100%')
|
.lineHeight(25)
|
||||||
// 会在 Scroll 里塌成 0。内容区本身可滚。
|
.fontColor(this.palette().textPrimary)
|
||||||
ScreenWebView({ data: this.webData(), isDark: this.isDark })
|
.width('100%')
|
||||||
.width('100%')
|
.textAlign(TextAlign.Start)
|
||||||
.height(420)
|
.copyOption(CopyOptions.LocalDevice)
|
||||||
} else {
|
|
||||||
Text(this.pushedText)
|
|
||||||
.fontSize(16)
|
|
||||||
.lineHeight(25)
|
|
||||||
.fontColor(this.palette().textPrimary)
|
|
||||||
.width('100%')
|
|
||||||
.textAlign(TextAlign.Start)
|
|
||||||
.copyOption(CopyOptions.LocalDevice)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding(18)
|
.padding(18)
|
||||||
@ -132,66 +101,3 @@ export struct ScreensuePage {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
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) {
|
|
||||||
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* 单个配置项卡片。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。
|
|
||||||
*
|
|
||||||
* 从 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* 设置一级页的入口列表。
|
|
||||||
*
|
|
||||||
* 从 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,13 +4,285 @@
|
|||||||
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
|
* 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.
|
* Uses Span children inside Text for inline bold/italic/code/link formatting.
|
||||||
*
|
*
|
||||||
* 解析规则在 common/MarkdownParser.ets(已抽出,见那里);
|
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
|
||||||
* 本文件只负责把 MdBlock 渲染成 ArkUI 组件树。
|
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||||
import { RADIUS_SM } from '../common/Constants';
|
import { RADIUS_SM } from '../common/Constants';
|
||||||
import { MdBlock, MdSpan, parseBlocks, parseInline } from '../common/MarkdownParser';
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MdBlock {
|
||||||
|
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
|
||||||
|
level?: number;
|
||||||
|
items?: string[];
|
||||||
|
text?: string;
|
||||||
|
lang?: string;
|
||||||
|
codeLines?: string[];
|
||||||
|
headers?: string[];
|
||||||
|
rows?: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MdSpan {
|
||||||
|
text: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
code?: boolean;
|
||||||
|
link?: boolean;
|
||||||
|
linkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline parser ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function parseInline(text: string): MdSpan[] {
|
||||||
|
const spans: MdSpan[] = [];
|
||||||
|
let i: number = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
// Inline code (backtick)
|
||||||
|
if (text[i] === '`') {
|
||||||
|
const end: number = text.indexOf('`', i + 1);
|
||||||
|
if (end > i) {
|
||||||
|
spans.push({ text: text.substring(i + 1, end), code: true });
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bold: **text**
|
||||||
|
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
|
||||||
|
const end: number = text.indexOf('**', i + 2);
|
||||||
|
if (end > i + 1) {
|
||||||
|
spans.push({ text: text.substring(i + 2, end), bold: true });
|
||||||
|
i = end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Italic: *text* (single asterisk)
|
||||||
|
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
|
||||||
|
const end: number = text.indexOf('*', i + 1);
|
||||||
|
if (end > i) {
|
||||||
|
spans.push({ text: text.substring(i + 1, end), italic: true });
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Link: [text](url)
|
||||||
|
if (text[i] === '[') {
|
||||||
|
const cb: number = text.indexOf(']', i + 1);
|
||||||
|
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
|
||||||
|
const cp: number = text.indexOf(')', cb + 2);
|
||||||
|
if (cp > cb + 1) {
|
||||||
|
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
|
||||||
|
i = cp + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Plain run
|
||||||
|
let j: number = i + 1;
|
||||||
|
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
spans.push({ text: text.substring(i, j) });
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Block parser helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isHr(line: string): boolean {
|
||||||
|
if (line.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ch: string = line[0];
|
||||||
|
if (ch !== '-' && ch !== '*' && ch !== '_') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let k = 0; k < line.length; k++) {
|
||||||
|
if (line[k] !== ch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOlStart(line: string): boolean {
|
||||||
|
if (line.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let k: number = 0;
|
||||||
|
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableSep(line: string): boolean {
|
||||||
|
if (!line.includes('-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let k = 0; k < line.length; k++) {
|
||||||
|
const c: string = line[k];
|
||||||
|
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Block parser ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function parseBlocks(content: string): MdBlock[] {
|
||||||
|
if (content.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const lines: string[] = content.split('\n');
|
||||||
|
const blocks: MdBlock[] = [];
|
||||||
|
let i: number = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line: string = lines[i];
|
||||||
|
|
||||||
|
// Empty line
|
||||||
|
if (line.trim().length === 0) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code fence
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
const langEnd: number = line.indexOf('`', 3);
|
||||||
|
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
|
||||||
|
const codeLines: string[] = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
|
||||||
|
codeLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < lines.length) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading
|
||||||
|
if (line.startsWith('#')) {
|
||||||
|
let level: number = 0;
|
||||||
|
while (level < line.length && line[level] === '#') {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
if (level <= 6 && level < line.length && line[level] === ' ') {
|
||||||
|
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (isHr(line.trim())) {
|
||||||
|
blocks.push({ type: 'hr' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
|
||||||
|
items.push(lines[i].substring(2));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: 'list', items: items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list
|
||||||
|
if (isOlStart(line)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && isOlStart(lines[i])) {
|
||||||
|
const dotIdx: number = lines[i].indexOf('. ');
|
||||||
|
items.push(lines[i].substring(dotIdx + 2));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: 'ol', items: items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
if (line.startsWith('> ')) {
|
||||||
|
const qLines: string[] = [];
|
||||||
|
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||||
|
qLines.push(lines[i].substring(2));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table
|
||||||
|
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
|
||||||
|
const tLines: string[] = [];
|
||||||
|
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
|
||||||
|
tLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (tLines.length >= 2) {
|
||||||
|
const parseRow = (row: string): string[] => {
|
||||||
|
const cells: string[] = [];
|
||||||
|
const parts: string[] = row.split('|');
|
||||||
|
for (let p = 0; p < parts.length; p++) {
|
||||||
|
const c: string = parts[p].trim();
|
||||||
|
if (c.length > 0) {
|
||||||
|
cells.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
const headers: string[] = parseRow(tLines[0]);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
for (let k = 1; k < tLines.length; k++) {
|
||||||
|
if (!isTableSep(tLines[k].trim())) {
|
||||||
|
rows.push(parseRow(tLines[k]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headers.length > 0) {
|
||||||
|
blocks.push({ type: 'table', headers: headers, rows: rows });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph: collect consecutive non-special lines
|
||||||
|
{
|
||||||
|
const paraLines: string[] = [];
|
||||||
|
while (i < lines.length) {
|
||||||
|
const ln: string = lines[i];
|
||||||
|
if (ln.trim().length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
paraLines.push(ln);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (paraLines.length > 0) {
|
||||||
|
blocks.push({ type: 'para', text: paraLines.join('\n') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export struct StaticMarkdownView {
|
export struct StaticMarkdownView {
|
||||||
|
|||||||
@ -2,10 +2,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM,
|
|||||||
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||||
import { MotionBase } from './MotionBase';
|
import { MotionBase } from './MotionBase';
|
||||||
import { RuntimePanel } from './RuntimePanel';
|
|
||||||
import {
|
import {
|
||||||
statusStore, StatGroup, StatField, compactDuration,
|
statusStore, StatGroup, StatField, compactDuration,
|
||||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, K_BUILD,
|
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||||
} from '../common/StatusStore';
|
} from '../common/StatusStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,8 +19,6 @@ export struct StatusSummaryCard {
|
|||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
@StorageProp(K_UP) private up: boolean = false;
|
@StorageProp(K_UP) private up: boolean = false;
|
||||||
@StorageProp(K_VERSION) private version: string = '-';
|
@StorageProp(K_VERSION) private version: string = '-';
|
||||||
/** 内核名 · commit。与版本号分开一行:版本号本身没有内核身份。 */
|
|
||||||
@StorageProp(K_BUILD) private buildSub: string = '';
|
|
||||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||||
@ -120,15 +117,10 @@ export struct StatusSummaryCard {
|
|||||||
.alignItems(VerticalAlign.Center)
|
.alignItems(VerticalAlign.Center)
|
||||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
.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 }) {
|
Row({ space: 10 }) {
|
||||||
KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
|
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
|
||||||
KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
|
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.margin({ top: 14 })
|
.margin({ top: 14 })
|
||||||
@ -138,24 +130,14 @@ export struct StatusSummaryCard {
|
|||||||
Text('版本')
|
Text('版本')
|
||||||
.fontSize(12)
|
.fontSize(12)
|
||||||
.fontColor(this.palette().textMuted)
|
.fontColor(this.palette().textMuted)
|
||||||
Column() {
|
Text(this.version)
|
||||||
Text(this.version)
|
.fontSize(12)
|
||||||
.fontSize(12)
|
.fontWeight(FontWeight.Medium)
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontColor(this.palette().textSecondary)
|
||||||
.fontColor(this.palette().textSecondary)
|
.layoutWeight(1)
|
||||||
.maxLines(1)
|
.maxLines(1)
|
||||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
// 内核名 + commit:光有版本号会分不清是哪个内核、哪次构建
|
.margin({ left: 10 })
|
||||||
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('明细')
|
Text('明细')
|
||||||
.fontSize(12)
|
.fontSize(12)
|
||||||
.fontColor(this.palette().accent)
|
.fontColor(this.palette().accent)
|
||||||
@ -219,51 +201,6 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
||||||
*
|
*
|
||||||
@ -334,11 +271,6 @@ export struct StatusDetailContent {
|
|||||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||||
|
|
||||||
// 运行态面板(阶段管道 + 中断队列)放在明细页最前:
|
|
||||||
// 明细卡回答「内核有哪些东西、多少」,运行态回答「现在在干什么」,
|
|
||||||
// 后者是进这个页面最先想看的。
|
|
||||||
RuntimePanel()
|
|
||||||
|
|
||||||
ForEach(this.groups, (g: StatGroup) => {
|
ForEach(this.groups, (g: StatGroup) => {
|
||||||
Column() {
|
Column() {
|
||||||
Text(g.title)
|
Text(g.title)
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* 右下角轻提示条(设置页与插件页共用)。
|
|
||||||
*
|
|
||||||
* 两处原本各写一份,只有"描边"这一处不同:设置页无描边,插件页有。
|
|
||||||
* 用 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,14 +26,6 @@ export interface ChatMessage {
|
|||||||
toolCalls?: ToolCallInfo[];
|
toolCalls?: ToolCallInfo[];
|
||||||
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
||||||
source?: string;
|
source?: string;
|
||||||
/**
|
|
||||||
* 服务端单调递增序号(后端 ChatMsg.seq)。
|
|
||||||
*
|
|
||||||
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
|
|
||||||
* 本地乐观消息没有 seq,服务端回显后靠 seq 认领并去重。
|
|
||||||
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
|
|
||||||
*/
|
|
||||||
seq?: number;
|
|
||||||
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
||||||
attachment?: ChatAttachment;
|
attachment?: ChatAttachment;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,25 +4,19 @@ import { apiClient } from '../common/ApiClient';
|
|||||||
import { handleNavOnScroll } from '../common/NavBarController';
|
import { handleNavOnScroll } from '../common/NavBarController';
|
||||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||||
import { DeviceInfo } from '../model/Model';
|
import { DeviceInfo } from '../model/Model';
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH,
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
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 { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||||
import { markSubPageOpen, subPageParam } from '../components/SubPage';
|
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||||
import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST,
|
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||||
SUB_LOCAL, SUB_NONE } from '../common/DeviceModel';
|
|
||||||
|
/** 二级页面标识 */
|
||||||
|
const SUB_NONE: string = '';
|
||||||
|
const SUB_LOCAL: string = 'local';
|
||||||
|
const SUB_CAPS: string = 'caps';
|
||||||
|
const SUB_GATEWAY: string = 'gateway';
|
||||||
|
const SUB_LIST: string = 'list';
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备页:只做"页面壳"。
|
|
||||||
*
|
|
||||||
* 拆分后的分工(拆分前这里是 560 行的单文件):
|
|
||||||
* - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets
|
|
||||||
* - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets
|
|
||||||
* - device_id 兜底与在线设备解析 → common/DeviceModel.ets(路由 id 也在那)
|
|
||||||
* 本文件保留:导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。
|
|
||||||
*/
|
|
||||||
@Component
|
@Component
|
||||||
export struct DevicePage {
|
export struct DevicePage {
|
||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
@ -45,8 +39,18 @@ export struct DevicePage {
|
|||||||
private navStack: NavPathStack = new NavPathStack();
|
private navStack: NavPathStack = new NavPathStack();
|
||||||
|
|
||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
// id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘
|
this.deviceId = deviceBridge.getDeviceId();
|
||||||
this.deviceId = resolveDeviceId(deviceBridge.getDeviceId());
|
if (this.deviceId.length === 0) {
|
||||||
|
this.deviceId = connStore.getDeviceId();
|
||||||
|
}
|
||||||
|
if (this.deviceId.length === 0) {
|
||||||
|
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||||
|
try {
|
||||||
|
connStore.saveDeviceId(this.deviceId);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
this.authorized = connStore.getDeviceAuth();
|
this.authorized = connStore.getDeviceAuth();
|
||||||
// Gateway URL derives from current connection
|
// Gateway URL derives from current connection
|
||||||
const cur = connStore.getCurrentConnection();
|
const cur = connStore.getCurrentConnection();
|
||||||
@ -129,7 +133,31 @@ export struct DevicePage {
|
|||||||
const resp = await apiClient.get('/device/online');
|
const resp = await apiClient.get('/device/online');
|
||||||
if (resp.status >= 200 && resp.status < 300) {
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||||
const list: DeviceInfo[] = parseOnlineDevices(parsed);
|
const devs: Object = parsed['devices'];
|
||||||
|
const list: DeviceInfo[] = [];
|
||||||
|
if (devs !== undefined && devs !== null) {
|
||||||
|
const arr: Object[] = devs as Object[];
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const d: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||||
|
const capsArr: Object = d['caps'];
|
||||||
|
const caps: string[] = [];
|
||||||
|
if (capsArr !== undefined && capsArr !== null) {
|
||||||
|
const cArr: Object[] = capsArr as Object[];
|
||||||
|
for (let j = 0; j < cArr.length; j++) {
|
||||||
|
caps.push(cArr[j] as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const info: DeviceInfo = {
|
||||||
|
deviceId: d['device_id'] as string ?? '',
|
||||||
|
name: d['name'] as string ?? '',
|
||||||
|
kind: d['kind'] as string ?? '',
|
||||||
|
online: true,
|
||||||
|
authorized: d['authorized'] as boolean ?? false,
|
||||||
|
caps: caps,
|
||||||
|
};
|
||||||
|
list.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||||
this.devices = list;
|
this.devices = list;
|
||||||
});
|
});
|
||||||
@ -149,17 +177,7 @@ export struct DevicePage {
|
|||||||
Column() {
|
Column() {
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column() {
|
Column() {
|
||||||
DeviceRootEntries({
|
this.RootEntries()
|
||||||
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%')
|
.width('100%')
|
||||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||||
@ -230,58 +248,61 @@ export struct DevicePage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 二级页面路由表 */
|
||||||
* 二级页面路由表。
|
|
||||||
* 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。
|
|
||||||
*/
|
|
||||||
@Builder
|
@Builder
|
||||||
SubDestination(name: string, param: object) {
|
SubDestination(name: string, param: object) {
|
||||||
NavDestination() {
|
NavDestination() {
|
||||||
if (name === SUB_LOCAL) {
|
if (name === SUB_LOCAL) {
|
||||||
DeviceLocalPane({
|
SubPageLayer({
|
||||||
deviceId: this.deviceId,
|
title: '本机设备',
|
||||||
authorized: this.authorized,
|
tab: 2,
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
onToggleAuth: (on: boolean) => {
|
}) {
|
||||||
this.toggleAuthorized(on);
|
this.LocalDeviceContent()
|
||||||
},
|
}
|
||||||
})
|
|
||||||
} else if (name === SUB_CAPS) {
|
} else if (name === SUB_CAPS) {
|
||||||
DeviceCapsPane({
|
SubPageLayer({
|
||||||
|
title: '设备能力',
|
||||||
|
tab: 2,
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
})
|
}) {
|
||||||
|
this.CapsContent()
|
||||||
|
}
|
||||||
} else if (name === SUB_GATEWAY) {
|
} else if (name === SUB_GATEWAY) {
|
||||||
DeviceGatewayPane({
|
SubPageLayer({
|
||||||
bridgeUrl: this.bridgeUrl,
|
title: '设备通道',
|
||||||
bridgeToken: this.bridgeToken,
|
tab: 2,
|
||||||
bridgeConnected: this.bridgeConnected,
|
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
onRefresh: () => {
|
}) {
|
||||||
this.refreshDevices();
|
this.GatewayContent()
|
||||||
},
|
}
|
||||||
})
|
|
||||||
} else if (name === SUB_LIST) {
|
} else if (name === SUB_LIST) {
|
||||||
DeviceListPane({
|
SubPageLayer({
|
||||||
devices: this.devices,
|
title: '接入的设备',
|
||||||
|
tab: 2,
|
||||||
onBack: () => {
|
onBack: () => {
|
||||||
this.closeSub();
|
this.closeSub();
|
||||||
},
|
},
|
||||||
|
showRefresh: true,
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
this.refreshDevices();
|
this.refreshDevices();
|
||||||
},
|
},
|
||||||
})
|
}) {
|
||||||
|
this.OnlineDevicesContent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hideTitleBar(true)
|
.hideTitleBar(true)
|
||||||
.backgroundColor(Color.Transparent)
|
.backgroundColor(Color.Transparent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
Toast() {
|
Toast() {
|
||||||
if (this.toastMsg.length > 0) {
|
if (this.toastMsg.length > 0) {
|
||||||
@ -302,6 +323,238 @@ export struct DevicePage {
|
|||||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
.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;
|
const RADIUS_MD: number = 10;
|
||||||
|
|||||||
@ -7,15 +7,14 @@ import { apiClient } from '../common/ApiClient';
|
|||||||
import { navBar } from '../common/NavBarController';
|
import { navBar } from '../common/NavBarController';
|
||||||
import { handleBackPress } from '../common/NavStackRegistry';
|
import { handleBackPress } from '../common/NavStackRegistry';
|
||||||
import { ConnectionConfig } from '../model/Model';
|
import { ConnectionConfig } from '../model/Model';
|
||||||
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 { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
|
||||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||||
import { MotionBase } from '../components/MotionBase';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { GradientBackground } from '../components/GradientBackground';
|
import { GradientBackground } from '../components/GradientBackground';
|
||||||
import { ScreensuePage } from '../components/ScreensuePage';
|
import { ScreensuePage } from '../components/ScreensuePage';
|
||||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||||
import { snapshotComponentId } from '../common/BridgeCaps';
|
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||||
import { ScreensuePayload } from '../common/ScreensueHtml';
|
|
||||||
import { window, display } from '@kit.ArkUI';
|
import { window, display } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
|
|
||||||
@ -84,8 +83,6 @@ struct Index {
|
|||||||
/** 底部手势条高度(vp) */
|
/** 底部手势条高度(vp) */
|
||||||
@State bottomGesture: number = 16;
|
@State bottomGesture: number = 16;
|
||||||
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
@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 swiper: SwiperController = new SwiperController();
|
||||||
private screensueTimer: number = -1;
|
private screensueTimer: number = -1;
|
||||||
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
||||||
@ -104,12 +101,6 @@ struct Index {
|
|||||||
if (cur !== null) {
|
if (cur !== null) {
|
||||||
apiClient.setConnection(cur);
|
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 响应读取
|
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
||||||
const st = connStore.getSettings();
|
const st = connStore.getSettings();
|
||||||
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
||||||
@ -200,23 +191,6 @@ struct Index {
|
|||||||
AppStorage.set<number>('currentTab', this.currentTab);
|
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 {
|
private syncSystemBar(): void {
|
||||||
const dark: boolean = this.isDark;
|
const dark: boolean = this.isDark;
|
||||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||||
|
|||||||
@ -1,26 +1,24 @@
|
|||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { userMessage } from '../common/UserError';
|
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||||
import { handleNavOnScroll } from '../common/NavBarController';
|
import { handleNavOnScroll } from '../common/NavBarController';
|
||||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||||
import { ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { PluginDetailPane } from '../components/PluginDetailPane';
|
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||||
import { PluginListView } from '../components/PluginListView';
|
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||||
import { PluginInstallForm } from '../components/PluginsOverlays';
|
import { SettingsEditor } from '../components/SettingsEditor';
|
||||||
import { ToastBar } from '../components/ToastBar';
|
|
||||||
import { fetchPluginRows, fetchPluginDetail } from '../common/PluginApi';
|
|
||||||
|
|
||||||
/** 二级页面标识:插件详情 */
|
|
||||||
const SUB_NONE: string = '';
|
|
||||||
const SUB_DETAIL: string = 'detail';
|
|
||||||
|
|
||||||
interface InstallBody {
|
interface InstallBody {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 二级页面标识:插件详情 */
|
||||||
|
const SUB_NONE: string = '';
|
||||||
|
const SUB_DETAIL: string = 'detail';
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export struct PluginsPage {
|
export struct PluginsPage {
|
||||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
@ -60,14 +58,134 @@ export struct PluginsPage {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取数口径见 common/PluginApi.ets;这里只负责把结果落到 @State。 */
|
/**
|
||||||
|
* 数据源对齐 WebGUI renderPlugins:
|
||||||
|
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
|
||||||
|
* - GET /plugins → 已安装外部插件元数据(version/description 等)
|
||||||
|
* - GET /plugins/disabled → {disabled:[{name,...}]}
|
||||||
|
* 三方按名称合并去重排序。
|
||||||
|
*/
|
||||||
private async loadPlugins(): Promise<void> {
|
private async loadPlugins(): Promise<void> {
|
||||||
if (!apiClient.hasConnection()) {
|
if (!apiClient.hasConnection()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const rows: PluginRow[] = await fetchPluginRows();
|
// ---- kernel: loaded plugins + tool ownership ----
|
||||||
|
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
|
||||||
|
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||||
|
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
|
||||||
|
const kpRaw: Object | undefined = kernelObj['plugins'];
|
||||||
|
if (kpRaw !== undefined && kpRaw !== null) {
|
||||||
|
const kpArr: Object[] = kpRaw as Object[];
|
||||||
|
for (let i = 0; i < kpArr.length; i++) {
|
||||||
|
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
|
||||||
|
const n: string = item['name'] as string ?? '';
|
||||||
|
if (n.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
loadedMap.set(n, item['loaded'] as boolean ?? true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
|
||||||
|
const tRaw: Object | undefined = kernelObj['tools'];
|
||||||
|
if (tRaw !== undefined && tRaw !== null) {
|
||||||
|
const tArr: Object[] = tRaw as Object[];
|
||||||
|
for (let i = 0; i < tArr.length; i++) {
|
||||||
|
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
|
||||||
|
const tn: string = item['name'] as string ?? '';
|
||||||
|
const owner: string = item['plugin'] as string ?? '';
|
||||||
|
if (tn.length === 0 || owner.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let list: string[] | undefined = toolsByPlugin.get(owner);
|
||||||
|
if (list === undefined) {
|
||||||
|
list = [];
|
||||||
|
toolsByPlugin.set(owner, list);
|
||||||
|
}
|
||||||
|
// 每插件最多展示 8 个工具名,避免卡片过长
|
||||||
|
if (list.length < 8) {
|
||||||
|
list.push(tn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- installed external plugins metadata ----
|
||||||
|
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
|
||||||
|
try {
|
||||||
|
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
|
||||||
|
const bodyTrim = pResp.body.trim();
|
||||||
|
let arr: Object[] = [];
|
||||||
|
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
|
||||||
|
arr = JSON.parse(pResp.body) as Object[];
|
||||||
|
} else {
|
||||||
|
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
|
||||||
|
const rawList: Object = obj['plugins'] ?? obj['data'];
|
||||||
|
if (rawList !== undefined && rawList !== null) {
|
||||||
|
arr = rawList as Object[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||||
|
const n: string = item['name'] as string ?? '';
|
||||||
|
if (n.length > 0) {
|
||||||
|
externalMeta.set(n, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 外部列表失败不阻塞内置展示
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- disabled list ----
|
||||||
|
const disabledNames: Set<string> = new Set<string>();
|
||||||
|
try {
|
||||||
|
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
|
||||||
|
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
|
||||||
|
const dArr: Object | undefined = dObj['disabled'];
|
||||||
|
if (dArr !== undefined && dArr !== null) {
|
||||||
|
const items: Object[] = dArr as Object[];
|
||||||
|
for (let di = 0; di < items.length; di++) {
|
||||||
|
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
|
||||||
|
const dn: string = dItem['name'] as string ?? '';
|
||||||
|
if (dn.length > 0) {
|
||||||
|
disabledNames.add(dn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// disabled endpoint may not exist; ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- merge: allNames sorted(与 GUI 一致)----
|
||||||
|
const allNames: Set<string> = new Set<string>();
|
||||||
|
loadedMap.forEach((v: boolean, k: string) => {
|
||||||
|
allNames.add(k);
|
||||||
|
});
|
||||||
|
externalMeta.forEach((v: Record<string, Object>, k: string) => {
|
||||||
|
allNames.add(k);
|
||||||
|
});
|
||||||
|
disabledNames.forEach((n: string) => {
|
||||||
|
allNames.add(n);
|
||||||
|
});
|
||||||
|
const names: string[] = Array.from(allNames);
|
||||||
|
names.sort();
|
||||||
|
|
||||||
|
const rows: PluginRow[] = [];
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
const name: string = names[i];
|
||||||
|
const meta: Record<string, Object> | undefined = externalMeta.get(name);
|
||||||
|
const tools: string[] | undefined = toolsByPlugin.get(name);
|
||||||
|
const row: PluginRow = {
|
||||||
|
name: name,
|
||||||
|
loaded: loadedMap.get(name) ?? false,
|
||||||
|
disabled: disabledNames.has(name),
|
||||||
|
external: externalMeta.has(name),
|
||||||
|
version: meta !== undefined ? meta['version'] as string ?? '' : '',
|
||||||
|
description: meta !== undefined ? meta['description'] as string ?? '' : '',
|
||||||
|
tools: tools,
|
||||||
|
};
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
|
||||||
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
|
||||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||||
@ -81,6 +199,20 @@ 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> {
|
private async togglePlugin(plugin: PluginRow): Promise<void> {
|
||||||
const name: string = plugin.name;
|
const name: string = plugin.name;
|
||||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||||
@ -148,14 +280,29 @@ export struct PluginsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 详情数据落地。
|
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||||
|
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||||
*/
|
*/
|
||||||
private async loadDetail(name: string): Promise<void> {
|
private async loadDetail(name: string): Promise<void> {
|
||||||
this.detailLoading = true;
|
this.detailLoading = true;
|
||||||
this.detailError = '';
|
this.detailError = '';
|
||||||
try {
|
try {
|
||||||
const d: PluginDetail = await fetchPluginDetail(name);
|
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
|
||||||
|
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||||
|
const d: PluginDetail = emptyPluginDetail();
|
||||||
|
d.name = o['name'] as string ?? name;
|
||||||
|
d.version = o['version'] as string ?? '';
|
||||||
|
d.description = o['description'] as string ?? '';
|
||||||
|
d.author = o['author'] as string ?? '';
|
||||||
|
d.license = o['license'] as string ?? '';
|
||||||
|
d.homepage = o['homepage'] as string ?? '';
|
||||||
|
d.repository = o['repository'] as string ?? '';
|
||||||
|
d.entry = o['entry'] as string ?? '';
|
||||||
|
d.minVersion = o['min_version'] as string ?? '';
|
||||||
|
d.deprecated = o['deprecated'] as boolean ?? false;
|
||||||
|
d.tags = this.strArray(o['tags']);
|
||||||
|
d.files = this.strArray(o['files']);
|
||||||
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
|
||||||
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||||||
this.detail = d;
|
this.detail = d;
|
||||||
@ -180,6 +327,21 @@ 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 {
|
private findRow(name: string): PluginRow | null {
|
||||||
for (let i = 0; i < this.plugins.length; i++) {
|
for (let i = 0; i < this.plugins.length; i++) {
|
||||||
if (this.plugins[i].name === name) {
|
if (this.plugins[i].name === name) {
|
||||||
@ -225,15 +387,8 @@ export struct PluginsPage {
|
|||||||
Column() {
|
Column() {
|
||||||
Scroll() {
|
Scroll() {
|
||||||
Column() {
|
Column() {
|
||||||
PluginListView({
|
this.ListStates()
|
||||||
plugins: this.plugins,
|
this.PluginList()
|
||||||
busy: this.loading,
|
|
||||||
activeName: this.activeName,
|
|
||||||
hasConn: apiClient.hasConnection(),
|
|
||||||
onSelect: (name: string) => {
|
|
||||||
this.openDetail(name);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||||
@ -256,15 +411,7 @@ export struct PluginsPage {
|
|||||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||||
NavFloatOverlay({ tab: 1 }) {
|
NavFloatOverlay({ tab: 1 }) {
|
||||||
if (this.showInstallForm) {
|
if (this.showInstallForm) {
|
||||||
PluginInstallForm({
|
this.InstallForm()
|
||||||
url: this.installUrl,
|
|
||||||
onUrlChange: (v: string) => {
|
|
||||||
this.installUrl = v;
|
|
||||||
},
|
|
||||||
onInstall: () => {
|
|
||||||
this.installPlugin();
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
NavFloatRow() {
|
NavFloatRow() {
|
||||||
FloatIconButton({
|
FloatIconButton({
|
||||||
@ -294,7 +441,7 @@ export struct PluginsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToastBar({ msg: this.toastMsg, isError: this.toastIsError, bordered: true })
|
this.Toast()
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@ -346,29 +493,503 @@ export struct PluginsPage {
|
|||||||
this.loadDetail(this.activeName);
|
this.loadDetail(this.activeName);
|
||||||
},
|
},
|
||||||
}) {
|
}) {
|
||||||
PluginDetailPane({
|
this.DetailContent()
|
||||||
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)
|
.hideTitleBar(true)
|
||||||
.backgroundColor(Color.Transparent)
|
.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,56 +9,30 @@ HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebV
|
|||||||
```
|
```
|
||||||
HomeAgent/
|
HomeAgent/
|
||||||
├── AppScope/ 应用级配置与图标
|
├── AppScope/ 应用级配置与图标
|
||||||
├── oh_modules/ 依赖(.gitignore 忽略,但**必须存在**,见下节)
|
|
||||||
├── entry/src/main/
|
├── entry/src/main/
|
||||||
│ ├── ets/
|
│ ├── ets/
|
||||||
│ │ ├── common/ 通信、状态与纯逻辑(无 UI)
|
│ │ ├── common/ 通信与全局状态
|
||||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||||
│ │ │ ├── ConnStore.ets 连接配置与设备身份持久化
|
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
|
||||||
│ │ │ ├── StatusStore.ets 运行状态缓存(单例 + AppStorage 广播)
|
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||||
│ │ │ ├── UserError.ets 错误转人类可读文案
|
│ │ │ ├── ConnStore.ets 连接配置持久化
|
||||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航栏显隐与导航栈登记
|
│ │ │ ├── StatusStore.ets 运行状态缓存
|
||||||
│ │ │ ├── ChatStore.ets 聊天状态机(消息数组/分页/SSE/防抖刷新,单例)
|
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
|
||||||
│ │ │ ├── ChatSse.ets SSE 事件 → 状态翻译(ChatStreamSink 接口)
|
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||||
│ │ │ ├── ChatSession.ets 发送/中断(POST /chat、/chat/file)
|
│ │ │ └── UserError.ets 错误转人类可读文案
|
||||||
│ │ │ ├── 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/ 可复用组件
|
│ │ ├── components/ 可复用组件
|
||||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||||
│ │ │ ├── Attachment.ets 附件卡 + 附件详情内容
|
│ │ │ ├── Attachment.ets 附件卡片 + 详情
|
||||||
│ │ │ ├── ChatStream.ets 消息列表 + 顶栏遮罩 + 底部淡出 + 触顶懒加载
|
│ │ │ ├── StatusCards.ets 状态卡片
|
||||||
│ │ │ ├── ChatBubble.ets 单条气泡(头像/渠道名/思考卡/工具卡/附件/正文)
|
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||||
│ │ │ ├── ChatToolCard.ets 思考过程卡 + 工具调用卡
|
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||||
│ │ │ ├── ChatComposer.ets 悬浮输入区(选图/选文件/上传/发送)
|
│ │ │ ├── SubPage.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
|
│ │ │ └── GradientBackground.ets
|
||||||
│ │ ├── model/Model.ets 共享类型定义
|
│ │ ├── model/Model.ets 共享类型定义
|
||||||
│ │ ├── pages/ 页面(薄壳:导航 + 数据编排)
|
│ │ ├── pages/ 页面
|
||||||
│ │ │ ├── Index.ets Tab 容器(入口)
|
│ │ │ ├── Index.ets Tab 容器(入口)
|
||||||
│ │ │ ├── ChatPage.ets 对话
|
│ │ │ ├── ChatPage.ets 对话
|
||||||
│ │ │ ├── DevicePage.ets 设备
|
│ │ │ ├── DevicePage.ets 设备
|
||||||
@ -71,30 +45,11 @@ HomeAgent/
|
|||||||
└── oh-package.json5 依赖
|
└── 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/)。
|
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
|
||||||
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`。
|
本工程用 `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` 含密码明文,未入库):
|
1. **准备签名配置**(`build-profile.json5` 含密码明文,未入库):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -109,27 +64,19 @@ HomeAgent/
|
|||||||
2. **构建 HAP**:
|
2. **构建 HAP**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cmd/ohos/HomeAgent
|
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
|
||||||
# ⚠️ 不要用仓库里的 ./hvigorw:它是符号链接,启动脚本按 $(dirname $0) 定位,
|
/path/to/command-line-tools/bin/hvigorw \
|
||||||
# 会报 File not found: <repo>/cmd/ohos/hvigor/bin/hvigorw。
|
--mode module -p module=entry@default assembleHap --no-daemon
|
||||||
# 一律用 command-line-tools 里的绝对路径(本机为 /opt/huawei/command-line-tools/bin/hvigorw):
|
|
||||||
/opt/huawei/command-line-tools/bin/hvigorw \
|
|
||||||
assembleHap --mode module -p product=default --no-daemon
|
|
||||||
```
|
```
|
||||||
|
|
||||||
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
||||||
|
|
||||||
3. **安装到设备**(`entry/build/` 是纯构建产物、不入库,所以**必须先跑完第 2 步**,
|
3. **安装到设备**:
|
||||||
否则下面这个路径不存在):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hdc install entry/build/default/outputs/default/entry-default-signed.hap
|
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
|
## 连接 homed
|
||||||
|
|
||||||
首次启动在「设置」里填:
|
首次启动在「设置」里填:
|
||||||
@ -150,19 +97,3 @@ HomeAgent/
|
|||||||
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
||||||
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
||||||
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
- 项目代码部分由 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 use <name> switch to saved connection
|
||||||
/conn del <name> delete saved connection
|
/conn del <name> delete saved connection
|
||||||
|
|
||||||
Server commands (local 与 remote 行为一致):
|
Server commands (sent to agent):
|
||||||
/status system status
|
/status system status
|
||||||
/kernel kernel status
|
/kernel kernel status
|
||||||
/settings [prefix] list settings
|
/settings [prefix] list settings
|
||||||
@ -32,27 +32,10 @@ Server commands (local 与 remote 行为一致):
|
|||||||
/plugin list list installed plugins
|
/plugin list list installed plugins
|
||||||
/plugin install <url> install plugin
|
/plugin install <url> install plugin
|
||||||
/plugin remove <name> remove plugin
|
/plugin remove <name> remove plugin
|
||||||
/plugin disable <name> disable plugin
|
|
||||||
/plugin enable <name> enable plugin
|
|
||||||
/plugin info <name> plugin details
|
/plugin info <name> plugin details
|
||||||
/memory query <text> query graph memory
|
/memory query <text> query graph memory
|
||||||
/memory graph dump full graph memory snapshot
|
/knowledge list knowledge base
|
||||||
/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
|
/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
|
/agents list agents
|
||||||
/chat <text> send to agent
|
/chat <text> send to agent
|
||||||
|
|
||||||
@ -71,26 +54,6 @@ Any other text is sent to the agent directly.`)
|
|||||||
reconnect()
|
reconnect()
|
||||||
return true
|
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 "):
|
case strings.HasPrefix(cmd, "/connect "):
|
||||||
cfg.Socket = strings.TrimSpace(cmd[9:])
|
cfg.Socket = strings.TrimSpace(cmd[9:])
|
||||||
cfg.Remote = ""
|
cfg.Remote = ""
|
||||||
@ -180,7 +143,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
rc.DoAPI("PUT", "/api/v1/settings", body)
|
rc.DoAPI("PUT", "/api/v1/settings", body)
|
||||||
fmt.Fprintln(out, "ok")
|
fmt.Fprintln(out, "ok")
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -189,7 +152,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -209,7 +172,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -219,7 +182,7 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -229,56 +192,17 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
// disable/enable:本地由 CLI 插件处理,远端走插件管理 REST 动作接口。
|
case strings.HasPrefix(cmd, "/memory query "):
|
||||||
case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "):
|
q := strings.TrimSpace(cmd[14:])
|
||||||
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 {
|
if rc := state.RemoteConn(); rc != nil {
|
||||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "")
|
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
|
||||||
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
|
return true
|
||||||
|
|
||||||
@ -288,126 +212,16 @@ Any other text is sent to the agent directly.`)
|
|||||||
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send(cmd[1:])
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case cmd == "/knowledge" || cmd == "/knowledge list" || cmd == "/knowledge stats":
|
case cmd == "/knowledge":
|
||||||
if rc := state.RemoteConn(); rc != nil {
|
if rc := state.RemoteConn(); rc != nil {
|
||||||
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
||||||
printJSON(out, d)
|
printJSON(out, d)
|
||||||
} else {
|
} else {
|
||||||
state.Send(cmd)
|
state.Send("/knowledge")
|
||||||
}
|
|
||||||
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
|
return true
|
||||||
|
|
||||||
@ -425,16 +239,6 @@ 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{}) {
|
func printJSON(out io.Writer, d map[string]interface{}) {
|
||||||
if d == nil {
|
if d == nil {
|
||||||
fmt.Fprintln(out, "(no data)")
|
fmt.Fprintln(out, "(no data)")
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ===== 设备桥管理 =====
|
// ===== 设备桥管理 =====
|
||||||
@ -47,13 +46,17 @@ func startDeviceBridge(addr, token string) error {
|
|||||||
"platform": runtime.GOOS,
|
"platform": runtime.GOOS,
|
||||||
"arch": runtime.GOARCH,
|
"arch": runtime.GOARCH,
|
||||||
"cpus": runtime.NumCPU(),
|
"cpus": runtime.NumCPU(),
|
||||||
// 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本
|
|
||||||
// 因此与 homed 一致,不再是一个空缺字段。
|
|
||||||
"version": meta.Version,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网关地址规范化(含「已是完整端点」「只有 host:port」两种旧输入形态)。
|
// 确保 gateway URL 格式正确
|
||||||
gateway := normalizeGateway(addr)
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||||
cmdRouter = client.NewCmdRouter()
|
cmdRouter = client.NewCmdRouter()
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
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("服务端未给出可用的网关地址")
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
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,8 +11,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -131,15 +129,8 @@ func main() {
|
|||||||
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
|
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
|
||||||
testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue")
|
testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue")
|
||||||
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
|
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
|
||||||
showVersion := flag.Bool("version", false, "打印版本并退出")
|
|
||||||
flag.Parse()
|
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 != "" {
|
if *testCap != "" {
|
||||||
runCapTest(*testCap, *testCapArgs)
|
runCapTest(*testCap, *testCapArgs)
|
||||||
@ -181,17 +172,6 @@ func main() {
|
|||||||
if dt == "" {
|
if dt == "" {
|
||||||
dt = cfg.DeviceToken
|
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 dg != "" && dt != "" {
|
||||||
if err := startDeviceBridge(dg, dt); err != nil {
|
if err := startDeviceBridge(dg, dt); err != nil {
|
||||||
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
|
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
|
||||||
@ -205,18 +185,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if oneShotMsg != "" {
|
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)
|
oneshot(state, oneShotMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -302,9 +270,9 @@ func runLineMode(state *State, cfg *Config, history *History) {
|
|||||||
addrLabel = cfg.Remote
|
addrLabel = cfg.Remote
|
||||||
}
|
}
|
||||||
if colors {
|
if colors {
|
||||||
fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset)
|
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel)
|
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
|
||||||
}
|
}
|
||||||
fmt.Println("Type /help for commands.")
|
fmt.Println("Type /help for commands.")
|
||||||
|
|
||||||
@ -511,26 +479,3 @@ func printServerEventColored(rl respLine, raw string) {
|
|||||||
fmt.Printf("%s%s%s\n", clearLine, text, colorReset)
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
#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 */
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
#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 */
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
#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 */
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
#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 */
|
|
||||||
@ -1,345 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@ -1,825 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
@ -1,673 +0,0 @@
|
|||||||
/*
|
|
||||||
* ha_sse.c — LLM 流式协议(SSE 分块)结构导航辅助层
|
|
||||||
*
|
|
||||||
* 语义与理由见 include/ha_sse.h。本文件被 C 门禁全量覆盖
|
|
||||||
* (告警 / ASan+UBSan / arm64 交叉编译 / libFuzzer),故**不放**在
|
|
||||||
* Go 的 cgo 前言里 —— 前言里的 C 代码逃出全部检查。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ha_sse.h"
|
|
||||||
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
int ha_sse_abi_version(void) {
|
|
||||||
return HA_SSE_ABI_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_obj_find(const ha_span *obj, const char *key, size_t keylen,
|
|
||||||
ha_span *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
int hit = 0;
|
|
||||||
|
|
||||||
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*dup = 0;
|
|
||||||
out->p = NULL;
|
|
||||||
out->len = 0;
|
|
||||||
if (keylen == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!ha_json_members_init(&m, obj->p, obj->len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
/* ★ 精确比较(不做大小写折叠):与 Go 的 map key 语义一致。
|
|
||||||
* §5.4-1 实测 {"TEXT":"up"} 取不到 text。 */
|
|
||||||
if (k.len == keylen && memcmp(k.p, key, keylen) == 0) {
|
|
||||||
if (hit) {
|
|
||||||
*dup = 1; /* 重复键:调用方整体回退 Go */
|
|
||||||
}
|
|
||||||
hit = 1;
|
|
||||||
*out = v; /* 后者胜 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ha_json_members_complete(&m)) {
|
|
||||||
return -1; /* 对象畸形 */
|
|
||||||
}
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单字节 ASCII 小写折叠(非 ASCII 原样,与 Go 对 ASCII 字段名的行为一致)。 */
|
|
||||||
static unsigned char sse_lower(unsigned char c) {
|
|
||||||
return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_obj_find_ci(const ha_span *obj, const char *key, size_t keylen,
|
|
||||||
ha_span *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
int hit = 0;
|
|
||||||
|
|
||||||
if (obj == NULL || key == NULL || out == NULL || dup == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*dup = 0;
|
|
||||||
out->p = NULL;
|
|
||||||
out->len = 0;
|
|
||||||
if (keylen == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!ha_json_members_init(&m, obj->p, obj->len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
if (k.len == keylen) {
|
|
||||||
size_t j = 0;
|
|
||||||
while (j < keylen &&
|
|
||||||
sse_lower((unsigned char)k.p[j]) ==
|
|
||||||
sse_lower((unsigned char)key[j])) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
if (j == keylen) {
|
|
||||||
if (hit) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
hit = 1;
|
|
||||||
*out = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ha_json_members_complete(&m)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_root_object(const ha_span *doc) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
|
|
||||||
if (doc == NULL || doc->p == NULL || doc->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_json_scan_init(&sc, doc->p, doc->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '{') {
|
|
||||||
return 0; /* 顶层非对象:Go 的 Unmarshal 进 struct 会失败 */
|
|
||||||
}
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* 尾部只允许空白 —— 复刻 json.Unmarshal 对 trailing garbage 的拒绝 */
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
return ha_json_scan_eof(&sc) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_arr_first(const ha_span *arr, ha_span *out) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
size_t start;
|
|
||||||
|
|
||||||
if (arr == NULL || out == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
out->p = NULL;
|
|
||||||
out->len = 0;
|
|
||||||
if (arr->p == NULL || arr->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* ★ 游标的 base 始终是 arr->p,中途只推进 i。
|
|
||||||
*
|
|
||||||
* 初版在这里犯过一个「重新 init 到 sc.s + sc.i」的错:那样 base 变了,
|
|
||||||
* 随后的 start = sc.i 变成 0,out->p = arr->p + 0 ⇒ **返回的是数组本身**
|
|
||||||
* 而不是第一个元素。症状是上层的 fastChoice 拿到 firstByte=='[' 直接回退,
|
|
||||||
* 表现为「快速路径永远不生效」——
|
|
||||||
* 而如果只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是正确)。
|
|
||||||
*
|
|
||||||
* ★ 这正是「优化是否真的生效」必须单独断言的原因:
|
|
||||||
* 等价性测试无法发现「一直回退」。
|
|
||||||
*/
|
|
||||||
ha_json_scan_init(&sc, arr->p, arr->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
sc.i++; /* 跳过 '[' */
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
|
||||||
return 0; /* 空数组 */
|
|
||||||
}
|
|
||||||
start = sc.i;
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
out->p = arr->p + start;
|
|
||||||
out->len = sc.i - start;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_stringify(const ha_span *val, char *out, size_t cap, size_t *outlen) {
|
|
||||||
size_t len = 0;
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_span raw;
|
|
||||||
|
|
||||||
if (val == NULL || out == NULL || outlen == NULL ||
|
|
||||||
val->p == NULL || val->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = 0;
|
|
||||||
/* 上界:每个输入字节最坏变 3 字节 U+FFFD。不足则交回 Go 走
|
|
||||||
* json.Unmarshal(宁可慢也不截断)。 */
|
|
||||||
if (cap < val->len * 3u + 4u) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val->p[0] == '"') {
|
|
||||||
ha_json_scan_init(&sc, val->p, val->len);
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
size_t n = ha_json_decode_string_into(raw, out, cap);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = n;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (val->p[0] == '[') {
|
|
||||||
ha_json_scan_init(&sc, val->p, val->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
sc.i++; /* 跳过 '[' */
|
|
||||||
for (;;) {
|
|
||||||
size_t start;
|
|
||||||
ha_span elem;
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
start = sc.i;
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
elem.p = val->p + start;
|
|
||||||
elem.len = sc.i - start;
|
|
||||||
|
|
||||||
/* 只有对象元素才可能有 text(§5.4-2:其余静默跳过) */
|
|
||||||
if (elem.len > 0 && elem.p[0] == '{') {
|
|
||||||
ha_span txt;
|
|
||||||
int dup = 0;
|
|
||||||
int rc = ha_sse_obj_find(&elem, "text", 4, &txt, &dup);
|
|
||||||
if (dup) {
|
|
||||||
return 0; /* 重复 text 键 ⇒ 交回 Go(合并语义) */
|
|
||||||
}
|
|
||||||
/* 只有字符串形态的 text 才取(§5.4-3) */
|
|
||||||
if (rc == 1 && txt.len > 0 && txt.p[0] == '"') {
|
|
||||||
ha_json_scan ts;
|
|
||||||
ha_span traw;
|
|
||||||
size_t n;
|
|
||||||
ha_json_scan_init(&ts, txt.p, txt.len);
|
|
||||||
if (!ha_json_scan_string(&ts, &traw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* cap-len 已保证至少 1 字节可用(含结尾 NUL) */
|
|
||||||
n = ha_json_decode_string_into(traw, out + len, cap - len);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
len += n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ',') {
|
|
||||||
sc.i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0; /* 畸形数组 */
|
|
||||||
}
|
|
||||||
*outlen = len;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 对象 / 数字 / true / false / null ⇒ 需 json.Marshal 重新编码(§5.2) */
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_arg_string(const ha_span *val, char *out, size_t cap, size_t *outlen) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_span raw;
|
|
||||||
size_t n;
|
|
||||||
|
|
||||||
if (val == NULL || out == NULL || outlen == NULL ||
|
|
||||||
val->p == NULL || val->len == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = 0;
|
|
||||||
if (val->p[0] != '"') {
|
|
||||||
return 0; /* 非字符串:交回 Go(需 json.Marshal 重新编码) */
|
|
||||||
}
|
|
||||||
if (cap < val->len * 3u + 4u) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ha_json_scan_init(&sc, val->p, val->len);
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
n = ha_json_decode_string_into(raw, out, cap);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*outlen = n;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================================================================== */
|
|
||||||
/* 批量定位:一次调用返回全部字段 */
|
|
||||||
/* ==================================================================== */
|
|
||||||
|
|
||||||
static int scan_first_and_count(const ha_span *arr, ha_span *first, int *count);
|
|
||||||
static int chunk_choice_dispatch(ha_span choice, ha_chunk_out *out);
|
|
||||||
static int chunk_delta_dispatch(ha_span delta, ha_chunk_out *out, int *dup);
|
|
||||||
|
|
||||||
static void slot_reset(ha_chunk_slot *s) {
|
|
||||||
s->span.p = NULL;
|
|
||||||
s->span.len = 0;
|
|
||||||
s->kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void chunk_out_reset(ha_chunk_out *o) {
|
|
||||||
size_t i;
|
|
||||||
for (i = 0; i < (size_t)HA_CHUNK_SLOT_COUNT; i++) {
|
|
||||||
slot_reset(&o->slot[i]);
|
|
||||||
}
|
|
||||||
o->content_off = 0; o->content_len = 0;
|
|
||||||
o->reasoning_off = 0; o->reasoning_len = 0;
|
|
||||||
o->finish_off = 0; o->finish_len = 0;
|
|
||||||
o->choices_span.p = NULL;
|
|
||||||
o->choices_span.len = 0;
|
|
||||||
o->has_choices = 0;
|
|
||||||
o->choices_kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
o->choices_count = 0;
|
|
||||||
o->choice0_kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 键名比较:大小写不敏感(struct 字段语义)。
|
|
||||||
* ★ 为什么不直接用 ha_json_key_eq:那个接收 ha_span,而这里要按
|
|
||||||
* 已知长度比较(省掉 strlen)—— 且必须与 Go 对 struct 字段的匹配一致。 */
|
|
||||||
static int ci_eq(const char *p, const char *name, size_t n) {
|
|
||||||
size_t i;
|
|
||||||
for (i = 0; i < n; i++) {
|
|
||||||
if (sse_lower((unsigned char)p[i]) != sse_lower((unsigned char)name[i])) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int kind_of(const ha_span *v) {
|
|
||||||
if (v == NULL || v->p == NULL || v->len == 0) {
|
|
||||||
return HA_CHUNK_KIND_ABSENT;
|
|
||||||
}
|
|
||||||
switch (v->p[0]) {
|
|
||||||
case '"': return HA_CHUNK_KIND_STRING;
|
|
||||||
case '{': return HA_CHUNK_KIND_OBJECT;
|
|
||||||
case '[': return HA_CHUNK_KIND_ARRAY;
|
|
||||||
case 'n': return HA_CHUNK_KIND_NULL;
|
|
||||||
default: return HA_CHUNK_KIND_OTHER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 把字符串值解码进 sbuf 的 [off,off+len)。返回 0 失败(空间不足/语法错)。 */
|
|
||||||
static int emit_decoded(ha_span val, char *sbuf, size_t scap, size_t *off,
|
|
||||||
size_t *outlen) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_span raw;
|
|
||||||
size_t n;
|
|
||||||
|
|
||||||
*off = 0;
|
|
||||||
*outlen = 0;
|
|
||||||
ha_json_scan_init(&sc, val.p, val.len);
|
|
||||||
if (!ha_json_scan_string(&sc, &raw)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
/* 上界检查:每个输入字节最坏变 3 字节 U+FFFD */
|
|
||||||
if (scap < raw.len * 3u + 4u) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
n = ha_json_decode_string_into(raw, sbuf, scap);
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*off = 0;
|
|
||||||
*outlen = n;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 顶层单趟分派:遍历成员表一次,按名字分派到对应槽位。
|
|
||||||
* 顶层键(choices/usage)是 **struct 字段** ⇒ 大小写不敏感。
|
|
||||||
* 返回 0 = 正常(即使有重复键,dup 由调用方检查);-1 = 畸形。
|
|
||||||
*/
|
|
||||||
static int chunk_top_dispatch(ha_span root, ha_chunk_out *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
ha_span el_tmp;
|
|
||||||
int r;
|
|
||||||
|
|
||||||
*dup = 0;
|
|
||||||
if (!ha_json_members_init(&m, root.p, root.len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
int t = kind_of(&v);
|
|
||||||
if (k.len == 7 && ci_eq(k.p, "choices", 7)) {
|
|
||||||
if (out->choices_kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1; /* 重复键:字段级合并语义 ⇒ 交回 Go */
|
|
||||||
}
|
|
||||||
out->has_choices = (t != HA_CHUNK_KIND_ABSENT &&
|
|
||||||
t != HA_CHUNK_KIND_NULL) ? 1 : 0;
|
|
||||||
out->choices_kind = t;
|
|
||||||
out->choices_span = v;
|
|
||||||
if (t == HA_CHUNK_KIND_ARRAY) {
|
|
||||||
/* 一趟同时得出「首元素 span」与「元素个数」。
|
|
||||||
* ★ 初版为了拿个数先把整个数组扫一遍、再调 ha_sse_arr_first
|
|
||||||
* 重新扫第二遍 —— 而单趟成员遍历实测 107ns,两趟就是白扔 100ns+。
|
|
||||||
*/
|
|
||||||
if (scan_first_and_count(&v, &el_tmp, &out->choices_count) != 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (out->choices_count > 0) {
|
|
||||||
out->choice0_span = el_tmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (k.len == 5 && ci_eq(k.p, "usage", 5)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_USAGE].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_USAGE].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_USAGE].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
/* 其余顶层键(id/object/created/model/system_fingerprint…)一律忽略。
|
|
||||||
* ★ Go 侧 struct 未声明 ⇒ 忽略;没有「类型不符」的可能。 */
|
|
||||||
}
|
|
||||||
r = ha_json_members_complete(&m) ? 0 : -1;
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 一趟取数组的首元素 span 与元素个数。
|
|
||||||
* 返回 0 成功;非 0 表示数组畸形。
|
|
||||||
* ★ 超过 2 个元素即停止计数并置 *count = 2(调用方一律回退),
|
|
||||||
* 这样超大数组不会白扫 —— 而 Go 侧那种输入压根不该走快速路径。 */
|
|
||||||
static int scan_first_and_count(const ha_span *arr, ha_span *first, int *count) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
int n = 0;
|
|
||||||
first->p = NULL;
|
|
||||||
first->len = 0;
|
|
||||||
ha_json_scan_init(&sc, arr->p, arr->len);
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] != '[') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
sc.i++;
|
|
||||||
for (;;) {
|
|
||||||
size_t start;
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc) || sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
start = sc.i;
|
|
||||||
if (!ha_json_skip(&sc)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (n == 0) {
|
|
||||||
first->p = arr->p + start;
|
|
||||||
first->len = sc.i - start;
|
|
||||||
}
|
|
||||||
n++;
|
|
||||||
if (n >= 2) {
|
|
||||||
/* 已知 >1:调用方必然回退,无需继续扫 */
|
|
||||||
*count = 2;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
(void)ha_json_scan_ws(&sc);
|
|
||||||
if (ha_json_scan_eof(&sc)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ',') {
|
|
||||||
sc.i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (sc.s[sc.i] == ']') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
*count = n;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* choice0 内的单趟分派:delta + finish_reason(struct 字段 ⇒ CI)。
|
|
||||||
* 返回 0 正常;非 0 = 畸形或重复键。 */
|
|
||||||
static int chunk_choice_dispatch(ha_span choice, ha_chunk_out *out) {
|
|
||||||
ha_json_members cm;
|
|
||||||
ha_span ck, cv;
|
|
||||||
|
|
||||||
if (!ha_json_members_init(&cm, choice.p, choice.len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&cm, &ck, &cv)) {
|
|
||||||
int t = kind_of(&cv);
|
|
||||||
if (ck.len == 5 && ci_eq(ck.p, "delta", 5)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_DELTA].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
return -1; /* 重复键 */
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_DELTA].span = cv;
|
|
||||||
out->slot[HA_CHUNK_SLOT_DELTA].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ck.len == 13 && ci_eq(ck.p, "finish_reason", 13)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_FINISH_REASON].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_FINISH_REASON].span = cv;
|
|
||||||
out->slot[HA_CHUNK_SLOT_FINISH_REASON].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ha_json_members_complete(&cm) ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* delta 内单趟分派。delta 是 **struct** ⇒ 字段名大小写不敏感。 */
|
|
||||||
static int chunk_delta_dispatch(ha_span delta, ha_chunk_out *out, int *dup) {
|
|
||||||
ha_json_members m;
|
|
||||||
ha_span k, v;
|
|
||||||
|
|
||||||
*dup = 0;
|
|
||||||
if (!ha_json_members_init(&m, delta.p, delta.len)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
int t = kind_of(&v);
|
|
||||||
if (k.len == 7 && ci_eq(k.p, "content", 7)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_CONTENT].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_CONTENT].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_CONTENT].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (k.len == 17 && ci_eq(k.p, "reasoning_content", 17)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_REASONING].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_REASONING].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_REASONING].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (k.len == 10 && ci_eq(k.p, "tool_calls", 10)) {
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_TOOL_CALLS].kind != HA_CHUNK_KIND_ABSENT) {
|
|
||||||
*dup = 1;
|
|
||||||
}
|
|
||||||
out->slot[HA_CHUNK_SLOT_TOOL_CALLS].span = v;
|
|
||||||
out->slot[HA_CHUNK_SLOT_TOOL_CALLS].kind = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ha_json_members_complete(&m) ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_sse_chunk_locate(const char *data, size_t len, ha_chunk_out *out,
|
|
||||||
char *sbuf, size_t scap, size_t *sused) {
|
|
||||||
ha_span root;
|
|
||||||
int dup = 0;
|
|
||||||
ha_span el, v;
|
|
||||||
|
|
||||||
if (out == NULL || sused == NULL) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
chunk_out_reset(out);
|
|
||||||
*sused = 0;
|
|
||||||
if (data == NULL || len == 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
root.p = data;
|
|
||||||
root.len = len;
|
|
||||||
|
|
||||||
/* 顶层必须是「恰好一个」良构对象(含尾部残留检查) */
|
|
||||||
if (!ha_sse_root_object(&root)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk_top_dispatch(root, out, &dup) != 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
if (dup) {
|
|
||||||
return HA_CHUNK_FALLBACK; /* §5.1 字段级合并 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- choices[0] ---- */
|
|
||||||
if (out->choices_kind == HA_CHUNK_KIND_ARRAY) {
|
|
||||||
if (out->choices_count > 1) {
|
|
||||||
/* Go 侧会解析**全部**元素;本层只认 [0],其余元素可能类型不符
|
|
||||||
* 而让 Go 整块作废 ⇒ 无法保证等价,必须回退。 */
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
if (out->choices_count == 0) {
|
|
||||||
out->choice0_kind = HA_CHUNK_KIND_ABSENT;
|
|
||||||
} else {
|
|
||||||
if (ha_sse_arr_first(&out->choices_span, &el) != 1) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->choice0_kind = kind_of(&el);
|
|
||||||
if (out->choice0_kind != HA_CHUNK_KIND_OBJECT) {
|
|
||||||
/* Go 侧是 []struct:元素非对象 ⇒ 整块作废 */
|
|
||||||
return HA_CHUNK_TYPE_FAIL;
|
|
||||||
}
|
|
||||||
/* 元素内的 delta / finish_reason(struct 字段 ⇒ CI),**一趟**取完。
|
|
||||||
* ★ 初版这里对 choices 数组做了「数个数 + 取首元素」两趟、
|
|
||||||
* 又在 choice0 内单独跑一趟 members —— 合计 3 趟。
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
if (chunk_choice_dispatch(el, out) != 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- delta 内分派 ---- */
|
|
||||||
if (out->slot[HA_CHUNK_SLOT_DELTA].kind == HA_CHUNK_KIND_OBJECT) {
|
|
||||||
v = out->slot[HA_CHUNK_SLOT_DELTA].span;
|
|
||||||
if (chunk_delta_dispatch(v, out, &dup) != 0) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
if (dup) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
} else if (out->slot[HA_CHUNK_SLOT_DELTA].kind == HA_CHUNK_KIND_OTHER) {
|
|
||||||
/* delta 非对象:Go 侧 Unmarshal 到 struct 会失败 */
|
|
||||||
return HA_CHUNK_TYPE_FAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- content:字符串直接解码;文本数组走 stringify ---- */
|
|
||||||
{
|
|
||||||
ha_chunk_slot *cs = &out->slot[HA_CHUNK_SLOT_CONTENT];
|
|
||||||
if (cs->kind == HA_CHUNK_KIND_STRING) {
|
|
||||||
if (!emit_decoded(cs->span, sbuf, scap, &out->content_off,
|
|
||||||
&out->content_len)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
*sused = out->content_len;
|
|
||||||
} else if (cs->kind == HA_CHUNK_KIND_ARRAY) {
|
|
||||||
size_t n = 0;
|
|
||||||
if (!ha_sse_stringify(&cs->span, sbuf, scap, &n)) {
|
|
||||||
/* 需 json.Marshal 重新编码(§5.2)⇒ 交回 Go */
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->content_off = 0;
|
|
||||||
out->content_len = n;
|
|
||||||
*sused = n;
|
|
||||||
} else if (cs->kind == HA_CHUNK_KIND_OBJECT ||
|
|
||||||
cs->kind == HA_CHUNK_KIND_OTHER) {
|
|
||||||
/* 对象/数字/布尔 ⇒ stringifyContent 走 json.Marshal(§5.2) */
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
/* NULL / ABSENT ⇒ content=""(与 Go 的 stringifyContent(nil) 一致) */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- reasoning_content:Go 侧是 **string**(强类型) ----
|
|
||||||
* 若是 string 则解码;若是 null/absent ⇒ "";其它类型 ⇒ 整块作废。 */
|
|
||||||
{
|
|
||||||
ha_chunk_slot *rs = &out->slot[HA_CHUNK_SLOT_REASONING];
|
|
||||||
if (rs->kind == HA_CHUNK_KIND_STRING) {
|
|
||||||
if (!emit_decoded(rs->span, sbuf + *sused, scap - *sused,
|
|
||||||
&out->reasoning_off, &out->reasoning_len)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->reasoning_off += *sused;
|
|
||||||
*sused += out->reasoning_len;
|
|
||||||
} else if (rs->kind != HA_CHUNK_KIND_ABSENT &&
|
|
||||||
rs->kind != HA_CHUNK_KIND_NULL) {
|
|
||||||
return HA_CHUNK_TYPE_FAIL; /* 与 Go 的 Unmarshal 失败一致 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- finish_reason:Go 侧是 *string ---- */
|
|
||||||
{
|
|
||||||
ha_chunk_slot *fs = &out->slot[HA_CHUNK_SLOT_FINISH_REASON];
|
|
||||||
if (fs->kind == HA_CHUNK_KIND_STRING) {
|
|
||||||
if (!emit_decoded(fs->span, sbuf + *sused, scap - *sused,
|
|
||||||
&out->finish_off, &out->finish_len)) {
|
|
||||||
return HA_CHUNK_FALLBACK;
|
|
||||||
}
|
|
||||||
out->finish_off += *sused;
|
|
||||||
*sused += out->finish_len;
|
|
||||||
} else if (fs->kind != HA_CHUNK_KIND_ABSENT &&
|
|
||||||
fs->kind != HA_CHUNK_KIND_NULL) {
|
|
||||||
return HA_CHUNK_TYPE_FAIL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return HA_CHUNK_OK;
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
/*
|
|
||||||
* test_fuzz_ha_codec.c —— libFuzzer 入口:编码语义不变式 + 内存安全
|
|
||||||
*
|
|
||||||
* ============================ 为什么要它 ============================
|
|
||||||
* ha_codec 声称**逐值等价于 Go 参考实现**,其中最要紧的一条是
|
|
||||||
* 「对畸形 UTF-8 的解码边界与 Go 的 utf8.DecodeRuneInString 一致」。
|
|
||||||
* 而这条行为在正常输入下**永远测不到** —— 只有随机字节才能覆盖
|
|
||||||
* 截断的多字节序列 / 过长编码 / 代理对 / 超 U+10FFFF / 内嵌 NUL。
|
|
||||||
*
|
|
||||||
* Go 侧用 TestGolden_InvalidUTF8(3000 组随机字节)做等价钉死;
|
|
||||||
* C 侧则要独立验证两件 Go 测不了的事:
|
|
||||||
* 1. 任何输入都不崩、不越界(内存安全 —— C 侧没有 -race 等价物,
|
|
||||||
* 越界写是静默的,而 ha_codec 的零 malloc 设计依赖这个前提)
|
|
||||||
* 2. 返回值不违反头文件声明的不变式(0 <= keep <= len 等)
|
|
||||||
* —— 违约不会崩,但会让 Go 侧切出错误切片
|
|
||||||
*
|
|
||||||
* 构建:cmake -DBUILD_FUZZ=ON(需 clang);跑:./test_fuzz_ha_codec -max_total_time=60
|
|
||||||
* 见 CMakeLists.txt 的 BUILD_FUZZ 段。
|
|
||||||
*
|
|
||||||
* ⚠️ 关键:所有指针参数都不能为 NULL 时传入随机数据。
|
|
||||||
* libFuzzer 给的是 (const uint8_t *Data, size_t Size),Size 可能为 0;
|
|
||||||
* 而本库的契约是「NULL 或 len==0 返回哨兵/0」——故这里显式分派,
|
|
||||||
* 既测 len>0 路径也测 NULL 路径(后者是 Go 侧空串短路的对应面)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#include "ha_codec.h"
|
|
||||||
|
|
||||||
/* libFuzzer 的 max_len:限制单次输入大小。
|
|
||||||
* 1MB 上限与 Go 侧 SSE 行上限(bufio.Scanner 的 1MB)同量级,
|
|
||||||
* 够覆盖真实最坏输入,又不会让单次迭代慢到没法迭代。 */
|
|
||||||
#define HA_FUZZ_MAX_LEN (1u << 20)
|
|
||||||
|
|
||||||
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size);
|
|
||||||
|
|
||||||
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
|
|
||||||
if (Size > HA_FUZZ_MAX_LEN) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 从输入里取若干参数,让同一批字节同时驱动不同函数的不同分支。
|
|
||||||
* 取模是刻意的:避免引入 PRNG(libFuzzer 自己就是 PRNG,
|
|
||||||
* 再叠一层只会让 corpus 的意图变模糊)。 */
|
|
||||||
const char *s = (const char *)Data;
|
|
||||||
const int n = (int)(Size & 0x7fffffff);
|
|
||||||
int a = (Size > 0) ? (int)Data[0] : 0;
|
|
||||||
int b = (Size > 1) ? (int)Data[1] : 0;
|
|
||||||
|
|
||||||
/* ---- 不变式 1:token 估算非负,且空输入为 0 ---- */
|
|
||||||
int est = ha_codec_estimate_tokens(s, Size);
|
|
||||||
if (est < 0) {
|
|
||||||
abort(); /* 契约:估算值不会为负 */
|
|
||||||
}
|
|
||||||
if (Size == 0 && est != 0) {
|
|
||||||
abort(); /* 契约:空输入返回 0 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 不变式 2:截断返回的字节数恒在 [0, len] 内 ----
|
|
||||||
* 这是 Go 侧 `s[:keep]` 切片的前提。越界即为可利用的内存安全缺陷:
|
|
||||||
* Go 会切出一个指向别处的 string。 */
|
|
||||||
for (int t = 0; t < 4; t++) {
|
|
||||||
int max_tokens = t == 0 ? 0 : t == 1 ? 1 : t == 2 ? n / 4 : n;
|
|
||||||
size_t keep = ha_codec_truncate_by_tokens(s, Size, max_tokens);
|
|
||||||
if (keep > Size) {
|
|
||||||
abort(); /* 契约:0 <= keep <= text_len */
|
|
||||||
}
|
|
||||||
/* 结果必然是输入的前缀:逐字节核对前缀相等。
|
|
||||||
* 这条比 keep <= Size 更强 —— 若实现返回了长度对但内容错的
|
|
||||||
* 切片(例如从中间某处开始拷贝),也能被抓住。 */
|
|
||||||
/* keep 为 0 时无可核对内容 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 不变式 3:截断结果本身可再次被截断且幂等 ----
|
|
||||||
* 即 keep(keep(x)) == keep(x)(截断是幂等算子)。
|
|
||||||
* 违反意味着实现里有状态或边界算错。 */
|
|
||||||
{
|
|
||||||
size_t k1 = ha_codec_truncate_by_tokens(s, Size, (n / 2) + 1);
|
|
||||||
size_t k2 = ha_codec_truncate_by_tokens(s, k1, (n / 2) + 1);
|
|
||||||
if (k2 > k1) {
|
|
||||||
abort(); /* 契约:截断幂等 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 不变式 4:模型名窗口推断的取值域 ----
|
|
||||||
* 契约:要么是合法窗口(>0),要么是 UNKNOWN(-1),不得是别的负值。 */
|
|
||||||
{
|
|
||||||
int w = ha_codec_model_context_window(s, Size);
|
|
||||||
if (w < 0 && w != HA_CODEC_CONTEXT_WINDOW_UNKNOWN) {
|
|
||||||
abort(); /* 契约:负值只能是 UNKNOWN 哨兵 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- NULL 路径:Go 侧空串短路会传 (nil, 0),C 侧必须能吃 ---- */
|
|
||||||
if (Size == 0) {
|
|
||||||
if (ha_codec_estimate_tokens(NULL, 0) != 0) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (ha_codec_truncate_by_tokens(NULL, 0, 16) != 0) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (ha_codec_model_context_window(NULL, 0) != HA_CODEC_CONTEXT_WINDOW_UNKNOWN) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 用 a/b 驱动 max_tokens 的边界值(0 / 负 / 超大)----
|
|
||||||
* 头文件声明 max_tokens <= 0 返回 0;超大值返回整串。 */
|
|
||||||
if (Size > 0) {
|
|
||||||
if (ha_codec_truncate_by_tokens(s, Size, a) > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (ha_codec_truncate_by_tokens(s, Size, b - 256) > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
/*
|
|
||||||
* test_fuzz_ha_json_scan.c — libFuzzer:扫描器的内存安全 + 不变式
|
|
||||||
*
|
|
||||||
* ============================ 为什么要它 ============================
|
|
||||||
* 扫描器是本刀最危险的部件:它做**指针算术与递归下降**,且要处理
|
|
||||||
* 任意上游字节(LLM 网关可能吐任何东西)。C 侧没有 Go 的 -race 等价物,
|
|
||||||
* 越界读/写是**静默**的(不崩、结果看着对)—— 而内核在这里吃掉的是
|
|
||||||
* 不可信输入,所以必须持续模糊,而不是等下一次手写用例。
|
|
||||||
*
|
|
||||||
* 覆盖的六条不变式:
|
|
||||||
* 1. scan/skip 的游标**永不越过**输入长度(否则后续所有 span 都错位)
|
|
||||||
* 2. skip 成功 ⇒ 恰好消费一个完整值,不残留结构字符
|
|
||||||
* 3. 成员迭代器游标单调,且永不越过输入长度
|
|
||||||
* 4. 解码输出的长度上界 = 输入长度的 3 倍
|
|
||||||
* (每个字节最坏变一个 U+FFFD = 3 字节;这是内存规划的前提)
|
|
||||||
* 5. decode_into **绝不越界写**
|
|
||||||
* 6. get_int 与 strtoll 语义在合法整数上一致(溢出时都必须拒绝)
|
|
||||||
*
|
|
||||||
* 构建:cmake -DBUILD_FUZZ=ON(需 clang)
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <limits.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
#include "ha_json_scan.h"
|
|
||||||
|
|
||||||
#define HA_FUZZ_MAX_LEN (1u << 18) /* 256KB:比真实 chunk 大得多,够覆盖 */
|
|
||||||
|
|
||||||
/* 累积解码输出的 sink */
|
|
||||||
typedef struct {
|
|
||||||
size_t total;
|
|
||||||
int overflowed;
|
|
||||||
char stash[4096]; /* 小段暂存,用于比对 decode_into */
|
|
||||||
size_t stash_len;
|
|
||||||
} acc_t;
|
|
||||||
|
|
||||||
static void acc_sink(void *ctx, const char *b, size_t n) {
|
|
||||||
acc_t *a = (acc_t *)ctx;
|
|
||||||
/* 累加并做溢出保护:若真出现无界增长,这里会先崩(暴露问题),
|
|
||||||
* 而不是静默算错。 */
|
|
||||||
if (a->total > (1ull << 40)) {
|
|
||||||
a->overflowed = 1;
|
|
||||||
}
|
|
||||||
a->total += n;
|
|
||||||
if (a->stash_len + n <= sizeof(a->stash)) {
|
|
||||||
memcpy(a->stash + a->stash_len, b, n);
|
|
||||||
a->stash_len += n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size);
|
|
||||||
|
|
||||||
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
|
|
||||||
if (Size > HA_FUZZ_MAX_LEN) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const char *s = (const char *)Data;
|
|
||||||
|
|
||||||
/* ---- 1. skip 游标边界 ---- */
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, s, Size);
|
|
||||||
int ok = ha_json_skip(&sc);
|
|
||||||
if (sc.i > Size) {
|
|
||||||
abort(); /* 游标越界 —— 后续所有 span 都会错位 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 2. skip 成功 ⇒ 消费的是一个完整值;字符串 scan 同样不越界 ---- */
|
|
||||||
if (ok) {
|
|
||||||
/* 从头再扫一次字符串(若首字符是引号),校验 span 落在输入内 */
|
|
||||||
ha_json_scan sc2;
|
|
||||||
ha_json_scan_init(&sc2, s, Size);
|
|
||||||
ha_span raw;
|
|
||||||
if (ha_json_scan_string(&sc2, &raw)) {
|
|
||||||
if (raw.len > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
/* span 必须落在输入区间内 */
|
|
||||||
if (raw.p < s || raw.p > s + Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sc2.i > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 3. 成员迭代器:游标单调不减、不越界 ---- */
|
|
||||||
{
|
|
||||||
ha_json_members m;
|
|
||||||
if (ha_json_members_init(&m, s, Size)) {
|
|
||||||
size_t prev = m.sc.i;
|
|
||||||
ha_span key, val;
|
|
||||||
int guard = 0;
|
|
||||||
while (ha_json_members_next(&m, &key, &val)) {
|
|
||||||
if (m.sc.i > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (m.sc.i < prev) {
|
|
||||||
abort(); /* 游标回退 ⇒ 可能死循环 */
|
|
||||||
}
|
|
||||||
prev = m.sc.i;
|
|
||||||
if (key.len > Size || key.p < s || key.p > s + Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
/* ★ 值必须能独立跳过:这条不变式正是模糊测试第一轮
|
|
||||||
* 抓到的缺陷(旧 API 只报值起点、不消费值,游标仍在
|
|
||||||
* 值的前面,于是下一个成员解析到了值本身)。 */
|
|
||||||
ha_json_scan vs;
|
|
||||||
ha_json_scan_init(&vs, val.p, val.len);
|
|
||||||
if (!ha_json_skip(&vs)) {
|
|
||||||
abort(); /* 成员报了个值,却跳不过去 ⇒ 内部不一致 */
|
|
||||||
}
|
|
||||||
if (val.len > Size || val.p < s || val.p > s + Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (++guard > 100000) {
|
|
||||||
abort(); /* 死循环保护 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* 游标必须落在输入内 */
|
|
||||||
if (m.sc.i > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (m.sc.i > Size) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 4/5. 解码:输出上界 + 不越界写 ----
|
|
||||||
* 上界 3×:每个输入字节最坏变一个 3 字节 U+FFFD。
|
|
||||||
* 若违反,说明解码器会放大数据 —— 那是内存放大的安全隐患。 */
|
|
||||||
{
|
|
||||||
ha_json_scan sc3;
|
|
||||||
ha_json_scan_init(&sc3, s, Size);
|
|
||||||
ha_span raw;
|
|
||||||
if (ha_json_scan_string(&sc3, &raw)) {
|
|
||||||
acc_t acc;
|
|
||||||
memset(&acc, 0, sizeof(acc));
|
|
||||||
size_t out_len = 0;
|
|
||||||
(void)ha_json_decode_string(raw, acc_sink, &acc, &out_len);
|
|
||||||
if (acc.total != out_len) {
|
|
||||||
abort(); /* sink 累加必须等于报告的 out_len */
|
|
||||||
}
|
|
||||||
if (acc.total > (size_t)raw.len * 3 + 3) {
|
|
||||||
abort(); /* 放大超过 3× 上界 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* decode_into 用小缓冲:绝不越界(哨兵检查) */
|
|
||||||
char tiny[8];
|
|
||||||
memset(tiny, 0x5a, sizeof(tiny));
|
|
||||||
size_t got = ha_json_decode_string_into(raw, tiny, sizeof(tiny));
|
|
||||||
/* 成功时必须以 NUL 结尾且长度 < cap */
|
|
||||||
if (got != (size_t)-1) {
|
|
||||||
if (got >= sizeof(tiny)) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (tiny[got] != '\0') {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
/* 失败:末尾 NUL 位不得被单独改写(仍是哨兵或已被部分写)*/
|
|
||||||
/* 只要求不越界 —— ASan 已保证,这里做一个显式触摸 */
|
|
||||||
(void)tiny[sizeof(tiny) - 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 6. get_int 与 strtoll 对照(合法整数) ---- */
|
|
||||||
{
|
|
||||||
ha_span v = { s, Size };
|
|
||||||
long long got = 0;
|
|
||||||
if (ha_json_get_int(v, &got)) {
|
|
||||||
/* 本库认了 ⇒ 必须是纯整数,且 strtoll 应给出同值 */
|
|
||||||
char *dup = (char *)malloc(Size + 1);
|
|
||||||
if (dup) {
|
|
||||||
memcpy(dup, s, Size);
|
|
||||||
dup[Size] = '\0';
|
|
||||||
errno = 0;
|
|
||||||
char *end = NULL;
|
|
||||||
long long ref = strtoll(dup, &end, 10);
|
|
||||||
/* 只有「整串被消费且无溢出」时才可比较 */
|
|
||||||
if (errno == 0 && end == dup + Size) {
|
|
||||||
if (ref != got) {
|
|
||||||
abort(); /* 与 strtoll 分叉 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
free(dup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- NULL / 空输入防御 ---- */
|
|
||||||
if (Size == 0) {
|
|
||||||
ha_json_scan z;
|
|
||||||
ha_json_scan_init(&z, NULL, 0);
|
|
||||||
if (!ha_json_scan_eof(&z)) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
if (ha_json_skip(&z)) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
long long v;
|
|
||||||
ha_span e = { NULL, 0 };
|
|
||||||
if (ha_json_get_int(e, &v)) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
/*
|
|
||||||
* test_ha_codec.c — ha_codec C 侧契约测试
|
|
||||||
*
|
|
||||||
* 编译运行(无 cmake 亦可):
|
|
||||||
* gcc -std=c99 -I../include ../src/ha_codec.c test_ha_codec.c -o test_ha_codec && ./test_ha_codec
|
|
||||||
*
|
|
||||||
* 这一层钉死 C 实现的语义;与 Go 的逐值一致由黄金对照测试负责(双保险)。
|
|
||||||
*
|
|
||||||
* ★ 注意签名已改为「指针 + 长度」(见 ha_codec.h):不再依赖 NUL 结尾,
|
|
||||||
* 截断返回字节数而非字符串。测试相应用 LIT()/LEN 辅助宏。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ha_codec.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
static int g_fail = 0;
|
|
||||||
static int g_pass = 0;
|
|
||||||
|
|
||||||
/* 字面量 → (指针, 长度):避免每处手写 sizeof-1。 */
|
|
||||||
#define LIT(s) (s), (sizeof(s) - 1)
|
|
||||||
|
|
||||||
static void check_int(const char *what, int got, int want) {
|
|
||||||
if (got != want) {
|
|
||||||
printf(" [FAIL] %s: got %d, want %d\n", what, got, want);
|
|
||||||
g_fail++;
|
|
||||||
} else {
|
|
||||||
g_pass++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 断言「截断得到的字节数」确实是原文前缀,且正好是期望的字节长度。 */
|
|
||||||
static void check_trunc_prefix(const char *what, const char *text, size_t len,
|
|
||||||
int max_tokens, size_t want_bytes) {
|
|
||||||
size_t got = ha_codec_truncate_by_tokens(text, len, max_tokens);
|
|
||||||
if (got != want_bytes) {
|
|
||||||
printf(" [FAIL] %s: got %zu bytes, want %zu\n", what, got, want_bytes);
|
|
||||||
g_fail++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (got > len) {
|
|
||||||
printf(" [FAIL] %s: 返回值 %zu 超出输入长度 %zu\n", what, got, len);
|
|
||||||
g_fail++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
g_pass++;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 字节级断言:截断结果的字节内容必须与期望字符串逐字节相等。 */
|
|
||||||
static void check_trunc_bytes(const char *what, const char *text, size_t len,
|
|
||||||
int max_tokens, const char *want) {
|
|
||||||
size_t got = ha_codec_truncate_by_tokens(text, len, max_tokens);
|
|
||||||
size_t want_len = strlen(want);
|
|
||||||
if (got != want_len) {
|
|
||||||
printf(" [FAIL] %s: got %zu bytes, want %zu\n", what, got, want_len);
|
|
||||||
g_fail++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (got > 0 && memcmp(text, want, got) != 0) {
|
|
||||||
printf(" [FAIL] %s: 字节内容不匹配\n", what);
|
|
||||||
g_fail++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
g_pass++;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_context_window(void) {
|
|
||||||
printf("model_context_window:\n");
|
|
||||||
check_int("deepseek-v4.1-flash",
|
|
||||||
ha_codec_model_context_window(LIT("deepseek/deepseek-v4.1-flash")), 1048576);
|
|
||||||
check_int("deepseek-v4-flash",
|
|
||||||
ha_codec_model_context_window(LIT("deepseek-v4-flash")), 1048576);
|
|
||||||
check_int("deepseek-chat",
|
|
||||||
ha_codec_model_context_window(LIT("deepseek-chat")), 65536);
|
|
||||||
check_int("claude-opus-5",
|
|
||||||
ha_codec_model_context_window(LIT("claude-opus-5")), 100000);
|
|
||||||
check_int("gpt-4-turbo",
|
|
||||||
ha_codec_model_context_window(LIT("gpt-4-turbo")), 128000);
|
|
||||||
check_int("llama-3-70b",
|
|
||||||
ha_codec_model_context_window(LIT("llama-3-70b")), 8192);
|
|
||||||
check_int("AUTO (unknown)",
|
|
||||||
ha_codec_model_context_window(LIT("AUTO")), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
|
|
||||||
check_int("NULL (unknown)",
|
|
||||||
ha_codec_model_context_window(NULL, 0), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
|
|
||||||
check_int("zero len (unknown)",
|
|
||||||
ha_codec_model_context_window("abc", 0), HA_CODEC_CONTEXT_WINDOW_UNKNOWN);
|
|
||||||
check_int("case-insensitive",
|
|
||||||
ha_codec_model_context_window(LIT("QWEN-MAX")), 131072);
|
|
||||||
check_int("moonshot",
|
|
||||||
ha_codec_model_context_window(LIT("moonshot-v1-128k")), 131072);
|
|
||||||
/* 分支顺序:gpt-4-turbo 必须先于裸 gpt-4 命中 */
|
|
||||||
check_int("gpt-4-mini (branch order)",
|
|
||||||
ha_codec_model_context_window(LIT("gpt-4-mini")), 128000);
|
|
||||||
check_int("gpt-4 (bare)",
|
|
||||||
ha_codec_model_context_window(LIT("gpt-4")), 8192);
|
|
||||||
/* claude-3 必须先于裸 claude */
|
|
||||||
check_int("claude-3-opus (branch order)",
|
|
||||||
ha_codec_model_context_window(LIT("claude-3-opus")), 200000);
|
|
||||||
/* 中文子串:非 ASCII 字节不受折叠影响 */
|
|
||||||
check_int("零一万物",
|
|
||||||
ha_codec_model_context_window(LIT("\xe9\x9b\xb6\xe4\xb8\x80\xe4\xb8\x87\xe7\x89\xa9")), 200000);
|
|
||||||
|
|
||||||
/* 非 NUL 结尾:把模型名放在大缓冲中间,只传前 N 字节。
|
|
||||||
* 这是新签名的关键能力(旧签名会读到后续垃圾)。 */
|
|
||||||
{
|
|
||||||
char buf[64];
|
|
||||||
memset(buf, 'Z', sizeof(buf));
|
|
||||||
memcpy(buf, "qwen-max", 8);
|
|
||||||
check_int("no NUL terminator (prefix only)",
|
|
||||||
ha_codec_model_context_window(buf, 8), 131072);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 超长模型名(超过栈缓冲)必须仍零分配地正确匹配。 */
|
|
||||||
{
|
|
||||||
static char big[512];
|
|
||||||
memset(big, 'a', sizeof(big));
|
|
||||||
memcpy(big + 400, "gpt-4-turbo", 11);
|
|
||||||
check_int("oversize model name (heap-free fallback)",
|
|
||||||
ha_codec_model_context_window(big, sizeof(big)), 128000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_estimate_tokens(void) {
|
|
||||||
printf("estimate_tokens:\n");
|
|
||||||
check_int("empty", ha_codec_estimate_tokens(LIT("")), 0);
|
|
||||||
check_int("NULL", ha_codec_estimate_tokens(NULL, 0), 0);
|
|
||||||
check_int("zero len", ha_codec_estimate_tokens("abc", 0), 0);
|
|
||||||
/* "abc" = 3 rune * 2 = 6 */
|
|
||||||
check_int("ascii abc", ha_codec_estimate_tokens(LIT("abc")), 6);
|
|
||||||
/* "你好" = 2 rune * 2 = 4(不是字节数 6) */
|
|
||||||
check_int("chinese 2 chars", ha_codec_estimate_tokens(LIT("你好")), 4);
|
|
||||||
/* 混合 "a你" = 2 rune * 2 = 4 */
|
|
||||||
check_int("mixed", ha_codec_estimate_tokens(LIT("a你")), 4);
|
|
||||||
/* 4 字节 emoji:1 rune * 2 = 2 */
|
|
||||||
check_int("emoji", ha_codec_estimate_tokens(LIT("\xF0\x9F\x98\x80")), 2);
|
|
||||||
|
|
||||||
/* ASCII 快路径跨界:长度正好落在批量块边界附近,计数必须精确。 */
|
|
||||||
{
|
|
||||||
static char buf[300];
|
|
||||||
memset(buf, 'x', sizeof(buf));
|
|
||||||
check_int("ascii 300 bytes (chunk boundaries)",
|
|
||||||
ha_codec_estimate_tokens(buf, sizeof(buf)), 600);
|
|
||||||
}
|
|
||||||
/* 非 NUL 结尾:只计前 N 字节(后面是垃圾)。 */
|
|
||||||
{
|
|
||||||
char buf[16];
|
|
||||||
memcpy(buf, "abc", 3);
|
|
||||||
memset(buf + 3, 'x', sizeof(buf) - 3);
|
|
||||||
check_int("no NUL terminator (prefix only)",
|
|
||||||
ha_codec_estimate_tokens(buf, 3), 6);
|
|
||||||
}
|
|
||||||
/* 截断的多字节序列:Go 对无效序列按每字节 1 rune 计,C 必须一致。 */
|
|
||||||
check_int("truncated 3-byte seq (invalid)",
|
|
||||||
ha_codec_estimate_tokens("\xE4\xBD", 2), 4); /* 2 rune → 4 */
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_truncate(void) {
|
|
||||||
printf("truncate_by_tokens:\n");
|
|
||||||
|
|
||||||
/* max_tokens<=0 → 0 字节 */
|
|
||||||
check_trunc_prefix("max_tokens=0", LIT("hello"), 0, 0);
|
|
||||||
|
|
||||||
/* 未超限 → 全长 */
|
|
||||||
check_trunc_prefix("no truncation", LIT("abc"), 100, 3);
|
|
||||||
|
|
||||||
/* "abcdefghij" = 10 rune → 20 tokens;max=8 → keep=4 → "abcd" */
|
|
||||||
check_trunc_prefix("keep 4", LIT("abcdefghij"), 8, 4);
|
|
||||||
|
|
||||||
/* 中文按 rune 截断,不切碎 UTF-8:"你好世界" 4 rune,max=4 → keep=2 → "你好"(6B) */
|
|
||||||
check_trunc_prefix("chinese keep 2", LIT("你好世界"), 4, 6);
|
|
||||||
|
|
||||||
/* 恰好等于预算:不截断 */
|
|
||||||
check_trunc_prefix("exact budget", LIT("abc"), 6, 3);
|
|
||||||
/* 差一:截断。3 rune=6 tokens,max=5 → keep=2 → "ab" */
|
|
||||||
check_trunc_prefix("just under budget", LIT("abc"), 5, 2);
|
|
||||||
|
|
||||||
/* 长 ASCII 跨批量块边界,keep 落在块内(提前短路路径)。 */
|
|
||||||
{
|
|
||||||
static char buf[200];
|
|
||||||
memset(buf, 'k', sizeof(buf));
|
|
||||||
check_trunc_prefix("long ascii, keep inside chunk", buf, sizeof(buf), 128, 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 非 NUL 结尾:max 足够大 → 返回传入长度(而非 strlen 结果)。 */
|
|
||||||
{
|
|
||||||
char buf[16];
|
|
||||||
memcpy(buf, "abcd", 4);
|
|
||||||
memset(buf + 4, 'x', sizeof(buf) - 4);
|
|
||||||
check_trunc_prefix("no NUL terminator, full length", buf, 4, 100, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单字节 rune 边界:ASCII 与多字节混合,确保不切在字符中间。
|
|
||||||
* "a你b好c" = 5 rune = 10 tokens;max=6 → keep=3 → "a你b" = 1+3+1 = 5 字节 */
|
|
||||||
check_trunc_prefix("mixed keep 3", LIT("a你b好c"), 6, 5);
|
|
||||||
/* max=4 → keep=2 → "a你" = 1+3 = 4 字节(正好切在字符边界上)*/
|
|
||||||
check_trunc_prefix("mixed keep 2 (byte boundary)", LIT("a你b好c"), 4, 4);
|
|
||||||
/* 字节内容级校验:结果必须是原串的**逐字节前缀**,不能切碎 UTF-8。 */
|
|
||||||
check_trunc_bytes("content zh keep 2", "你好世界", sizeof("你好世界") - 1, 4, "你好");
|
|
||||||
check_trunc_bytes("content ascii keep 4", "abcdefghij", 10, 8, "abcd");
|
|
||||||
check_trunc_bytes("content no truncation", "abc", 3, 100, "abc");
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
printf("=== ha_codec 契约测试 ===\n\n");
|
|
||||||
test_context_window();
|
|
||||||
test_estimate_tokens();
|
|
||||||
test_truncate();
|
|
||||||
printf("\n=== 结果: %d passed, %d failed ===\n", g_pass, g_fail);
|
|
||||||
return g_fail == 0 ? 0 : 1;
|
|
||||||
}
|
|
||||||
@ -1,414 +0,0 @@
|
|||||||
/*
|
|
||||||
* test_ha_json_scan.c — ha_json_scan 的 C 侧契约测试
|
|
||||||
*
|
|
||||||
* 覆盖重点(与 docs/zh/c-core/sse-codec-c.md 真值表对应):
|
|
||||||
* 语法严格性、键大小写不敏感、重复键后者胜、\u 解码(含代理对)、
|
|
||||||
* 非法 UTF-8 → U+FFFD、整数溢出、深度保险。
|
|
||||||
*
|
|
||||||
* 另一半验收在 Go 侧(codec_jsongolden_test.go):与 encoding/json 逐值比对。
|
|
||||||
* 本文件负责**不依赖 Go** 的语义自洽与边界安全。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
|
|
||||||
#include "ha_json_scan.h"
|
|
||||||
|
|
||||||
static int g_fail = 0;
|
|
||||||
static int g_run = 0;
|
|
||||||
|
|
||||||
static void check(int cond, const char *what, const char *detail) {
|
|
||||||
g_run++;
|
|
||||||
if (!cond) {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] %s%s%s\n", what,
|
|
||||||
detail ? " :: " : "", detail ? detail : "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void check_str(const char *what, const char *got, size_t gotlen,
|
|
||||||
const char *want) {
|
|
||||||
g_run++;
|
|
||||||
size_t wl = strlen(want);
|
|
||||||
if (wl != gotlen || memcmp(got, want, wl) != 0) {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] %s: got \"%.*s\" want \"%s\"\n", what,
|
|
||||||
(int)gotlen, got, want);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 语法严格性 ---------------- */
|
|
||||||
|
|
||||||
/* 约定:ok=1 表示「skip 成功」;ok=0 表示「拒绝」。
|
|
||||||
* ★ 注意 `{"a":1}x` 在 skip 层**不拒绝**(skip 只跳一个值),
|
|
||||||
* 而「尾部有残留」的判定是**调用方的义务**(比对游标是否到末尾)。
|
|
||||||
* 这与 Go 侧 json.Unmarshal 的区别就在这:Unmarshal 会拒绝尾部残留。
|
|
||||||
* 故下面用 eoc(end-of-consume)字段单独断言。 */
|
|
||||||
static void test_syntax(void) {
|
|
||||||
struct { const char *in; int ok; } cases[] = {
|
|
||||||
{ "{", 0 }, { "{\"a\":}", 0 }, { "", 0 },
|
|
||||||
/* 顶层非对象:skip 层**接受**(它是个合法 JSON 值),
|
|
||||||
* 由「必须落到对象」的需求在上层拒绝。Go 侧拒绝是因为要 Unmarshal
|
|
||||||
* 进 struct,与 skip 语义不同层。 */
|
|
||||||
{ "null", 1 }, { "[]", 1 }, { "\"str\"", 1 }, { "123", 1 },
|
|
||||||
{ "{\"a\":1,}", 0 }, /* 尾逗号非法 */
|
|
||||||
{ "{'a':1}", 0 }, /* 单引号非法 */
|
|
||||||
{ "{\"a\":1", 0 }, /* 未闭合 */
|
|
||||||
{ "{\"a\" 1}", 0 }, /* 缺冒号 */
|
|
||||||
{ "{\"a\":01}", 0 }, /* 前导零 */
|
|
||||||
{ "{\"a\":1.}", 0 }, /* 1. 非法 */
|
|
||||||
{ "{\"a\":1e}", 0 }, /* 1e 非法 */
|
|
||||||
{ "{\"a\":-}", 0 },
|
|
||||||
{ "{\"a\":tru}", 0 },
|
|
||||||
{ "{\"a\":\"b\"", 0 },
|
|
||||||
{ "{\"a\":\"b\nc\"}", 0 }, /* 字符串内裸控制字符 */
|
|
||||||
{ "{\"a\":\"b\\\"}", 0 }, /* 悬空转义 */
|
|
||||||
/* 合法 */
|
|
||||||
{ "{}", 1 }, { "{\"a\":1}", 1 }, { "{\"a\":null}", 1 },
|
|
||||||
{ "{\"a\":true}", 1 }, { "{\"a\":-1}", 1 }, { "{\"a\":1.5}", 1 },
|
|
||||||
{ "{\"a\":1e2}", 1 }, { " {\"a\" : 1 } ", 1 },
|
|
||||||
{ "{\"a\":\"\\u4f60\"}", 1 }, { "{\"a\":{\"b\":[1,2]}}", 1 },
|
|
||||||
{ "{\"a\":[],\"b\":{}}", 1 },
|
|
||||||
};
|
|
||||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, cases[i].in, strlen(cases[i].in));
|
|
||||||
int ok = ha_json_skip(&sc);
|
|
||||||
check(ok == cases[i].ok, "syntax", cases[i].in);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 尾部残留:skip 不管,但调用方必须能察觉(比对游标) */
|
|
||||||
{
|
|
||||||
const char *s = "{\"a\":1}x";
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, s, strlen(s));
|
|
||||||
check(ha_json_skip(&sc) == 1, "trailing-garbage-skip-ok", s);
|
|
||||||
check(sc.i != sc.n, "trailing-garbage-detectable", s);
|
|
||||||
}
|
|
||||||
/* 前后空白:必须被吃掉,调用方才能用 i==n 判定「干净」 */
|
|
||||||
{
|
|
||||||
const char *s = " {\"a\" : 1 } ";
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, s, strlen(s));
|
|
||||||
check(ha_json_skip(&sc) == 1, "ws-skip-ok", s);
|
|
||||||
/* 尾部空白是 JSON 允许的:**不能**要求游标精确落在 n。
|
|
||||||
* 真正要保证的是「值本体已被完整消费」——
|
|
||||||
* 即剩余部分只剩空白。这个判定留给调用方(见 sse-codec-c.md §2.5)。 */
|
|
||||||
int rest_is_ws = 1;
|
|
||||||
for (size_t k = sc.i; k < sc.n; k++) {
|
|
||||||
if (s[k] != ' ' && s[k] != '\t' && s[k] != '\n' && s[k] != '\r') {
|
|
||||||
rest_is_ws = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check(rest_is_ws, "ws-tail-only-whitespace", s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 顶层成员迭代 / 大小写不敏感 ---------------- */
|
|
||||||
|
|
||||||
static void test_members(void) {
|
|
||||||
/* Go 的键匹配大小写不敏感:{"DELTA":{"CONTENT":"up"}} */
|
|
||||||
const char *s = "{\"DELTA\":{\"CONTENT\":\"up\"}}";
|
|
||||||
ha_json_members m;
|
|
||||||
check(ha_json_members_init(&m, s, strlen(s)) == 1, "members-init", s);
|
|
||||||
|
|
||||||
ha_span key, val, outer_delta = { NULL, 0 };
|
|
||||||
while (ha_json_members_next(&m, &key, &val)) {
|
|
||||||
if (ha_json_key_eq(key, "delta")) {
|
|
||||||
outer_delta = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check(outer_delta.p != NULL, "members-case-insensitive", s);
|
|
||||||
check(ha_json_members_complete(&m) == 1, "members-complete", s);
|
|
||||||
|
|
||||||
/* 二级:CONTENT 也应能取到 */
|
|
||||||
ha_json_members m2;
|
|
||||||
check(ha_json_members_init(&m2, outer_delta.p, outer_delta.len) == 1,
|
|
||||||
"members-init-2", NULL);
|
|
||||||
ha_span k2, v2;
|
|
||||||
int found = 0;
|
|
||||||
while (ha_json_members_next(&m2, &k2, &v2)) {
|
|
||||||
if (ha_json_key_eq(k2, "content")) {
|
|
||||||
found = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check(found, "members-case-insensitive-2", NULL);
|
|
||||||
check(ha_json_members_complete(&m2) == 1, "members-complete-2", NULL);
|
|
||||||
|
|
||||||
/* 便捷取值 */
|
|
||||||
char buf[64];
|
|
||||||
size_t n = ha_json_object_get_string(outer_delta, "CONTENT", buf, sizeof(buf));
|
|
||||||
g_run++;
|
|
||||||
if (n != 2 || memcmp(buf, "up", 2) != 0) {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] object_get_string: n=%zu buf=%s\n", n, buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 重复键后者胜 ---------------- */
|
|
||||||
|
|
||||||
static void test_dup_key(void) {
|
|
||||||
const char *s = "{\"total_tokens\":1,\"total_tokens\":2}";
|
|
||||||
ha_span obj = { s, strlen(s) };
|
|
||||||
long long v = 0;
|
|
||||||
check(ha_json_object_get_int(obj, "total_tokens", &v) == 1, "dup-getint", s);
|
|
||||||
g_run++;
|
|
||||||
if (v != 2) {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] dup-key 应后者胜: got %lld want 2\n", v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 畸形输入必须能被辨别(复刻 Go 严格性) ---------------- */
|
|
||||||
static void test_malformed_detected(void) {
|
|
||||||
struct { const char *in; int complete; } cases[] = {
|
|
||||||
{ "{}", 1 }, { "{\"a\":1}", 1 },
|
|
||||||
{ "{\"a\":1", 0 }, /* 缺 '}' */
|
|
||||||
{ "{\"a\":1,}", 0 }, /* 尾逗号 */
|
|
||||||
{ "{\"a\":}", 0 }, /* 值非法 */
|
|
||||||
{ "{\"a\"}", 0 }, /* 缺冒号与值 */
|
|
||||||
{ "{\"a\":1 \"b\":2}", 0 }, /* 缺逗号 */
|
|
||||||
{ "{'a':1}", 0 }, /* 单引号 */
|
|
||||||
};
|
|
||||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) {
|
|
||||||
ha_json_members m;
|
|
||||||
int init_ok = ha_json_members_init(&m, cases[i].in, strlen(cases[i].in));
|
|
||||||
g_run++;
|
|
||||||
if (!init_ok) {
|
|
||||||
/* init 失败也算「正确地拒绝了」 */
|
|
||||||
g_run++; continue;
|
|
||||||
}
|
|
||||||
ha_span k, v;
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) { /* 全部消费 */ }
|
|
||||||
int done = ha_json_members_complete(&m);
|
|
||||||
g_run++;
|
|
||||||
if (done != cases[i].complete) {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] malformed[%zu] %s: complete=%d 期望 %d\n",
|
|
||||||
i, cases[i].in, done, cases[i].complete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 关键:返回 1 的成员,其值必须能独立 skip(fuzz 抓到过的正是这条) */
|
|
||||||
{
|
|
||||||
const char *s = "{\"\":k\"\"}"; /* fuzz 崩溃输入的形状 */
|
|
||||||
ha_json_members m;
|
|
||||||
if (ha_json_members_init(&m, s, strlen(s))) {
|
|
||||||
ha_span k, v;
|
|
||||||
int guard = 0;
|
|
||||||
while (ha_json_members_next(&m, &k, &v)) {
|
|
||||||
ha_json_scan vs;
|
|
||||||
ha_json_scan_init(&vs, v.p, v.len);
|
|
||||||
if (!ha_json_skip(&vs)) {
|
|
||||||
check(0, "member-value-must-be-skippable", s);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (++guard > 1000) { check(0, "member-iter-loop", s); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 字符串解码 / \u / 代理对 ---------------- */
|
|
||||||
|
|
||||||
static void test_decode(void) {
|
|
||||||
struct { const char *in; const char *want; } cases[] = {
|
|
||||||
{ "\"\"", "" },
|
|
||||||
{ "\"a\"", "a" },
|
|
||||||
{ "\"\\\"\"", "\"" },
|
|
||||||
{ "\"\\\\\"", "\\" },
|
|
||||||
{ "\"\\/\"", "/" },
|
|
||||||
{ "\"\\b\\f\\n\\r\\t\"", "\b\f\n\r\t" },
|
|
||||||
{ "\"\\u4f60\\u597d\"", "\xe4\xbd\xa0\xe5\xa5\xbd" }, /* 你好 */
|
|
||||||
{ "\"\\ud83d\\ude00\"", "\xf0\x9f\x98\x80" }, /* 😀 代理对 */
|
|
||||||
{ "\"\\u0041\"", "A" },
|
|
||||||
{ "\"\\u00e9\"", "\xc3\xa9" },
|
|
||||||
{ "\"\\u4e2d\\u6587\"", "\xe4\xb8\xad\xe6\x96\x87" },
|
|
||||||
/* 非法 UTF-8:每字节一个 U+FFFD */
|
|
||||||
{ "\"\xff\xfe\"", "\xef\xbf\xbd\xef\xbf\xbd" },
|
|
||||||
{ "\"\xc3\"", "\xef\xbf\xbd" }, /* 截断序列 */
|
|
||||||
{ "\"\xc3\x28\"", "\xef\xbf\xbd\x28" }, /* 坏续字节 */
|
|
||||||
{ "\"\xe0\x80\x80\"", "\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" }, /* 过长 */
|
|
||||||
{ "\"\xed\xa0\x80\"", "\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" }, /* 代理区 */
|
|
||||||
{ "\"\xf5\x80\x80\x80\"", "\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" },
|
|
||||||
/* 孤立代理 */
|
|
||||||
{ "\"\\udc00\"", "\xef\xbf\xbd" },
|
|
||||||
{ "\"\\ud800\"", "\xef\xbf\xbd" },
|
|
||||||
/* 正常中文直传 */
|
|
||||||
{ "\"\xe4\xbd\xa0\xe5\xa5\xbd\"", "\xe4\xbd\xa0\xe5\xa5\xbd" },
|
|
||||||
};
|
|
||||||
char buf[64];
|
|
||||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, cases[i].in, strlen(cases[i].in));
|
|
||||||
ha_span raw;
|
|
||||||
int ok = ha_json_scan_string(&sc, &raw);
|
|
||||||
if (!ok) { check(0, "scan-string", cases[i].in); continue; }
|
|
||||||
size_t n = ha_json_decode_string_into(raw, buf, sizeof(buf));
|
|
||||||
if (n == (size_t)-1) {
|
|
||||||
check(0, "decode", cases[i].in);
|
|
||||||
} else {
|
|
||||||
check_str("decode-value", buf, n, cases[i].want);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 非法转义必须报错而不是静默吞掉 */
|
|
||||||
static void test_bad_escape(void) {
|
|
||||||
const char *bad[] = { "\"\\q\"", "\"\\u00\"", "\"\\uZZZZ\"", "\"\\u12g4\"" };
|
|
||||||
for (size_t i = 0; i < sizeof(bad)/sizeof(bad[0]); i++) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, bad[i], strlen(bad[i]));
|
|
||||||
ha_span raw;
|
|
||||||
if (ha_json_scan_string(&sc, &raw)) {
|
|
||||||
char buf[32];
|
|
||||||
size_t n = ha_json_decode_string_into(raw, buf, sizeof(buf));
|
|
||||||
check(n == (size_t)-1, "bad-escape-must-fail", bad[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 整数 ---------------- */
|
|
||||||
|
|
||||||
static void test_int(void) {
|
|
||||||
struct { const char *in; int ok; long long v; } cases[] = {
|
|
||||||
{ "0", 1, 0 }, { "1", 1, 1 }, { "-1", 1, -1 },
|
|
||||||
{ "12345", 1, 12345 }, { "-99999", 1, -99999 },
|
|
||||||
{ "0", 1, 0 },
|
|
||||||
{ "9223372036854775807", 1, 9223372036854775807LL },
|
|
||||||
{ "-9223372036854775808", 1, -9223372036854775807LL - 1 },
|
|
||||||
{ "9223372036854775808", 0, 0 }, /* 溢出 */
|
|
||||||
{ "-9223372036854775809", 0, 0 }, /* 溢出 */
|
|
||||||
{ "1.5", 0, 0 }, { "1e2", 0, 0 }, { "", 0, 0 },
|
|
||||||
{ "abc", 0, 0 }, { "0x10", 0, 0 },
|
|
||||||
};
|
|
||||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) {
|
|
||||||
long long v = 0;
|
|
||||||
int ok = ha_json_get_int((ha_span){ cases[i].in, strlen(cases[i].in) }, &v);
|
|
||||||
check(ok == cases[i].ok, "int-ok", cases[i].in);
|
|
||||||
if (ok && cases[i].ok) {
|
|
||||||
g_run++;
|
|
||||||
if (v != cases[i].v) {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] int %s: got %lld want %lld\n",
|
|
||||||
cases[i].in, v, cases[i].v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 缓冲不足不写越界 ---------------- */
|
|
||||||
|
|
||||||
static void test_buf_overflow(void) {
|
|
||||||
/* 缓冲区不足:必须返回 -1,且**绝不写出缓冲之外**。
|
|
||||||
* ASan 在这里把关:越界写会被直接抓住,故这条断言能回归「写到
|
|
||||||
* buf[len] 恰好越界」这类经典错误。允许部分写入(流式 sink 的
|
|
||||||
* 固有性质),调用方拿到 -1 必须丢弃整个结果。 */
|
|
||||||
char small[4];
|
|
||||||
memset(small, 0x7f, sizeof(small));
|
|
||||||
ha_span raw = { "abcdefghijklmnop", 16 };
|
|
||||||
size_t n = ha_json_decode_string_into(raw, small, sizeof(small));
|
|
||||||
check(n == (size_t)-1, "overflow-must-fail", NULL);
|
|
||||||
/* 结尾 NUL 位不得被写(out_cap 内的最后一位) */
|
|
||||||
check((unsigned char)small[sizeof(small)-1] == 0x7f || n == (size_t)-1,
|
|
||||||
"overflow-no-oob", NULL);
|
|
||||||
/* ★ 边界:out_cap = 内容 + 1(正好留给结尾 NUL)必须成功。
|
|
||||||
*
|
|
||||||
* sink 是**逐字节**发射的(一个 rune 可能分成多次 sink 调用),
|
|
||||||
* 而 buf_write 写满 cap 后即判定溢出 ⇒ 若 cap 只等于内容长度,
|
|
||||||
* 最后一个字节就会撞上 cap 而被判溢出。
|
|
||||||
* 这就是为什么 buf_write 里必须是 `s->len + n > s->cap` 才溢出:
|
|
||||||
* cap 已经预留了结尾 NUL 的位置(out_cap - 1),故 `>` 才是判据;
|
|
||||||
* 若写成 `>=`,「内容恰好占满 cap」会被误判为溢出。 */
|
|
||||||
char exact[5];
|
|
||||||
ha_span four = { "abcd", 4 }; /* ★ 必须用 4 字节 span,
|
|
||||||
* 不能用上面那个 16 字节的 raw */
|
|
||||||
size_t n2 = ha_json_decode_string_into(four, exact, sizeof(exact));
|
|
||||||
g_run++;
|
|
||||||
if (n2 != 4 || memcmp(exact, "abcd", 4) != 0 || exact[4] != '\0') {
|
|
||||||
g_fail++;
|
|
||||||
printf(" [FAIL] exact-fit: n=%zu (期望 4)\\n", n2);
|
|
||||||
}
|
|
||||||
/* 少一位(cap 3 < 内容 4)必须失败 */
|
|
||||||
char tight[4];
|
|
||||||
size_t n3 = ha_json_decode_string_into(four, tight, sizeof(tight));
|
|
||||||
check(n3 == (size_t)-1, "one-short-must-fail", NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 深度保险 ---------------- */
|
|
||||||
|
|
||||||
static void test_deep_nesting(void) {
|
|
||||||
/* 200 层嵌套:应被拒(不崩溃、不栈溢出) */
|
|
||||||
char deep[512];
|
|
||||||
size_t d = 0;
|
|
||||||
for (int i = 0; i < 200; i++) { deep[d++] = '['; }
|
|
||||||
for (int i = 0; i < 200; i++) { deep[d++] = ']'; }
|
|
||||||
deep[d] = '\0';
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, deep, d);
|
|
||||||
int ok = ha_json_skip(&sc);
|
|
||||||
check(ok == 0, "deep-nesting-rejected", NULL);
|
|
||||||
|
|
||||||
/* 30 层:合法,应通过 */
|
|
||||||
d = 0;
|
|
||||||
for (int i = 0; i < 30; i++) { deep[d++] = '['; }
|
|
||||||
for (int i = 0; i < 30; i++) { deep[d++] = ']'; }
|
|
||||||
deep[d] = '\0';
|
|
||||||
ha_json_scan sc2;
|
|
||||||
ha_json_scan_init(&sc2, deep, d);
|
|
||||||
check(ha_json_skip(&sc2) == 1, "moderate-nesting-ok", NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- NUL 字节在输入里 ---------------- */
|
|
||||||
|
|
||||||
static void test_embedded_nul(void) {
|
|
||||||
/* 输入含 NUL:因签名是 (ptr,len) 而非 C 字符串,必须能正确处理 */
|
|
||||||
const char s[] = "{\"a\":\"x\0y\"}";
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, s, sizeof(s) - 1);
|
|
||||||
check(ha_json_skip(&sc) == 0, "embedded-nul-rejected", NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- NULL / 空输入防御 ---------------- */
|
|
||||||
|
|
||||||
static void test_null_defense(void) {
|
|
||||||
ha_json_scan sc;
|
|
||||||
ha_json_scan_init(&sc, NULL, 0);
|
|
||||||
check(ha_json_scan_eof(&sc) == 1, "null-init-eof", NULL);
|
|
||||||
check(ha_json_skip(&sc) == 0, "null-skip", NULL);
|
|
||||||
|
|
||||||
ha_span empty = { NULL, 0 };
|
|
||||||
long long v;
|
|
||||||
check(ha_json_get_int(empty, &v) == 0, "null-int", NULL);
|
|
||||||
check(ha_json_object_get_string(empty, "a", NULL, 0) == (size_t)-1,
|
|
||||||
"null-getstring", NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- ABI ---------------- */
|
|
||||||
|
|
||||||
static void test_abi(void) {
|
|
||||||
int v = ha_json_scan_abi_version();
|
|
||||||
check(v == HA_JSON_SCAN_ABI_VERSION, "abi-self", NULL);
|
|
||||||
check(v >= 1000 && v <= 99999, "abi-range", NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
printf("== ha_json_scan 契约测试 ==\n");
|
|
||||||
test_abi();
|
|
||||||
test_syntax();
|
|
||||||
test_members();
|
|
||||||
test_dup_key();
|
|
||||||
test_malformed_detected();
|
|
||||||
test_decode();
|
|
||||||
test_bad_escape();
|
|
||||||
test_int();
|
|
||||||
test_buf_overflow();
|
|
||||||
test_deep_nesting();
|
|
||||||
test_embedded_nul();
|
|
||||||
test_null_defense();
|
|
||||||
|
|
||||||
printf("%s:%d 项断言,%d 失败\n",
|
|
||||||
g_fail == 0 ? "PASS" : "FAIL", g_run, g_fail);
|
|
||||||
return g_fail == 0 ? 0 : 1;
|
|
||||||
}
|
|
||||||
364
demo.md
Normal file
364
demo.md
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md)
|
||||||
|
|
||||||
|
> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程,
|
||||||
|
> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 背景与目标
|
||||||
|
|
||||||
|
框架目标:**内核零 IO、插件承载所有 IO**(`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。
|
||||||
|
|
||||||
|
**Failback 的定位:所有错误的一层兜底(Safe-Mode 式),而非针对单一场景。**
|
||||||
|
|
||||||
|
- 主 agent(全量 LLM agent)是一切骚操作的执行者,可能把自己搞到无法自愈的任意状态:
|
||||||
|
LLM 源改坏 / 系统网络(proxy、DNS、host)破坏 / 配置文件损坏 / OOM / panic / 崩溃循环……
|
||||||
|
这些错误无法在**同一个被污染环境内**用自身操作自救。
|
||||||
|
- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**——
|
||||||
|
类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP),
|
||||||
|
单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。**
|
||||||
|
- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现状盘点(代码勘查结论)
|
||||||
|
|
||||||
|
### 1.1 守护进程(`internal/supervisor/daemon.go`)
|
||||||
|
|
||||||
|
- 主 agent in-process 常驻,`agent.Start()` 在 `cmd/homed/main.go:468` 直接启动,**无独立进程边界**。
|
||||||
|
- `healthLoop` → `checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor` 的 `AggregateResult().LLMAPIReachable`**(`daemon.go:132`)。
|
||||||
|
- `failCount >= MaxRetries`(默认 3)→ `handleFailure`:
|
||||||
|
- 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`。
|
||||||
|
- `restartAgent`(`daemon.go:166`)**只改内存状态再重新 Register,不真重启任何进程**,几乎空转。
|
||||||
|
|
||||||
|
关键问题:
|
||||||
|
- 探测是系统级(HTTP/DNS/TCP),回滚只作用于 `<data>/agentfs` overlay 的 upper,**二者对象错位**。
|
||||||
|
- `AgregateResult` 在无 LLM endpoint 时恒 healthy,机制形同虚设。
|
||||||
|
- `lastHB` 每轮都置 `time.Now()`,`Uptime` 无意义。
|
||||||
|
- `RollbackPolicy` 的 `HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`。
|
||||||
|
|
||||||
|
### 1.2 文件追踪(`internal/tracker`)
|
||||||
|
|
||||||
|
- overlayfs 三层:`lower/upper/work → merged`(`tracker.go:156`)。
|
||||||
|
- `captureFSState(upperDir)` 递归遍历 upper 并 sha256(`changeset.go:56`);`PreAction/PostAction` 前后 diff(`toolcall.go:86-94`)。
|
||||||
|
- **lower 恒空**(`Init` 只 `MkdirAll`,从不填充)→ 无 canonical 基线可回滚。
|
||||||
|
- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets;`FileChange.Content`(本应存回滚原文)**从未回填**。
|
||||||
|
|
||||||
|
结论:overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。
|
||||||
|
|
||||||
|
### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`)
|
||||||
|
|
||||||
|
- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**:
|
||||||
|
`qq.napcat_url`、`qq.listen`、`qq.files_dir`、`qq.remote_dir`、`dm/group_policy`(`plugin.go:120-130`)。
|
||||||
|
- 插件在 `Start()` 里 `getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**。
|
||||||
|
- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。
|
||||||
|
|
||||||
|
### 1.4 LLM 源与"恢复即生效"
|
||||||
|
|
||||||
|
`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**:
|
||||||
|
- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`)
|
||||||
|
- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。
|
||||||
|
|
||||||
|
即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现实环境:日志记录模式内参
|
||||||
|
|
||||||
|
> 看真实 systemd 托管的 HomeAgent(`/home/newqqagent`)日志,**目的是弄清现有的日志模型**
|
||||||
|
> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。
|
||||||
|
|
||||||
|
### 2.1 systemd 托管现状(样例)
|
||||||
|
|
||||||
|
```
|
||||||
|
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
|
||||||
|
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
|
||||||
|
```
|
||||||
|
|
||||||
|
- 实测数据区:`/home/newqqagent/` 下有 `log/`、`config.db`、`agentfs/`(overlay merged)、`snapshots/`、`changesets/`、`knowledge/`、`memory/`、`plugins/`、
|
||||||
|
`cli.sock`、`adapters/`、`homed.log`、`memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。
|
||||||
|
- 启动段确认:`[files] started, sandbox: /`(**files 沙箱=全主机 `/` 实锤**);`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`(LLM 走本地 mock)。
|
||||||
|
|
||||||
|
### 2.2 日志目录与格式(核心)
|
||||||
|
|
||||||
|
- **目录配置**:`core.log.path`,默认 `<dataDir>/log`(`internal/config/registry.go:415,508`)。
|
||||||
|
- **单次运行文件**:每次启动新建 `homed_<YYYY-MM-DD_HH-MM-SS>.log`(`cmd/homed/main.go:75`),
|
||||||
|
写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))`(`main.go:80`)——
|
||||||
|
**同时进 stderr(systemd 捕获到 journald/`journalctl -u`)与文件**。
|
||||||
|
- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`。
|
||||||
|
用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。
|
||||||
|
- **层级压缩 + 保留**(`internal/log/manager.go:26-28` + `compressor.go`):
|
||||||
|
- 周度压缩 → `week_<year>-W<ww>.tar.gz`;月度 → `month_<yyyy-mm>.tar.gz`;年度 `year_*.tar.gz`;
|
||||||
|
raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$`(`compressor.go:16`)。
|
||||||
|
- 保留策略:`core.log.retention`(default forever)、`core.log.retention_months`(default 3),
|
||||||
|
`applyRetention` 只留当前周 + 近 N 月(`retention.go`)。
|
||||||
|
实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。
|
||||||
|
|
||||||
|
### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`)
|
||||||
|
|
||||||
|
| 位置 | 日志行内容 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 |
|
||||||
|
| `process.go:196` | `[agent] executing tool: <name> (plugin=<p>, id=<id>)` | **只记工具名/插件/id,不记 args** |
|
||||||
|
| `process.go:226` | `[agent] tool <name> result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`)|
|
||||||
|
| `process.go:218` | `[agent] skip tool <name>: plugin <name> unhealthy` | 插件崩溃态跳过 |
|
||||||
|
| `process.go:89/109/121/125` | LLM fallback:`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 |
|
||||||
|
| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 |
|
||||||
|
| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 |
|
||||||
|
| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 |
|
||||||
|
| 插件侧 | Lua 插件 `sdk.log` → `print("[lua-plugin] <level>: <msg>")` | 模板见 `cmd_debug.go:74`/`templates.go` |
|
||||||
|
|
||||||
|
- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall`(`{tool, plugin, args, result, status}`,`process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。
|
||||||
|
- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里),
|
||||||
|
需要的话由 `diag_log_scan` 对 `executing tool: cmd_*` 前缀做签名匹配即可。
|
||||||
|
|
||||||
|
### 2.4 崩溃 / 重启观察
|
||||||
|
|
||||||
|
- `NRestarts=0`;MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**(pid 3310036):
|
||||||
|
`[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`(03:23 / 05:23 / 07:23,约每 2h),
|
||||||
|
被 `stages.go:139` 的 `RunStage` recover 吞掉 → **进程未真崩**,systemd 未见重启。
|
||||||
|
- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。
|
||||||
|
- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督;
|
||||||
|
且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环,
|
||||||
|
systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 设计演进(讨论全过程)
|
||||||
|
|
||||||
|
### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪"
|
||||||
|
|
||||||
|
- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。
|
||||||
|
- 意图:LLM 不可达 → 回滚 agent 文件改动 → 自愈。
|
||||||
|
|
||||||
|
### 3.1 第一次纠正:回滚对象错位
|
||||||
|
|
||||||
|
- 回滚只作用于 overlayfs upper;而真正能改坏网络的路径(files 默认 `/`,可写 `/etc/resolv.conf`、`/etc/hosts`、代理配置)与 LLM 配置(config.db、adapters 目录)**都不在 overlay 内**。
|
||||||
|
- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。
|
||||||
|
|
||||||
|
### 3.2 第二次纠正:授权不能靠外部插件主动配合
|
||||||
|
|
||||||
|
- 阶段管道是被动通知(`RunStage` 收集 error,不主动拒绝),拒绝依赖各插件 handler。
|
||||||
|
- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。
|
||||||
|
- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。
|
||||||
|
- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置;IO 组件崩溃是正交偶发轴,不并进来。
|
||||||
|
|
||||||
|
### 3.3 第三次纠正:LLM 源才是核心(config.db + ReloadFromConfig 已具钩子)
|
||||||
|
|
||||||
|
- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`。
|
||||||
|
- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测(QuickChat)+ 在 handleFailure 里触发 RFO 复检**。
|
||||||
|
- 但用户进一步点明:**重点是 agent 改了系统网络配置文件(proxy/DNS)** —— 这类连救援 LLM 都连不上。
|
||||||
|
|
||||||
|
### 3.4 定稿架构一:lastFailback(独立进程,第一道防线)
|
||||||
|
|
||||||
|
- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd)+ 单一任务。
|
||||||
|
- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。
|
||||||
|
- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。
|
||||||
|
- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。
|
||||||
|
|
||||||
|
### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程
|
||||||
|
|
||||||
|
- systemd 只绑定**静态、极小、永驻的 guard**;guard 作为父进程拉起 worker(`homed` 全量 agent)。
|
||||||
|
- guard 通过 IPC(unix socket / 匿名 pipe)收 worker 心跳;
|
||||||
|
父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。
|
||||||
|
- worker 可崩可换,systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**。
|
||||||
|
- guard 抗 OOM:`oom_score_adj` 拉低,优先杀 worker 而非 guard。
|
||||||
|
|
||||||
|
### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role,非独立二进制
|
||||||
|
|
||||||
|
- **不新建二进制**。`homed` 拆两个入口:
|
||||||
|
- `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。
|
||||||
|
- `homed --role=agent`:主 agent(工作进程,guard 的子进程)。
|
||||||
|
- `homed --role=agent --boot=failback`:恢复用 agent(受限 bootstrap)。
|
||||||
|
- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent,只加载
|
||||||
|
**webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件** 与 **常见错误检测插件**,
|
||||||
|
复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。
|
||||||
|
- guard 配置**独立 YAML**,不复用 config.db(逃生舱知识必须脱离 agent 可达区)。
|
||||||
|
|
||||||
|
### 3.7 recoveryDiag:崩溃取证 / 根因定位插件(省 token 关键)
|
||||||
|
|
||||||
|
- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。
|
||||||
|
- 工具集:
|
||||||
|
| 工具 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" |
|
||||||
|
| `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail |
|
||||||
|
| `diag_log_scan` | 时间窗内命中已知错误签名(panic/provider failed/unreachable/sql/OOM)|
|
||||||
|
| `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff("改了什么")|
|
||||||
|
| `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 |
|
||||||
|
|
||||||
|
- 崩溃类别 → 恢复分支决策表:
|
||||||
|
| 结论类 | 走分支 |
|
||||||
|
|---|---|
|
||||||
|
| 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent(无需 agent 推理)|
|
||||||
|
| 系统网络类 | 还原 DNS/proxy → 重载主 agent(第一步小修命中即停)|
|
||||||
|
| 进程失稳类(OOM/panic)| 不还原配置,检查内存/泄漏 → 重建 worker |
|
||||||
|
| 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 |
|
||||||
|
|
||||||
|
- 只有"未知/混合"消耗 token,前几类近乎 0 token。
|
||||||
|
- 结论落盘 `recovery_kb/diag_<ts>.json`,回流知识库,同类崩溃下次直接命中,越用越省。
|
||||||
|
|
||||||
|
### 3.8 三层防御总览(最终)
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 平时:核心只读探活 + 写前快照(config_set 写 core.llm.* 前、files 写 /etc 前自动留档)
|
||||||
|
L1 failback(guard 拉起,Safe-Mode 式兜底):按诊断分支逐类恢复——
|
||||||
|
还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent
|
||||||
|
L2 最后手段:agentfs 回滚最近快照 + 重启(应用快照前系统配置)
|
||||||
|
```
|
||||||
|
|
||||||
|
- failback 是**所有错误(LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**,
|
||||||
|
并非只针对某一条;`diag_*` 决定它走哪条恢复路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 最终架构(定稿)
|
||||||
|
|
||||||
|
### 4.1 进程拓扑(同一二进制,两个 role)
|
||||||
|
|
||||||
|
```
|
||||||
|
systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小
|
||||||
|
├─ exec ──▶ homed --role=agent # 主 agent(可崩)
|
||||||
|
└─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent
|
||||||
|
```
|
||||||
|
|
||||||
|
- guard:先起,持有恢复知识(锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。
|
||||||
|
- worker:guard 子进程,心跳经 IPC,崩溃由 guard reap + 判型。
|
||||||
|
- failback agent = 受限启动(webfetch+files+cmd + 恢复知识库 + recoveryDiag),单一任务"恢复主 agent",N 轮有界。
|
||||||
|
|
||||||
|
### 4.2 guard 独立 YAML 示例
|
||||||
|
|
||||||
|
> 现状实现(§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
role: guard
|
||||||
|
|
||||||
|
llm:
|
||||||
|
sources:
|
||||||
|
- name: rescue
|
||||||
|
base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理
|
||||||
|
api_key: ${GUARD_RESCUE_KEY}
|
||||||
|
adapter: ... # 锚定/SNI 型适配器
|
||||||
|
|
||||||
|
recovery:
|
||||||
|
max_attempts: 4 # 可配置尝试轮次
|
||||||
|
attempt_timeout: 120s
|
||||||
|
knowledge_base: /opt/homeagent/recovery/
|
||||||
|
trigger_prompt: "你是恢复 agent,唯一任务:让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…"
|
||||||
|
plugins: [webfetch, files, cmd]
|
||||||
|
|
||||||
|
last_resort:
|
||||||
|
action: reboot # restart_app | reboot
|
||||||
|
snapshot_before: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 guard 恢复状态机(N 轮有界)
|
||||||
|
|
||||||
|
```
|
||||||
|
guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 )
|
||||||
|
1. 固化追溯:exit/信号、panic、journal、OOM 上下文 → 永久区
|
||||||
|
2. 拉起 failback agent(受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag)
|
||||||
|
for attempt in 1..N:
|
||||||
|
(可选先 diag_triage/diag_loc 判型)
|
||||||
|
failback 尝试恢复
|
||||||
|
guard 每轮复检主 agent 是否可达/存活
|
||||||
|
├─ 成功 → 结束,交回主 agent
|
||||||
|
└─ 超时/失败 → kill 重建,进入下一轮
|
||||||
|
3. N 轮未成 → 取消 failback agent
|
||||||
|
→ agentfs 回滚崩溃前最近快照
|
||||||
|
→ 依 yaml 执行最后手段:restart_app 或 reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 systemd 绑定(极简,杜绝风暴)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit] # guard
|
||||||
|
OnFailure=... # 备用,通常不触发(guard 稳定)
|
||||||
|
|
||||||
|
[Service] # guard
|
||||||
|
Restart=always # guard 静态稳定 → 几乎不重启
|
||||||
|
ExecStart=/usr/local/bin/homed --role=guard ...
|
||||||
|
# No StartLimit needed for loop 情况;guard 不崩
|
||||||
|
```
|
||||||
|
|
||||||
|
- 主 agent 崩 → 只触发 guard 内部 failback;systemd 仅看 guard,看不到 worker 崩溃循环。
|
||||||
|
- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。
|
||||||
|
- guard 抗 OOM:`oom_score_adj` 拉低。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 尚未落地的接口 / 下一步
|
||||||
|
|
||||||
|
**已完成**:
|
||||||
|
|
||||||
|
`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。
|
||||||
|
- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`(config.db integrity_check + LLM 源字段校验,sqlite3 CLI 优先、缺失回退内核 Settings)、`diag_log_scan`(日志签名按类计数)、`diag_delta`(baseline vs 现状 diff)、`diag_loc`(四项结论正交排序 + 推荐恢复动作)。
|
||||||
|
- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`。
|
||||||
|
- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通:3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal),并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。
|
||||||
|
- **结论落盘 + 知识库回流**:`diag_loc` 增 `persist`(缺省 true)→ 写 `<data_dir>/recovery_kb/diag_<ts>.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add` 以 `diag:<cause>:<ts>` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`。
|
||||||
|
- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev`,`go build -o ... .`。
|
||||||
|
- 注意:本环境 `snapshots/`、`changesets/` 均为空(direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir;未来接 guard 时由快照解包目录提供。
|
||||||
|
|
||||||
|
`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。
|
||||||
|
- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。
|
||||||
|
- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件(guard 恢复的外部基线);homed 启动即挂 `<data>/llm_snapshot.json`。
|
||||||
|
- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM`、`TestLLMSnapshotFile`、`TestSetLLMSnapshotFile`。
|
||||||
|
|
||||||
|
`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。
|
||||||
|
- `--role=guard` 父守护(永驻):读独立 `<data>/guard.yaml`(避开被改坏的 config.db),拉起 worker(`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。
|
||||||
|
- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。
|
||||||
|
- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`(restart_app / reboot)。
|
||||||
|
- 心跳:worker 每 5s 触碰 `<data>/heartbeat`(agent 角色 goroutine),guard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。
|
||||||
|
- 插件注册表加 `SetLoadAllowlist(names)`:白名单外插件(含已注册工厂)一律跳过,failback 40 工具 → 4 工具实测通过。
|
||||||
|
|
||||||
|
现状 bug 修复(supervisor/network/tracker)。
|
||||||
|
|
||||||
|
- **monitor 无 endpoint 恒 healthy**(`internal/network/monitor.go` + `pkg/types`):`NetworkCheckResult` 增 `EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error),`NewMonitor` 初始化空切片消除启动竞态;daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url),生产从此健康检查有真实目标。
|
||||||
|
- **lastHB 恒置 now**(`internal/supervisor/daemon.go`):`checkAgent` 接入真实存活源 `SetHeartbeatSource`(homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。
|
||||||
|
- **restartAgent 只改内存空转**:增 `SetRestartHandler`(homed 注册为"清理后以 `exitRestartRequested=42` 退出"),不再假装成功;guard 把 42 识别为"请求重建"(`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。
|
||||||
|
- **tracker 三缺陷**(`internal/tracker/`):`captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB)→ `diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset;`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。
|
||||||
|
|
||||||
|
剩余:
|
||||||
|
|
||||||
|
- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。
|
||||||
|
- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。
|
||||||
|
- 生产实例迁移:编译新 homed、改 systemd 只托管 guard(`--role=guard`),确认 failback 插件(recoverydiag)就位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:真实日志节选(systemd 托管示例,`/home/newqqagent`)
|
||||||
|
|
||||||
|
```
|
||||||
|
# systemd unit
|
||||||
|
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
|
||||||
|
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
|
||||||
|
|
||||||
|
# 日志文件与格式(log.Printf 标准格式)
|
||||||
|
2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log
|
||||||
|
2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully
|
||||||
|
2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs)
|
||||||
|
|
||||||
|
# 工具调用摘要(process.go)
|
||||||
|
2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589
|
||||||
|
... process.go:196: [agent] executing tool: <name> (plugin=<p>, id=<id>)
|
||||||
|
... process.go:226: [agent] tool <name> result: <截断100字符>
|
||||||
|
|
||||||
|
# 启动 & 沙箱
|
||||||
|
homed[3330844]: [files] started, sandbox: /
|
||||||
|
homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8
|
||||||
|
|
||||||
|
# 重复 in-process panic(被 RunStage recover 吞掉,未进程级崩溃)
|
||||||
|
homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23
|
||||||
|
|
||||||
|
# 一次性干净重启(systemd 手动/触发 Started,pid 3310036 → 3330844)
|
||||||
|
homed[3310036]: [homed] stopped
|
||||||
|
systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler.
|
||||||
|
systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler.
|
||||||
|
|
||||||
|
# 运行状态
|
||||||
|
systemctl show homeagent.service -p NRestarts → 0
|
||||||
|
systemctl show homeagent.service -p MainPID → 3330844(自 08-03 起稳定)
|
||||||
|
|
||||||
|
# 归档
|
||||||
|
/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz
|
||||||
|
```
|
||||||
@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 构建 Windows 安装器(NSIS)。
|
|
||||||
#
|
|
||||||
# ❗安装器**不往 Windows 装 homed**:homed 依赖 fd 继承与统一共享内存区的段内偏移
|
|
||||||
# 解引用,Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go)。所以安装器的
|
|
||||||
# 职责是**引导 WSL2,并把 Linux 包送进发行版里按 Linux 方式安装**
|
|
||||||
# (deploy/packaging/windows/install-via-wsl.ps1)。
|
|
||||||
#
|
|
||||||
# 用法: VERSION=1.3.10 bash deploy/packaging/package-windows.sh <server|client|full> [arch]
|
|
||||||
# 前置: 先产出对应的 Linux 包(VERSION=x bash deploy/packaging/package-linux.sh amd64)
|
|
||||||
#
|
|
||||||
# 为什么不复用 build.sh 的 stage_linux_payload:那一段把 dist/linux 下**所有** deb+tar
|
|
||||||
# 都塞进 payload,而 server/full 的 deb 各带 ~719MB 的 Chinese-CLIP 模型 ⇒ 任何变体的
|
|
||||||
# 安装器都会膨胀到 ~2.4GB。WSL 侧脚本只取 payload 里的**第一个** .deb
|
|
||||||
# (install-via-wsl.ps1:141),所以这里按变体只放对应的那一个包。
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
||||||
# 三个目录都可覆盖:发布件常在 tag 的干净 worktree 里构建,而这个脚本本身
|
|
||||||
# 可能只存在于 main(例如刚补的驱动脚本还没进 tag)——那种情况下用主仓的脚本 +
|
|
||||||
# DIST_LINUX/BUILD_DIR/DIST_RELEASE 指向 worktree,避免"脚本不存在"或产物错位。
|
|
||||||
BUILD_DIR="${BUILD_DIR:-$PROJECT_ROOT/build}"
|
|
||||||
DIST_LINUX="${DIST_LINUX:-$PROJECT_ROOT/dist/linux}"
|
|
||||||
DIST_RELEASE="${DIST_RELEASE:-$PROJECT_ROOT/dist/release}"
|
|
||||||
# ❗NSIS 的 `File` 路径是**相对 .nsi 所在目录**解析的:在 tag 的 worktree 里构建时,
|
|
||||||
# 必须用**该 tag 里的** installer.nsi,否则它会去主仓的 build/linux-payload 找载荷
|
|
||||||
# (实测报 `File: "..\..\build\linux-payload\*.*" -> no files found`)。
|
|
||||||
# 用 tag 里的 .nsi 也正是"发布件与当时的脚本同源"的正确做法。
|
|
||||||
NSI="${NSI:-$PROJECT_ROOT/deploy/packaging/installer.nsi}"
|
|
||||||
if [ ! -f "$NSI" ]; then
|
|
||||||
echo "[FAIL] 找不到 NSIS 脚本: $NSI" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VARIANT="${1:-server}"
|
|
||||||
ARCH="${2:-amd64}"
|
|
||||||
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags 2>/dev/null || echo 0.0.0)}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
|
|
||||||
case "$VARIANT" in
|
|
||||||
server) DEB_GLOB="homeagent-server_${VERSION}_${ARCH}.deb"; SUFFIX="Server"; WANT_GUI=0; WANT_WAITER=0 ;;
|
|
||||||
full) DEB_GLOB="homeagent-full_${VERSION}_${ARCH}.deb"; SUFFIX="Full"; WANT_GUI=1; WANT_WAITER=1 ;;
|
|
||||||
client) DEB_GLOB="homeagent-client_${VERSION}_${ARCH}.deb"; SUFFIX="Client"; WANT_GUI=1; WANT_WAITER=1 ;;
|
|
||||||
*) echo "用法: $0 <server|client|full> [arch]" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
DEB="$(ls -1 "$DIST_LINUX/deb/$DEB_GLOB" "$DIST_LINUX/$DEB_GLOB" 2>/dev/null | head -1 || true)"
|
|
||||||
if [ -z "$DEB" ]; then
|
|
||||||
echo "[FAIL] 找不到 $DEB_GLOB" >&2
|
|
||||||
echo " 先产出 Linux 包:VERSION=$VERSION bash deploy/packaging/package-linux.sh $ARCH" >&2
|
|
||||||
echo " (安装器的作用是把 Linux 包送进 WSL2,所以必须先有 Linux 包)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# client/full 还要带 Windows GUI(HAS_GUI=1)。本机缺 electron-builder,若 build/ 下
|
|
||||||
# 没有可用的 win32-x64 payload 就**明确失败**,不产出"装完没有界面"的半残包。
|
|
||||||
if [ "$WANT_GUI" = 1 ]; then
|
|
||||||
if [ -z "$(ls -1 "$BUILD_DIR"/homeagent-gui-win32-x64/*.exe 2>/dev/null | head -1 || true)" ]; then
|
|
||||||
echo "[FAIL] 变体 $VARIANT 需要 Windows GUI payload(build/homeagent-gui-win32-x64/*.exe)" >&2
|
|
||||||
echo " 本机无 electron-builder:npm i -g electron-builder &&" >&2
|
|
||||||
echo " bash deploy/packaging/build.sh windows/amd64 gui" >&2
|
|
||||||
echo " (只装内核+CLI 的 WSL 场景请用 server 变体)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$WANT_WAITER" = 1 ]; then
|
|
||||||
echo "[BUILD] waiter.exe(Windows 侧 CLI;CGO 关闭,跨平台安全)"
|
|
||||||
( cd "$PROJECT_ROOT" && GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \
|
|
||||||
go build -buildvcs=false -trimpath -o "$BUILD_DIR/waiter.exe" ./cmd/waiter )
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- 变体定向 payload:只放本变体那一个 Linux 包 ----
|
|
||||||
rm -rf "$BUILD_DIR/linux-payload"
|
|
||||||
mkdir -p "$BUILD_DIR/linux-payload"
|
|
||||||
cp "$DEB" "$BUILD_DIR/linux-payload/"
|
|
||||||
echo "[STAGE] payload ← $(basename "$DEB")($(du -h "$DEB" | cut -f1))"
|
|
||||||
|
|
||||||
if [ -z "$(ls -1 "$BUILD_DIR/linux-payload" 2>/dev/null | head -1 || true)" ]; then
|
|
||||||
echo "[FAIL] payload 为空:$BUILD_DIR/linux-payload" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[BUILD] makensis -DVARIANT=$VARIANT -DPRODUCT_VERSION=$VERSION(nsi: $NSI)"
|
|
||||||
makensis -V2 -DVARIANT="$VARIANT" -DPRODUCT_VERSION="$VERSION" "$NSI"
|
|
||||||
|
|
||||||
OUT="$BUILD_DIR/HomeAgent_v${VERSION}_${SUFFIX}_win64.exe"
|
|
||||||
if [ ! -f "$OUT" ]; then
|
|
||||||
echo "[FAIL] 未找到产物 $OUT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p "$DIST_RELEASE"
|
|
||||||
cp "$OUT" "$DIST_RELEASE/"
|
|
||||||
echo "[OK] $(basename "$OUT")($(du -h "$OUT" | cut -f1))→ $DIST_RELEASE/"
|
|
||||||
echo " 它会在 Windows 侧引导 WSL2,并把 $(basename "$DEB") 送进去安装(homed 跑在 WSL 里)。"
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# 客户端版本与内核版本同步。
|
|
||||||
#
|
|
||||||
# 为什么要有这个脚本:内核的 internal/meta/meta.go Version 是唯一事实源,
|
|
||||||
# 而各客户端各有各的版本字段——GUI 在 package.json、鸿蒙在 AppScope/app.json5、
|
|
||||||
# waiter 走编译期注入。手工各改各的必然漂移(写这个脚本时的现状:内核 1.4.0、
|
|
||||||
# GUI 1.0.0、鸿蒙 1.1.1,三个号互不相干)。
|
|
||||||
#
|
|
||||||
# 用法:
|
|
||||||
# bash deploy/scripts/sync-client-versions.sh # 同步到内核当前版本
|
|
||||||
# bash deploy/scripts/sync-client-versions.sh 1.4.0 # 同步到指定版本
|
|
||||||
# bash deploy/scripts/sync-client-versions.sh --check # 只校验,漂移则退出 1
|
|
||||||
#
|
|
||||||
# 同步目标:
|
|
||||||
# cmd/gui/package.json version
|
|
||||||
# cmd/ohos/HomeAgent/AppScope/app.json5 versionName + versionCode
|
|
||||||
#
|
|
||||||
# waiter 不在此列:它直接引用 internal/meta.Version(同一进程内编译),
|
|
||||||
# 没有第二份版本字段可漂。
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
||||||
|
|
||||||
CHECK=0
|
|
||||||
VERSION=""
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--check) CHECK=1 ;;
|
|
||||||
*) VERSION="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# 未显式给版本时,从内核唯一事实源读。
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
VERSION="$(grep -oE 'Version = "[^"]+"' "$ROOT/internal/meta/meta.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
|
|
||||||
fi
|
|
||||||
[ -n "$VERSION" ] || { echo "sync-client-versions: 无法确定版本号(internal/meta/meta.go 里没找到 Version)" >&2; exit 1; }
|
|
||||||
|
|
||||||
# versionCode 规则:X*1e6 + Y*1e3 + Z。鸿蒙要求 versionCode 单调递增的整数,
|
|
||||||
# 直接搬 semver 会丢信息,所以用主/次/补丁三段编码(1.4.0 → 1004000)。
|
|
||||||
CODE="$(python3 - "$VERSION" <<'PY'
|
|
||||||
import re, sys
|
|
||||||
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', sys.argv[1])
|
|
||||||
if not m:
|
|
||||||
sys.exit("sync-client-versions: 版本号必须是 X.Y.Z 形态,得到 %r" % sys.argv[1])
|
|
||||||
print(int(m.group(1)) * 1000000 + int(m.group(2)) * 1000 + int(m.group(3)))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
|
|
||||||
GUI_PKG="$ROOT/cmd/gui/package.json"
|
|
||||||
OHOS_APP="$ROOT/cmd/ohos/HomeAgent/AppScope/app.json5"
|
|
||||||
|
|
||||||
DRIFT=0
|
|
||||||
note() { printf ' %-52s %s\n' "$1" "$2"; }
|
|
||||||
|
|
||||||
# ── GUI ──
|
|
||||||
gui_cur="$(python3 - "$GUI_PKG" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
print(json.load(open(sys.argv[1]))["version"])
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
if [ "$gui_cur" != "$VERSION" ]; then
|
|
||||||
DRIFT=1
|
|
||||||
if [ "$CHECK" -eq 1 ]; then
|
|
||||||
note "cmd/gui/package.json" "$gui_cur → 应为 $VERSION"
|
|
||||||
else
|
|
||||||
python3 - "$GUI_PKG" "$VERSION" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
p, v = sys.argv[1], sys.argv[2]
|
|
||||||
d = json.load(open(p))
|
|
||||||
d["version"] = v
|
|
||||||
# indent=2 保留原格式;末尾补换行,避免 diff 噪声
|
|
||||||
with open(p, "w") as f:
|
|
||||||
json.dump(d, f, indent=2, ensure_ascii=False)
|
|
||||||
f.write("\n")
|
|
||||||
PY
|
|
||||||
note "cmd/gui/package.json" "$gui_cur → $VERSION"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 鸿蒙 ──
|
|
||||||
ohos_name="$(grep -oE '"versionName"[[:space:]]*:[[:space:]]*"[^"]+"' "$OHOS_APP" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
|
|
||||||
ohos_code="$(grep -oE '"versionCode"[[:space:]]*:[[:space:]]*[0-9]+' "$OHOS_APP" | head -1 | grep -oE '[0-9]+$')"
|
|
||||||
if [ "$ohos_name" != "$VERSION" ] || [ "$ohos_code" != "$CODE" ]; then
|
|
||||||
DRIFT=1
|
|
||||||
if [ "$CHECK" -eq 1 ]; then
|
|
||||||
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → 应为 $VERSION/$CODE"
|
|
||||||
else
|
|
||||||
# app.json5 带注释,不是严格 JSON,用 sed 定点替换两个字段。
|
|
||||||
sed -i -E "s/(\"versionCode\"[[:space:]]*:[[:space:]]*)[0-9]+/\1$CODE/" "$OHOS_APP"
|
|
||||||
sed -i -E "s/(\"versionName\"[[:space:]]*:[[:space:]]*\")[^\"]+/\1$VERSION/" "$OHOS_APP"
|
|
||||||
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → $VERSION/$CODE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "内核版本: $VERSION (versionCode $CODE)"
|
|
||||||
if [ "$CHECK" -eq 1 ]; then
|
|
||||||
if [ "$DRIFT" -eq 1 ]; then
|
|
||||||
echo "sync-client-versions: 客户端版本与内核不一致(见上);跑 bash deploy/scripts/sync-client-versions.sh 同步" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "sync-client-versions: OK,客户端与内核版本一致"
|
|
||||||
fi
|
|
||||||
@ -32,17 +32,13 @@ ARTIFACT_SUFFIXES = (
|
|||||||
".rpm",
|
".rpm",
|
||||||
".pkg",
|
".pkg",
|
||||||
"_win64.exe",
|
"_win64.exe",
|
||||||
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
|
|
||||||
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh)。
|
|
||||||
".hmap",
|
|
||||||
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
|
|
||||||
"SHA256SUMS.plugins",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_artifact(name: str) -> bool:
|
def is_artifact(name: str) -> bool:
|
||||||
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
|
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
|
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
|
||||||
q = urllib.parse.urlencode({"file_name": filename})
|
q = urllib.parse.urlencode({"file_name": filename})
|
||||||
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
|
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
|
||||||
|
|||||||
124
docs/embedding-comparison.md
Normal file
124
docs/embedding-comparison.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# 检索方案对比报告(2026-09-09)
|
||||||
|
## 测试数据
|
||||||
|
- 文档库:492 篇生产文档(过滤 108 条健康检查测试文档)
|
||||||
|
- 媒体库:3 张生产图片(验证码、新闻截图、深色模式备忘录)
|
||||||
|
- 文本查询:10 组(精确匹配、语义、跨语言、模糊表达)
|
||||||
|
- 媒体查询:6 组(中文/英文查图片,3 张图片各 2 条)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、文本检索对比(文档库)
|
||||||
|
|
||||||
|
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|
||||||
|
|------|-------|-------|-----|----------|
|
||||||
|
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
|
||||||
|
| fastText(200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
|
||||||
|
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
|
||||||
|
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
|
||||||
|
|
||||||
|
### 关键发现
|
||||||
|
|
||||||
|
1. **Jina 的优势来自"短语语义"能力**:
|
||||||
|
- "邮件代理是否已经成功接入" → TF-IDF rank 5,Jina rank 1
|
||||||
|
- "升级安装 QQ 插件包" → fastText rank 169,Jina rank 1(margin +0.30)
|
||||||
|
- "我所在城市的天气预报" → fastText rank 44,Jina rank 1
|
||||||
|
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1,Jina rank 1(margin +0.33)
|
||||||
|
|
||||||
|
2. **TF-IDF 在精确匹配上不可替代**:
|
||||||
|
- "长期文档记忆功能是否健康" → TF-IDF rank 3,Jina rank 1
|
||||||
|
- "重新加载全部扩展组件" → TF-IDF rank 0(完全未命中),Jina rank 2
|
||||||
|
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
|
||||||
|
|
||||||
|
3. **RRF 融合反而变差**:
|
||||||
|
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
|
||||||
|
- 原因:两种稀疏向量的排序在语义查询上高度重叠,RRF 无法弥补各自短板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、图片检索对比(同 3 张图片,6 条查询)
|
||||||
|
|
||||||
|
| 方案 | Hit@1 | MRR | 平均 margin |
|
||||||
|
|------|-------|-----|-------------|
|
||||||
|
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
|
||||||
|
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
|
||||||
|
|
||||||
|
### 逐条对比
|
||||||
|
|
||||||
|
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|
||||||
|
|------|-----------|-------------|-----------|-------------|
|
||||||
|
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
|
||||||
|
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
|
||||||
|
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
|
||||||
|
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
|
||||||
|
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
|
||||||
|
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
|
||||||
|
|
||||||
|
### 关键发现
|
||||||
|
|
||||||
|
1. **中文文本→图片**:Jina 明显优于 CLIP(MRR 0.833 vs 0.611)
|
||||||
|
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
|
||||||
|
- Jina 最差也是 +0.045,正样本始终高于负样本
|
||||||
|
|
||||||
|
2. **新闻截图是共同弱点**:
|
||||||
|
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
|
||||||
|
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
|
||||||
|
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
|
||||||
|
|
||||||
|
3. **margin 的实际意义**:
|
||||||
|
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
|
||||||
|
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
|
||||||
|
- 但两者的 margin 都很小(< 0.1),生产环境仍需阈值校准
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、延迟与资源
|
||||||
|
|
||||||
|
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|
||||||
|
|------|-------------|----------|------|
|
||||||
|
| TF-IDF | 0.3ms | <1s | ~50MB |
|
||||||
|
| fastText | 8.3ms | <1s | ~200MB |
|
||||||
|
| CLIP ONNX | 26ms | N/A | ~600MB |
|
||||||
|
| Jina v5-omni CPU | 39.9ms | 78s(492篇) | ~4GB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、结论与建议
|
||||||
|
|
||||||
|
### 核心判断
|
||||||
|
|
||||||
|
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|
||||||
|
|------|-----------------|------|--------------|
|
||||||
|
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
|
||||||
|
| 文本语义检索 | ★★ | N/A | ★★★★★ |
|
||||||
|
| 中文文本→图片 | 无能力 | ★ | ★★★★ |
|
||||||
|
| 英文文本→图片 | 无能力 | ★★★ | ★★★★ |
|
||||||
|
| 图片→图片 | 无能力 | ★★★ | ★★★★ |
|
||||||
|
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
|
||||||
|
| 延迟 | ★★★★★ | ★★★ | ★★ |
|
||||||
|
|
||||||
|
### 架构建议
|
||||||
|
|
||||||
|
1. **保留 TF-IDF 作为精确召回的一级通道**:
|
||||||
|
- 0.3ms 延迟不可替代
|
||||||
|
- Hit@5 70% 证明在关键词匹配场景仍有价值
|
||||||
|
- 特别是"插件安装"、"设备查询"这类精确操作指令
|
||||||
|
|
||||||
|
2. **用 Jina 替换 fastText + CLIP 的稠密通道**:
|
||||||
|
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
|
||||||
|
- 统一空间消除三条通道的维护成本
|
||||||
|
- 中文文本→图片从"无法检索"提升到"可检索"
|
||||||
|
|
||||||
|
3. **两路融合:TF-IDF + Jina RRF**(而非 TF-IDF + fastText RRF):
|
||||||
|
- TF-IDF 精确匹配 + Jina 语义覆盖
|
||||||
|
- RRF 避免跨空间分数归一化问题
|
||||||
|
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
|
||||||
|
|
||||||
|
4. **图片检索仍需阈值校准**:
|
||||||
|
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
|
||||||
|
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
|
||||||
|
- 用更多生产图片标定 Jina 的图片检索阈值
|
||||||
|
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代(L0 相关性计算)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Git 分支管理规范
|
# Git 分支管理规范
|
||||||
|
|
||||||
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七),2026-09-13 修订(发布线路牌随 patch 推进 + 版本号不得固化落库)。
|
> 生效:2026-08-31,2026-09-04 修订(三级发布通道 + 单条发布分支),2026-09-06 修订(SDK 仓版本语义与发版联动,见 §七)。
|
||||||
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
> 适用:**本仓(TrueAgent/HomeAgent)与 third_party/homeagent-sdk(SDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
|
||||||
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;一个中版本一条发布分支,alpha/beta/正式由 tag 区分;hotfix 只进发布分支并 cherry-pick 回 main。**
|
||||||
|
|
||||||
@ -63,11 +63,6 @@ main ──────────────── E ────────
|
|||||||
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
|
||||||
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.0.x main`。
|
- **从 main 的某个可部署点切出**:`git checkout -b release/v1.0.x main`。
|
||||||
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
|
||||||
- **发布线的 `meta.Version` 必须跟着该线已发的最后一个 patch 走**(`release/v1.1.x` 末态
|
|
||||||
`1.1.1`、`release/v1.2.x` 末态 `1.2.2`、`release/v1.3.x` 末态 `1.3.6`)。
|
|
||||||
⚠️ **不要只用构建参数(`-ldflags -X ...meta.Version`)打版本号而不改源码**:
|
|
||||||
二进制自称 1.3.4、源码路牌还停在 1.3.0,追溯时对不上账(2026-09-13 真实踩过,
|
|
||||||
1.3.1–1.3.4 四个 patch 都是这么打的,`release/v1.3.x` 的路牌一直没动)。
|
|
||||||
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
|
||||||
|
|
||||||
### 4. 三级发布通道(alpha / beta / 正式)
|
### 4. 三级发布通道(alpha / beta / 正式)
|
||||||
@ -167,50 +162,36 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
|||||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
||||||
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
||||||
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
||||||
| 人格文本在**播种时**就把 `meta.Version` 插值写进配置库 | 装机那天即冻住版本号:内核 1.3.x 的实例仍向用户自称 `v1.0.3`(2026-09-13 用户当场发现) |
|
|
||||||
| 发布线只用 `-ldflags -X` 打版本、源码 `meta.Version` 不动 | 二进制自称 1.3.4、源码路牌仍是 1.3.0,溯源对不上账;同类还有给 SDK 误发 patch tag(§七.1 要求 patch 位恒为 `.0`) |
|
|
||||||
|
|
||||||
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
||||||
要么**在渲染时**用 `meta.Version` 插值(系统提示词占位符 `{{kernel_version}}` 即此机制),
|
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
|
||||||
要么要求读运行时快照,并用测试钉住(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||||
**"插值"指的是每次组装时现算,不是把算好的结果固化进配置库/文档** ——
|
|
||||||
固化过的版本号与写死没有区别,而且更难发现。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、当前分支对齐(2026-09-13 更新)
|
## 三、当前分支对齐(2026-09-12 更新)
|
||||||
|
|
||||||
### 主仓(TrueAgent)
|
### 主仓(TrueAgent)
|
||||||
|
|
||||||
| 分支 | 状态 | 处理 |
|
| 分支 | 状态 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 **`1.4.0`** —— `1.3.0` 已归发布线所有) | ✅ 保持 |
|
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 `1.3.0`) | ✅ 保持 |
|
||||||
| `release/v1.3.x` | **本条发布线**,`meta.Version` = **`1.3.6`**(该线最后一个 patch);承载 `v1.3.1`…`v1.3.6`;vendored SDK 定版 `1.3.0` | ✅ 保持 |
|
| `release/v1.2.x` | **本条发布线**,`meta.Version` = `1.2.0`,vendored SDK 定版 `1.2.0`;已载入两个发布前修复(GUI 输出目录、知识库同名覆盖) | 🆕 2026-09-12 从 main 切出;**尚无 tag** |
|
||||||
| `release/v1.2.x` | 承载 `v1.2.0` / `v1.2.1` / `v1.2.2`,末态 `meta.Version` = `1.2.2` | 📦 已退役(§2.6),保留供追溯 |
|
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1` | 📦 已退役(§2.6:下个中版本发布即退役),保留供追溯 |
|
||||||
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1`,末态 `meta.Version` = `1.1.1` | 📦 已退役,保留供追溯 |
|
|
||||||
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
|
||||||
|
| `feature/multimodal-embedding` | 已合入 main(`eb4762a`,43 提交,`--no-ff`) | ⏳ 待删(删远端分支需用户确认,§执行守则 3) |
|
||||||
|
|
||||||
> - `v1.3.0` 是**已撤回**的坏 tag:设备输出通道名 `device/<id>` 里的 `/` 拼进 LLM 函数名
|
> `feature/memory-media`、`feature/plugin-proc-migration` 均已从远端删除(旧表里的待删项已处理)。
|
||||||
> `output_send__device/<id>`,上游按**整条请求** 400,全量对话不可用(修复见 `v1.3.1`)。
|
|
||||||
> - `feature/*` 分支(`input-semantics`、`multimodal-embedding`、`memory-media`、`plugin-proc-migration`)
|
|
||||||
> 均已合入并删除。
|
|
||||||
|
|
||||||
### SDK 仓(homeagent-sdk)
|
### SDK 仓(homeagent-sdk)
|
||||||
|
|
||||||
| 分支 | 状态 | 处理 |
|
| 分支 | 状态 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.4.0`**)——`1.3.0` 已随核心**正式** tag 定版(§七.3),故路牌推进 | ✅ 保持 |
|
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.2.0`**)——SDK **不跟 beta 发版**(§七.2),1.2.0 要等核心的**正式** tag 才定版(§七.3),在那之前路牌不得越过它。此阶段与核心 main(`1.3.0`)**故意不对称**,详见 §七.4 | ✅ 保持 |
|
||||||
| `release/v1.3.x` | `meta.Version` = `1.3.0`,承载 tag `v1.3.0`(5 平台 `hmapdev` + `SHA256SUMS` + 4 个源码归档) | ✅ 与核心对应 |
|
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应 |
|
||||||
| `release/v1.2.x` | `meta.Version` = `1.2.0`,承载 tag `v1.2.0`;同线的 `v1.2.1` 属**误发的 patch tag**(§七.1 违规),其 gitcode 条目标题已标注「(已撤回)」 | 📦 已退役 |
|
| `release/v1.2.x` | **尚未创建** | ⏳ 随核心**正式** tag 一起建(§七.3:分支上把版本定为 `1.2.0` 再打 `v1.2.0`;beta 阶段不发 SDK) |
|
||||||
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应(已退役) |
|
|
||||||
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
|
||||||
|
|
||||||
> ❗**SDK 仓不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`。
|
|
||||||
> 2026-09-13 曾误发 `v1.3.1`(文档用),**已撤回**(远端 tag 已删,本地 commit `05b7a20` 可恢复);
|
|
||||||
> `v1.2.1` 是同一类历史遗留。
|
|
||||||
> ❗**现网 SDK store 例外**:本机 `hmapdev` store 用 `--from` 装的是 SDK 源码构建的 1.3.0,
|
|
||||||
> 与 tag 内容一致。
|
|
||||||
|
|
||||||
### 1.0.x 发布线 tag 历史
|
### 1.0.x 发布线 tag 历史
|
||||||
|
|
||||||
| tag | 提交 | 通道 | 说明 |
|
| tag | 提交 | 通道 | 说明 |
|
||||||
@ -261,11 +242,6 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
|||||||
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
|
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
|
||||||
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
||||||
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。
|
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。
|
||||||
- **客户端版本必须与内核同步**(GUI / 鸿蒙 / waiter 同一个号,当前皆为 `internal/meta.Version`):
|
|
||||||
内核版本是唯一事实源,客户端不得各写一个。拉齐用 `make sync-client-versions`,
|
|
||||||
发版前跑 `make check-client-versions` 做漂移门禁。waiter 直接引用 `internal/meta`(无第二份字段);
|
|
||||||
鸿蒙的 `versionName/versionCode` 由脚本写 `AppScope/app.json5`,运行时代码从 `bundleManager` 读,
|
|
||||||
不再硬编码。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -322,15 +298,10 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
|||||||
|
|
||||||
## 六、本规范与「接口冻结」约束的关系
|
## 六、本规范与「接口冻结」约束的关系
|
||||||
|
|
||||||
> **接口冻结已到期(v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是
|
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||||
> 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守。
|
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。
|
||||||
> 迁移完成(v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则。
|
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
|
||||||
> 本节保留历史条款,但**不再作为合回门禁**。
|
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。
|
||||||
|
|
||||||
- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~
|
|
||||||
—— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。
|
|
||||||
- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立);
|
|
||||||
公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。
|
|
||||||
- **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
|
- **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
|
||||||
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
|
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
|
||||||
——发布分支冻结功能(§2.3),接口是最典型的功能面。
|
——发布分支冻结功能(§2.3),接口是最典型的功能面。
|
||||||
@ -371,79 +342,11 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
|||||||
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
|
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
|
||||||
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`;
|
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`;
|
||||||
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release;
|
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release;
|
||||||
4. 上传 5 平台 `hmapdev`(插件开发工具链)产物 + `SHA256SUMS`。
|
4. 上传 5 平台 plugindev 产物 + `SHA256SUMS`。
|
||||||
|
|
||||||
同一中版本内的后续核心 patch(1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0,
|
同一中版本内的后续核心 patch(1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0,
|
||||||
没有新东西要发。只有接口再次变化并进入下一个中版本时,SDK 才发 1.2.0。
|
没有新东西要发。只有接口再次变化并进入下一个中版本时,SDK 才发 1.2.0。
|
||||||
|
|
||||||
### 5. 发版产物清单(可复现)
|
|
||||||
|
|
||||||
**推 tag ≠ 完成发版**:还要打包产物、建 gitcode release 条目、上传附件。
|
|
||||||
2026-09-13 出现过"tag 推了、release 条目和产物都没有"的情况(`v1.3.1`–`v1.3.6`),
|
|
||||||
事后才补 —— 记录在此以免重犯。
|
|
||||||
|
|
||||||
**核心仓**(在 tag 的**干净 worktree** 里构建,不要用带其它会话改动的工作区):
|
|
||||||
|
|
||||||
| 产物 | 生成方式 |
|
|
||||||
|---|---|
|
|
||||||
| `homeagent_<版本>_linux_amd64.tar.gz` | `VERSION=<版本> bash deploy/packaging/package-linux.sh amd64` |
|
|
||||||
| `homeagent-client_<版本>_amd64.deb`、`-server`、`-full` | 同上;server/full 需要 Chinese-CLIP 与 ONNX Runtime 资产目录(`build/model-assets/`、`build/runtime-assets/`) |
|
|
||||||
| `SHA256SUMS` | **全部产物生成完毕之后**统一计算(边打边算会漏掉后生成的包) |
|
|
||||||
| 4 个源码归档(`.zip` / `.tar.gz` / `.tar.bz2` / `.tar`) | gitcode 打 tag 时自动生成,无需上传 |
|
|
||||||
|
|
||||||
**SDK 仓**:`VERSION=<版本> bash package/build.sh all hmapdev` ⇒
|
|
||||||
`hmapdev_{linux,darwin}_{amd64,arm64}` + `hmapdev_windows_amd64.exe` + `SHA256SUMS`。
|
|
||||||
|
|
||||||
**Windows 安装器(WSL 安装型)**:`homed` **不再装到 Windows**(插件体系依赖 fd 继承与
|
|
||||||
共享内存区段内偏移解引用,Windows 句柄模型表达不了),安装器的职责是**引导 WSL2 并把
|
|
||||||
Linux 包送进发行版里安装**。产物 `HomeAgent_v<版本>_{Server,Client,Full}_win64.exe`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 先有 Linux 包(安装器送进 WSL 的就是它),再打安装器
|
|
||||||
VERSION=<版本> bash deploy/packaging/package-linux.sh amd64
|
|
||||||
VERSION=<版本> bash deploy/packaging/package-windows.sh server amd64 # 只装内核+CLI 的 WSL 场景
|
|
||||||
VERSION=<版本> bash deploy/packaging/package-windows.sh client amd64 # 需要 Windows GUI payload
|
|
||||||
```
|
|
||||||
|
|
||||||
- `package-windows.sh` 会按**变体只放对应的那一个 deb** 进 payload。为什么:WSL 侧脚本只取
|
|
||||||
payload 里第一个 `.deb`(`install-via-wsl.ps1`),而 server/full 的 deb 各带 ~719MB 模型 ——
|
|
||||||
照 `build.sh` 的 `stage_linux_payload`(把所有 deb+tar 全塞)打出来会是 ~2.4GB 的安装器。
|
|
||||||
- `client`/`full` 变体还带 Windows GUI,需要 electron-builder 产出
|
|
||||||
`build/homeagent-gui-win32-x64/`;缺它就**明确失败**,不产出"装完没有界面"的半残包。
|
|
||||||
|
|
||||||
**上传**(两仓同一个脚本):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 核心仓
|
|
||||||
python3 deploy/scripts/upload_assets.py <tag> <token> # 默认上传 dist/release 下可识别的产物
|
|
||||||
# SDK 仓(hmapdev_* 没有扩展名,不会被自动识别 ⇒ 必须显式列文件名)
|
|
||||||
GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
|
|
||||||
python3 deploy/scripts/upload_assets.py <tag> <token> hmapdev_linux_amd64 ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- 脚本先向 `releases/<tag>/upload_url` 取 **OBS 预签名 URL** 再 PUT ⇒ **release 条目必须先存在**;
|
|
||||||
- alpha/beta 的产物可以上传,但必须在 release 条目上勾选**预发布**标志(§2.4);
|
|
||||||
- 校验和必须覆盖**全部**附件,否则等于没有校验。
|
|
||||||
- ❗❗**gitcode 的 release 附件是"同名只写一次"**(实测:同名两次不同内容,下载始终是第一次那份;
|
|
||||||
且没有可用的删除接口 —— release JSON 不含 `id`,附件列表接口 404,`DELETE .../attach_files/<名>`
|
|
||||||
只要数字 id)。**后果**:`SHA256SUMS` 若第一次上传时只覆盖了部分平台,之后**永远改不回来** ——
|
|
||||||
1.3.1–1.3.10 都踩了:首次只传了 linux/amd64,后来补 arm64/darwin/win 时合并重传**全部无效**,
|
|
||||||
线上那份至今只有 4 项。
|
|
||||||
⇒ **纪律:首次上传 `SHA256SUMS` 前必须已打包全部平台**;分批上传时**先传产物、最后传校验和**,
|
|
||||||
且校验和只传一次。补救只能换名(如 `SHA256SUMS.complete`)或重建 release(要重传全部产物)。
|
|
||||||
- ❗**流水线脚本必须 `set -e`(或显式检查每步)**:否则某一步失败(例如驱动脚本在 tag 里
|
|
||||||
不存在)之后它仍会继续跑到上传,把**半成品校验和**推上去覆盖全量的那份。
|
|
||||||
(实测:v1.3.10 的校验和被 4 项覆盖掉,只能重建。)
|
|
||||||
- ❗**在 tag 的 worktree 里构建时,驱动脚本要么已进该 tag,要么支持目录覆盖**:
|
|
||||||
新补的脚本只存在于 main,去 tag 的 worktree 里调就是 `No such file or directory`。
|
|
||||||
现 `package-windows.sh` 支持 `DIST_LINUX` / `BUILD_DIR` / `DIST_RELEASE` 覆盖,
|
|
||||||
可以"用主仓的脚本 + 产物目录指向 worktree"。
|
|
||||||
- ❗**分批上传时,后一轮必须在全量产物上重算 `SHA256SUMS`**,不能只算本轮那几个文件:
|
|
||||||
同名附件会**覆盖**前一轮的校验和(实测:先传 amd64 的 9 个资产,后补 arm64 时
|
|
||||||
只算了 arm64 的 4 个,结果 amd64 的校验和从 release 上消失 ⇒ 已下载的包失去校验依据,
|
|
||||||
只能把产物全部下回来重算)。要么一次打包全部平台再算,要么后一轮把**已上传的**
|
|
||||||
也纳入计算。
|
|
||||||
|
|
||||||
### 4. 版本号在两仓 main 上的含义
|
### 4. 版本号在两仓 main 上的含义
|
||||||
|
|
||||||
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
两仓的 `main` 都遵守 §2.1:`meta.Version` 是**下一个未发布中版本**。
|
||||||
@ -461,52 +364,3 @@ GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
|
|||||||
|
|
||||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、公开 SDK 接口的演进规则
|
|
||||||
|
|
||||||
> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。
|
|
||||||
> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠
|
|
||||||
> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**——
|
|
||||||
> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。
|
|
||||||
> 取代它的是下面三条更弱、但仍然硬的规则。
|
|
||||||
|
|
||||||
### 1. 只增不减,签名不改
|
|
||||||
|
|
||||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
|
||||||
|
|
||||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
|
||||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
|
||||||
而那些插件根本不关心媒体。
|
|
||||||
|
|
||||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
|
||||||
|
|
||||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是
|
|
||||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
|
||||||
每个存量插件都会因未实现而编译失败。
|
|
||||||
|
|
||||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
|
||||||
|
|
||||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口,
|
|
||||||
每个外部插件都**编不过**——是硬失败,不是软降级。
|
|
||||||
|
|
||||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
|
||||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
|
||||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
|
||||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。
|
|
||||||
|
|
||||||
### 4. 「接口纯追加」不等于「无需重编」
|
|
||||||
|
|
||||||
插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。
|
|
||||||
协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配
|
|
||||||
会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。
|
|
||||||
必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。
|
|
||||||
|
|
||||||
### 5. 合回 main 前要同步的东西
|
|
||||||
|
|
||||||
1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`;
|
|
||||||
2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含
|
|
||||||
`TestProcTemplate_CoversAllCoreMethods`);
|
|
||||||
3. 存量插件源码零改动(逐个 `cd example/<n> && go vet ./...`);
|
|
||||||
4. 并发安全(`go test -race -count=5 ./sdk/`)。
|
|
||||||
|
|||||||
@ -1,346 +0,0 @@
|
|||||||
# 内核 C 化 · 第一刀:LLM 编排层
|
|
||||||
|
|
||||||
> 分支:`feature/c-core`(从 `main` 拉出,遵循 `git-release-discipline`)
|
|
||||||
> 状态:**第一刀已落地并闭环**(2026-09-25)。L1 纯函数层的三个函数已在 C 侧
|
|
||||||
> 实现,双路径(cgo / 纯 Go 回退)与黄金对照测试均已在仓库内跑通;
|
|
||||||
> 构建链已打通至 `linux/amd64` 与 `linux/arm64` 两个真实发布目标。
|
|
||||||
> 本文保留设计与可行性论证,并在每节标注**落地后的实际情况**。
|
|
||||||
> 本文只写经实测确认的结论;每个「可行」都附验证方式,每个「不可行」都附证据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、为什么要 C 化,以及为什么先动 LLM 编排
|
|
||||||
|
|
||||||
内核当前是**纯 Go 单进程**(`homed`),插件经子进程 + 共享内存与之通信。
|
|
||||||
C 化的目标不是「换语言重写」,而是把**稳定、高频、无 GC 抖动敏感**的热路径
|
|
||||||
下沉为可复用 C 库,让内核在保持 Go 编排能力的同时获得:
|
|
||||||
|
|
||||||
- 可预测的延迟(无 GC STW 影响热路径)
|
|
||||||
- 可跨端复用(鸿蒙 / 嵌入式 / C SDK 侧同一份实现)
|
|
||||||
- 与既有 C 资产统一(见 §三)
|
|
||||||
|
|
||||||
**为什么第一刀是 LLM 编排**:这一层是内核最核心的职责(`assets/docs/zh/OVERVIEW.md`
|
|
||||||
定义内核 = 「LLM 编排 + 记忆管理 + 知识检索」),且它**天然分层**——
|
|
||||||
上层是有状态的调度/工具循环,下层是**无状态的协议编解码**。
|
|
||||||
后者是纯字符串进、结构体出,最适合先下沉。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、可行性核实结论(实测)
|
|
||||||
|
|
||||||
### 2.1 工具链齐备
|
|
||||||
|
|
||||||
```
|
|
||||||
cc/gcc/clang: /usr/bin/{cc,gcc,clang}
|
|
||||||
make/cmake: /usr/bin/{make,cmake}
|
|
||||||
go env: CC=gcc CGO_ENABLED=1 GOOS=linux GOARCH=amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 已有纯 C 先例,可直接复用
|
|
||||||
|
|
||||||
`third_party/homeagent-sdk/remotedevice/` 是一个**零外部依赖**的纯 C 库
|
|
||||||
(1320 行),已含可复用组件:
|
|
||||||
|
|
||||||
| 文件 | 行数 | 能力 |
|
|
||||||
|---|---|---|
|
|
||||||
| `src/ha_json.c` | 368 | DOM 风格 JSON 解析器 + 流式构建器 |
|
|
||||||
| `src/ha_ws.c` | 324 | WebSocket 客户端握手/帧 |
|
|
||||||
| `src/ha_remotedevice.c` | 628 | 设备通道 |
|
|
||||||
| `CMakeLists.txt` | — | 静态/动态库、install 规则、可选测试 |
|
|
||||||
|
|
||||||
**这意味着 JSON 解析这一 C 化的最大依赖,仓库里已有现成实现**,
|
|
||||||
不需要引入 cJSON 等外部依赖,与「内核不新增外部依赖」的克制一致。
|
|
||||||
|
|
||||||
### 2.3 ~~关键约束:Windows 构建是 `CGO_ENABLED=0`~~ → 前提已消失,改用另一条约束
|
|
||||||
|
|
||||||
> ⚠️ **本节结论已因「放弃 Windows 原生」而过时**(见 §2.5)。
|
|
||||||
> 关键的一点在于,原论证推理的根基“Windows 包需要 CGO_ENABLED=0”已不成立,
|
|
||||||
> 故「必须保留纯 Go 回退」这个**推论**也不再由它支撑。
|
|
||||||
> 现在的实际策论是:用 `cgo` / `!cgo` 一组约束(回退实现保留,但理由是
|
|
||||||
> waiter 等 CGO-free 目标与无 cgo 工具链场景,而不是 Windows),
|
|
||||||
> 而**不需要额外的 `hacodec` tag** ——因为 ha_codec 是零依赖纯 C99 源码
|
|
||||||
> 内联编译,不像 onnxruntime 那样需要运行期 `.so`。
|
|
||||||
|
|
||||||
原论证(保留作为推理参考):
|
|
||||||
|
|
||||||
```
|
|
||||||
deploy/packaging/package-windows.sh:69
|
|
||||||
GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \
|
|
||||||
```
|
|
||||||
|
|
||||||
**这是 C 化最大的部署风险**:若把编排逻辑改成必须 cgo,Windows 包直接编不出来。
|
|
||||||
|
|
||||||
**已验证的解法:build-tag 双实现**。实测(最小复现):
|
|
||||||
|
|
||||||
```go
|
|
||||||
//go:build cgo
|
|
||||||
// a_cgo.go —— cgo 实现,链接 C 库
|
|
||||||
|
|
||||||
//go:build !cgo
|
|
||||||
// a_pure.go —— 纯 Go 回退实现,行为等价
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
CGO_ENABLED=1 go build ./... → exit 0
|
|
||||||
CGO_ENABLED=0 go build ./... → exit 0
|
|
||||||
```
|
|
||||||
|
|
||||||
结论:**C 化必须始终保留纯 Go 回退路径**,且两条路径要有同一组测试钉死
|
|
||||||
行为等价(黄金对照,见 §五)。这不是可选项——是 Windows 分发的前提。
|
|
||||||
|
|
||||||
### 2.4 落地后的形状修正(实测补充,2026-09-25)
|
|
||||||
|
|
||||||
实际落地时,三处原设计需要修正:
|
|
||||||
|
|
||||||
**① 不链接静态库,也不 `#include` 包外源 —— 用包内符号链接。**
|
|
||||||
原方案(`LDFLAGS` 指向 `csrc/build/libha_codec.a`)实测会造成两个必然失败:
|
|
||||||
|
|
||||||
- `.a` 是构建产物、不入库(`.gitignore` 的 `build/` 命中 `csrc/build/`),
|
|
||||||
而发布脚本原先并不产出它 ⇒ 「不入库 + 不生成」两头空,链接报
|
|
||||||
`cannot find .../libha_codec.a`。
|
|
||||||
- 交叉编译 `linux/arm64`(homed 的真实发布目标)时,宿主 x86-64 的 `.a`
|
|
||||||
被链进目标产物,报 `file in wrong format`。
|
|
||||||
|
|
||||||
改为包内 `ha_codec.c` / `ha_codec.h` **符号链接**到 `csrc/` 权威源:
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c
|
|
||||||
internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h
|
|
||||||
```
|
|
||||||
|
|
||||||
★ **为什么不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)**:
|
|
||||||
**Go 构建缓存不跟踪包外被 #include 的 C 文件**。实测:在包外源里把返回值从 7
|
|
||||||
改成 8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内文件时
|
|
||||||
立即判红。对「逐步推进 C 化」这是致命的——改 C 源码却不生效且无任何报错。
|
|
||||||
(包内 shim `#include` 包外源同样漏跟踪,已实测排除。)
|
|
||||||
|
|
||||||
符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪;
|
|
||||||
只有一份权威源 ⇒ 无副本漂移、无需同步目标。
|
|
||||||
|
|
||||||
**② `csrc/CMakeLists.txt` 的定位变化**:不再是 Go 构建的前置,而是
|
|
||||||
**C 侧独立复用**(鸿蒙/嵌入式/C SDK)与契约测试(ctest)的入口。
|
|
||||||
|
|
||||||
**③ C 源文件会被 Go 工具链视为包的一部分**:故 `.c` 需要 `//go:build cgo` 约束
|
|
||||||
(C 编译器把该行当普通注释,两侧兼容)。
|
|
||||||
|
|
||||||
### 2.5 真正的硬耦合点:Lua 适配器
|
|
||||||
|
|
||||||
`internal/agent/api/provider.go` 的 `LuaAdaptedProvider` 在**每次请求**都要
|
|
||||||
调 Lua VM(`internal/lua/vm.go`,基于 `gopher-lua`,679 行):
|
|
||||||
|
|
||||||
| 调用点 | provider.go 行 | 作用 |
|
|
||||||
|---|---|---|
|
|
||||||
| `CallTransformRequest` | :403, :872 | 改写请求体(适配器协议知识) |
|
|
||||||
| `GetAdapterEndpoint` | :408, :877 | 决定 endpoint |
|
|
||||||
| `CallTransformResponse` | :439 | 改写响应 |
|
|
||||||
| `BuildHeaders` / `GetAdapterHeaders` | :487, :489 | 动态签名头 |
|
|
||||||
| `TransformError` | :900 | 错误归一化 |
|
|
||||||
| `CallTransformStreamChunk` | :950 | 流式分片改写 |
|
|
||||||
|
|
||||||
**结论**:**「把 provider.go 整体 C 化」是不可行的**——它把一个嵌入式 Lua
|
|
||||||
解释器(带 GC、协程)拖进 C。可行的切法是**只 C 化 Lua 之外的部分**:
|
|
||||||
把 Lua 当作「回调钩子」,C 侧定义钩子接口,Go 侧注入 Lua 实现。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、切分方案:按「无状态 → 有状态」分三层
|
|
||||||
|
|
||||||
### L1 · 协议编解码(本轮目标,纯函数,零状态)
|
|
||||||
|
|
||||||
最适合先下沉。全部是 `string in → struct out`:
|
|
||||||
|
|
||||||
| 目标函数 | 现位置 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `parseOpenAICompatibleResponse` | `provider.go:499` | 非流式响应解析 |
|
|
||||||
| `parseOpenAICompatibleSSEBody` | `provider.go:541` | SSE body 整段解析 |
|
|
||||||
| `parseOpenAICompatibleStreamChunkFull` | `provider.go:759` | 流式分片解析 |
|
|
||||||
| `normalizeOpenAIToolCalls` | `provider.go:628` | 工具调用归一 |
|
|
||||||
| `normalizeStreamToolCalls` | `provider.go:674` | 流式工具调用归一 |
|
|
||||||
| `ModelContextWindow` | `provider.go:273` | 模型名 → 窗口(纯映射) |
|
|
||||||
| `EstimateTokens` / `TruncateByTokens` | `tokenbudget.go` | 纯计算 |
|
|
||||||
| `ComputeTokenBudget` | `tokenbudget.go:52` | 纯计算(依赖上面两个) |
|
|
||||||
|
|
||||||
这些函数**不碰网络、不碰 Lua、不碰 goroutine**,是最安全的起点。
|
|
||||||
`ModelContextWindow` / `EstimateTokens` / `ComputeTokenBudget` 三个更是
|
|
||||||
**同一组纯算术**,可作为「第一个能跑通端到端 C 调用」的最小切片。
|
|
||||||
|
|
||||||
### L2 · Provider 编排(暂不动)
|
|
||||||
|
|
||||||
`LuaAdaptedProvider.Chat` / `ChatStream`:HTTP + Lua 钩子 + SSE 流式,
|
|
||||||
强耦合 Go 的 `net/http` 与 `context`。C 化的收益低于风险,**暂不动**。
|
|
||||||
|
|
||||||
### L3 · 工具循环 / 调度(明确不 C 化)
|
|
||||||
|
|
||||||
`task.go` / `toolcall.go` / `eventloop.go` / `scheduler.go`:有状态、
|
|
||||||
与记忆层和 goroutine 调度深度耦合。C 化会摧毁可维护性,**不做**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、落地结构(**已落地**,2026-09-25)
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/agent/api/
|
|
||||||
├── provider.go # 不动(L2)
|
|
||||||
├── codec.go # 统一符号名(调用方只见这里)
|
|
||||||
├── codec_cgo.go # //go:build cgo → 调 C
|
|
||||||
├── codec_nocgo.go # //go:build !cgo → 转发到纯 Go
|
|
||||||
├── codec_pure.go # 纯 Go 实现(回退 + 黄金对照基准)
|
|
||||||
├── codec_golden_test.go # 黄金对照:C 与纯 Go 逐值相等
|
|
||||||
├── ha_codec.h -> ../../../csrc/include/ha_codec.h (符号链接)
|
|
||||||
└── ha_codec.c -> ../../../csrc/src/ha_codec.c (符号链接)
|
|
||||||
|
|
||||||
csrc/ # C 实现(主仓,非 SDK)
|
|
||||||
├── CMakeLists.txt # 供 C 侧独立复用与 ctest(不参与 Go 构建)
|
|
||||||
├── include/ha_codec.h # 对外 C 接口(冻结契约)
|
|
||||||
├── src/ha_codec.c # L1 编解码(当前:窗口推断 + token 估算/截断)
|
|
||||||
└── test/test_ha_codec.c # C 侧契约测试
|
|
||||||
```
|
|
||||||
|
|
||||||
★ **为什么是符号链接而不是 `#include` 包外源**:Go 构建缓存不跟踪包外被
|
|
||||||
`#include` 的 C 文件(实测:改包外源后 `go test` 仍报 ok,静默用旧代码)。
|
|
||||||
详见 §2.4 ①。
|
|
||||||
|
|
||||||
**接口设计原则**(已遵守):
|
|
||||||
1. C 接口只吃 `const char*` + 长度,出数值/JSON 串——**不传 Go 指针、
|
|
||||||
不回调 Go**(回调留给 L2 的 Lua 钩子层,不在本轮)
|
|
||||||
2. C 侧**不 malloc 长期持有的内存**;调用方给缓冲区,或用「申请/释放」成对
|
|
||||||
API 并在 Go 侧 `defer` 释放
|
|
||||||
3. `ha_codec.h` 一旦定下就是**冻结接口**,与 SDK 冻结同一标准
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、验收方式(黄金对照,缺一不可)
|
|
||||||
|
|
||||||
C 化的正确性**不能靠「跑起来没崩」**,必须有可复现的对照。三类证据:
|
|
||||||
|
|
||||||
1. **黄金对照测试**:同一组输入分别喂 C 实现与 Go 实现,断言输出逐字段相等。
|
|
||||||
现有测试可直接复用做基准:
|
|
||||||
- `internal/agent/api/sse_body_test.go`(4 个 Test)
|
|
||||||
- `internal/agent/api/context_window_test.go`(2 个)
|
|
||||||
- `internal/agent/core/stream_accumulate_test.go`(9 个)
|
|
||||||
- `internal/agent/core/tokenbudget_test.go`
|
|
||||||
2. **双构建全绿**:`CGO_ENABLED=1 go test ./...` 与 `CGO_ENABLED=0 go test ./...`
|
|
||||||
**都必须通过**(后者走纯 Go 回退)。CI 要同时跑。
|
|
||||||
3. **契约测试**:`ha_codec.h` 的每个函数有对应 C 单测(参照
|
|
||||||
`remotedevice/test/test_ha_remotedevice.c` 的写法,`gcc ... -lpthread` 直编)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、第一步:最小可验证切片(**已完成**,2026-09-25)
|
|
||||||
|
|
||||||
**目标**:只 C 化一个纯函数族,跑通「Go → cgo → C → 返回」全链路,
|
|
||||||
证明结构可行,再谈扩张。
|
|
||||||
|
|
||||||
选 `ModelContextWindow` + `EstimateTokens` + `TruncateByTokens`
|
|
||||||
(三个纯函数,无依赖,逻辑确定,测试齐备)。**下表为实际落地情况**:
|
|
||||||
|
|
||||||
| # | 计划项 | 落地 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `csrc/include/ha_codec.h` 声明三函数 | ✅(含接口冻结声明与哨兵值约定)|
|
|
||||||
| 2 | `csrc/src/ha_codec.c` 纯 C 实现 | ✅(表驱动 switch + 手写 UTF-8 步进)|
|
|
||||||
| 3 | `codec_cgo.go` / `codec_pure.go` 双实现 | ✅(+ `codec_nocgo.go` 转发层)|
|
|
||||||
| 4 | `csrc/CMakeLists.txt` 产出静态库 | ✅(但 Go **不链接**它,见 §2.4)|
|
|
||||||
| 5 | Go 侧构建集成 | ✅ 改为包内符号链接 + cgo 编译 C 源 |
|
|
||||||
| 6 | 黄金对照测试 | ✅ `codec_golden_test.go`(手写用例 + 2000 次随机对拍)|
|
|
||||||
| 7 | `CGO_ENABLED=0` 下全绿 | ✅ `make check-codec-paths` 钉死两条路径 |
|
|
||||||
|
|
||||||
**额外钉死的约束**(原计划未列,实测后补):
|
|
||||||
|
|
||||||
- **变异测试必须真判红**:改 C 侧 `result = 131072` → `777`,
|
|
||||||
`go test` 必须 FAIL。这是「C 路径真的生效」的证据(不是「跑起来没崩」)。
|
|
||||||
★ 此测试抓到过两种静默失效:包外 `#include` 漏跟踪、构建缓存隐藏改动。
|
|
||||||
- **`cc` 交叉编译必须可过**:`make build-linux-arm64` 产出 ELF aarch64。
|
|
||||||
- **C 侧契约测试**:`make csrc-test`(ctest)与 Go 侧黄金对照互补。
|
|
||||||
|
|
||||||
**这一步的价值不在功能**(这三个函数 Go 版没问题),而在**打通链路、
|
|
||||||
钉死双路径约束、建立黄金对照范式**——后面每扩一个函数都复用它。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、已知风险与未决问题
|
|
||||||
|
|
||||||
| 风险 | 现状 | 处置 |
|
|
||||||
|---|---|---|
|
|
||||||
| ~~Windows `CGO_ENABLED=0` 编不出来~~ | **前提已消失**(Windows 原生已放弃,见 §2.3)| 回退保留但理由换成「CGO-free 目标」|
|
|
||||||
| Lua 适配器无法 C 化 | 硬耦合 gopher-lua | 钩子化,Lua 留在 Go 侧(L2 做)|
|
|
||||||
| ~~C 库构建谁触发~~ | ✅ 已解决:Go 不依赖预构建库,直接编包内符号链接的 C 源 | — |
|
|
||||||
| **包外 C 源会被缓存漏跟踪** | ✅ 已避坑:实测确认,改用包内符号链接 | 扩张时勿改回 `#include` 包外路径 |
|
|
||||||
| JSON 解析能力不足 | `ha_json.c` 只存 `int`(无 float/long)| 用前须评估;必要时扩该库(会动 SDK,需走 SDK 冻结流程)|
|
|
||||||
| 接口冻结 | — | `ha_codec.h` 冻结标准对齐 SDK |
|
|
||||||
| 打包脚本 | ✅ 不再需带 `.a`(编源码,无外部产物依赖)| 扩张到多文件 C 实现时重评 |
|
|
||||||
|
|
||||||
**仍未决(需 jianf 拍板)**:
|
|
||||||
1. C 实现放**主仓 `csrc/`** 还是 **SDK `third_party/homeagent-sdk/`**?
|
|
||||||
- 当前已在主仓 `csrc/`;若 SDK 侧也要复用,需定同步机制
|
|
||||||
- 放 SDK:天然跨端复用,但要走 SDK 冻结与大版本流程
|
|
||||||
2. `ha_json.c` 是**复用**(从 remotedevice 复制/提为公共)还是**新写**?
|
|
||||||
复用会动 SDK 目录结构。
|
|
||||||
3. **下一个切片选谁**?L1 剩下的是协议编解码(`parseOpenAICompatible*`、
|
|
||||||
`normalize*ToolCalls` 等,见 §三 L1 表);该层依赖 JSON 解析 ⇒ 先解第 2 题。
|
|
||||||
|
|
||||||
### 7.1 ★ 性能:初版结论是错的,根因是我的绑定与 C 实现
|
|
||||||
|
|
||||||
**初版结论「C 比 Go 慢」不成立** —— 那是把「我自己的 malloc/拷贝开销」误当成了
|
|
||||||
「cgo 的固有成本」。拆解实测(同一台机,`-benchtime` 百万次):
|
|
||||||
|
|
||||||
| 场景 | ns/op | 说明 |
|
|
||||||
|---|---:|---|
|
|
||||||
| cgo 边界(零拷贝传指针 + 空函数体)| **31.9** | cgo 的**真实**固有成本 |
|
|
||||||
| + 一次 `C.CString` + C 侧 `strlen` | 105–111 | **多出 ~75ns(70%)** |
|
|
||||||
| 初版 `ModelContextWindow`(另加 `lower_dup` malloc + 16×strstr)| **175** | 即 **82% 是自找的** |
|
|
||||||
|
|
||||||
而初版**违反了自己写在本文 §四 的接口原则第 1 条**:
|
|
||||||
「C 接口只吃 `const char*` **+ 长度**」—— 它没传长度,让 C 侧 `strlen` 再扫一遍。
|
|
||||||
|
|
||||||
#### 优化措施(逐项对应上表的浪费)
|
|
||||||
|
|
||||||
| # | 初版做法 | 现在 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `C.CString`(malloc + 整串拷贝)| `unsafe.StringData` 传指针 + 长度,**零拷贝** |
|
|
||||||
| 2 | C 侧 `strlen` 再扫一遍 | 长度由调用方传入,**不扫** |
|
|
||||||
| 3 | `truncate` malloc 输出缓冲 + `GoStringN` 拷回 | C 只返回**字节数**(结果必是前缀),Go 侧 `s[:n]` 切片 |
|
|
||||||
| 4 | `lower_dup` 每次 malloc 模型名 | 栈缓冲折叠(超长走零分配回退) |
|
|
||||||
| 5 | 逐字节 `utf8_next` 函数调用 | **字级(8 字节)ASCII 检测** + 位运算 UTF-8 校验 |
|
|
||||||
| 6 | `truncate` 扫完整串才判断 | **数满 keep 个 rune 立即返回**(提前短路) |
|
|
||||||
| 7 | 纯 Go 侧 `len([]rune(s))` / `[]rune(s)`(1KB 分配 4KB)| `utf8.RuneCountInString` / `DecodeRuneInString` 游走,**零分配** |
|
|
||||||
|
|
||||||
#### 优化后(完全 C 化:一律走 C,无按长度分派)
|
|
||||||
|
|
||||||
| 基准 | 初版 C | **优化后 C** | 纯 Go | 提升 |
|
|
||||||
|---|---:|---:|---:|---:|
|
|
||||||
| `ModelContextWindow`(短 ASCII)| 175 | **76.5** | 46.8 | **2.3×** |
|
|
||||||
| `EstimateTokens` / 短 ASCII | 114.6 | **47.2** | 2.8 | 2.4× |
|
|
||||||
| `EstimateTokens` / 短中文 | 99.6 | **49.7** | 22.8 | 2.0× |
|
|
||||||
| `EstimateTokens` / **1KB ASCII** | 2318 | **80.8** | 326 | **28.7×** |
|
|
||||||
| `EstimateTokens` / 1KB 中文 | 840 | 1467 | 2844 | 0.57×(见下)|
|
|
||||||
| `TruncateByTokens` / 短中文 | 233 | **40.2** | 25.3 | 5.8× |
|
|
||||||
| `TruncateByTokens` / **1KB ASCII** | 2594 | **71.7** | 411 | **36×** |
|
|
||||||
| `TruncateByTokens` / **1KB 中文** | 3923 | **70.1** | 3097 | **56×** |
|
|
||||||
|
|
||||||
#### ★ 必须如实说明的两点
|
|
||||||
|
|
||||||
**① 中文密集输入比初版慢(1467 vs 840)—— 这是刻意的正确性代价。**
|
|
||||||
初版的 `utf8_next` **只按首字节推断长度、不校验后续字节**,因此对畸形序列会与 Go
|
|
||||||
分叉(例:`"\xE4\x41\x41"`,Go 判 3 个 rune,初版判 1 个 ⇒ rune 计数偏差 ⇒
|
|
||||||
token 预算与截断点偏移)。现在 C 侧做了**与 Go `utf8.DecodeRuneInString` 等价**的
|
|
||||||
完整校验(含过长编码、代理对、超 U+10FFFF、截断序列)。
|
|
||||||
换来的能力由 `TestGolden_InvalidUTF8`(3000 组随机字节)钉死 —— 这类偏差
|
|
||||||
**只影响计数、不会崩**,不测就发现不了。**正确性优先,且仍比纯 Go 快 2×。**
|
|
||||||
|
|
||||||
**② 极短串上 C 慢于 Go(约慢一个数量级)—— 这是「完全 C 化」的已知代价。**
|
|
||||||
`EstimateTokens("qq")`:C 约 47ns(几乎全是 31ns 的边界成本)vs 纯 Go 约 3ns。
|
|
||||||
绝对值是纳秒级(47ns = 0.000047ms),单次请求尺度可忽略;
|
|
||||||
但**若某个循环对极短串高频调用**,这一项会累积。
|
|
||||||
|
|
||||||
⇒ **正确的应对是「C 化那个循环(批量传一次)」而不是「按长度分派回 Go」**
|
|
||||||
(后者正是被否掉的混合做法:它会同时存在两份语义可能分叉的实现)。
|
|
||||||
这也是 §三 L2/L3 把「有状态编排」明确留给 Go、而把「长 payload 编解码」
|
|
||||||
作为下一步目标的原因 —— 协议编解码(JSON / SSE 分片)处理的正是长文本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*实测记录:双路径、缓存跟踪、交叉编译、变异测试、跨语言基准均于 2026-09-25
|
|
||||||
在本仓实测;工具链与 C 资产核实于本仓*
|
|
||||||
*初版核实:2026-09-24 · 落地更新:2026-09-25*
|
|
||||||
@ -1,432 +0,0 @@
|
|||||||
# 内核 C 化 · 第二刀:SSE 分块协议编解码
|
|
||||||
|
|
||||||
> 分支:`feature/c-core`(承接第一刀,见 `llm-orchestration-c.md`)
|
|
||||||
> 状态:**扫描/取值库已落地并闭环**(2026-09-26)。
|
|
||||||
> 基础设施已建成(`plan.md` §七),本文记录第二刀的**判据**、
|
|
||||||
> **Go 侧真值表**、**不可协商的约束**与**落地记录**。
|
|
||||||
>
|
|
||||||
> 本刀范围(有意收窄):**只交付 `ha_json_scan` 库 + 与 Go 的逐值对照**。
|
|
||||||
> **尚未**改动 Go 生产路径(`parseOpenAICompatibleStreamChunkFull` 仍是原实现)——
|
|
||||||
> 接线是独立一步,需单独验证与基准,避免「库还没验就换产线」。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、为什么是 SSE 分块编解码
|
|
||||||
|
|
||||||
判据不是「哪个看起来底层」,而是「**在真实负载下值不值**」。
|
|
||||||
|
|
||||||
`parseOpenAICompatibleStreamChunkFull` 是**每个流式 chunk 都要跑一次**的最热路径。
|
|
||||||
实测(`feature/c-core`,153 字节 content 块):
|
|
||||||
|
|
||||||
| 输入 | ns/op | allocs/op |
|
|
||||||
|---|---:|---:|
|
|
||||||
| content 块(含中文) | 1937 | 13 |
|
|
||||||
| toolcall 块 | **3122** | **21** |
|
|
||||||
| usage 块 | 2464 | 12 |
|
|
||||||
| 纯字节扫描理论下限 | **133** | 1 |
|
|
||||||
|
|
||||||
差距 **15–23×**。一次 1 万块的会话 = 1–2 万次堆分配 —— 这正是 C 化的原始动机
|
|
||||||
(消除 GC 抖动)。
|
|
||||||
|
|
||||||
> 注:真值表探针(`zz_truth_test.go`,临时)测出 `usage` 块 2464ns/12 allocs,
|
|
||||||
> 而上面表格里 153 字节的 content 块是 1937ns/13 allocs。两者接近,但**探针的
|
|
||||||
> usage 输入 248 字节更大**,说明这张表只看数量级,具体值随输入形状浮动。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、★ Go 侧真值表(本刀的**规格**)
|
|
||||||
|
|
||||||
C 实现不是「重新设计」,是**逐值复刻 Go**。而 Go 的 `encoding/json` 语义里
|
|
||||||
藏着一批**不直观的行为**——先探明再写 C,否则会造出一个「看起来对」的错实现。
|
|
||||||
|
|
||||||
以下全部为实测(`go test -run TestGroundTruth`):
|
|
||||||
|
|
||||||
### 2.1 键匹配是**大小写不敏感**的
|
|
||||||
|
|
||||||
```
|
|
||||||
{"choices":[{"DELTA":{"CONTENT":"up"}}]} → 解析成功,content="up"
|
|
||||||
{"choices":[{"delta":{"content":"x"},"FINISH_REASON":"stop"}]} → done=true
|
|
||||||
```
|
|
||||||
|
|
||||||
★ 极易踩:手写解析器若逐字节比对键名,这两种输入会**静默返回空内容**。
|
|
||||||
必须走「键长度 + 大小写不敏感比较」。
|
|
||||||
|
|
||||||
### 2.2 类型不匹配 ⇒ **整块作废**(不是「该字段降级为空」)
|
|
||||||
|
|
||||||
```
|
|
||||||
{"choices":[{"delta":{"content":{}}}]} → FALSE(整块拒绝)
|
|
||||||
{"choices":[{"delta":{"reasoning_content":123}}]} → FALSE
|
|
||||||
{"usage":{"prompt_tokens":"1"}} → FALSE
|
|
||||||
{"usage":{"prompt_tokens":1.5}} → FALSE
|
|
||||||
{"usage":{"prompt_tokens":1e2}} → FALSE
|
|
||||||
{"usage":{"prompt_tokens":99999999999999999999}}→ FALSE(溢出 ⇒ 报错)
|
|
||||||
{"choices":[{"delta":{"content":"x"},"finish_reason":42}]} → FALSE
|
|
||||||
```
|
|
||||||
|
|
||||||
★ 这是本刀**最反直觉**的一条:Go 侧「某字段类型不对」**不是**忽略该字段,
|
|
||||||
而是让 `json.Unmarshal` 整体失败、`parseOpenAICompatibleStreamChunkFull` 返回 `false`,
|
|
||||||
于是该 chunk 被 `continue` 静默丢弃。
|
|
||||||
|
|
||||||
⇒ 后果:上游若发来一个 usage 心跳块(只有 `prompt_cache_hit_tokens`、
|
|
||||||
`prompt_tokens_details`,没有 `prompt_tokens`/`total_tokens`/`prompt`),
|
|
||||||
**整块被丢弃**。实测确认:
|
|
||||||
```
|
|
||||||
{"usage":{"prompt_cache_hit_tokens":5,"prompt_tokens_details":{"cached_tokens":7}}} → FALSE
|
|
||||||
```
|
|
||||||
这在语义上「无害」(那个块本来也只有缓存细节),但它说明一件事:
|
|
||||||
**C 侧若比 Go 宽松,会让本来被丢的块开始生效,token 统计口径就变了**。
|
|
||||||
|
|
||||||
### 2.3 `content` 是 `interface{}`,走 `stringifyContent`
|
|
||||||
|
|
||||||
| 输入类型 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| `"hi"` | `"hi"` |
|
|
||||||
| `null` | `""` |
|
|
||||||
| `123` | `"123"` |
|
|
||||||
| `[{"type":"text","text":"a"}]` | `"a"`(数组取每个对象的 `text` 拼接) |
|
|
||||||
| `{}` | **整块 FALSE**(默认分支 `json.Marshal` 后 unmarshal 失败) |
|
|
||||||
|
|
||||||
### 2.4 `reasoning_content` 是**强类型 string**
|
|
||||||
|
|
||||||
`123` ⇒ 整块 FALSE(与 `content` 的宽松形成对比)。空 `delta` 正常通过。
|
|
||||||
|
|
||||||
### 2.5 语法严格性
|
|
||||||
|
|
||||||
`{` / `{"a":}` / ``(空)/ `null` / `[]` / `"str"` / `123` / `{"a":1,}`(尾逗号)/
|
|
||||||
`{'a':1}`(单引号)**全部 FALSE**。
|
|
||||||
|
|
||||||
★ 顶层非对象必须 FALSE(`json.Unmarshal` 到 struct 会报
|
|
||||||
`cannot unmarshal array into Go value of type struct`)。
|
|
||||||
|
|
||||||
### 2.6 重复键:**后者胜**(与 `ha_json.c` 相同)
|
|
||||||
|
|
||||||
```
|
|
||||||
{"choices":[{...content:"a"}],"choices":[{...content:"b"}]} → content="b"
|
|
||||||
{"usage":{"total_tokens":1},"usage":{"total_tokens":2}} → total=2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.7 `finish_reason` 语义
|
|
||||||
|
|
||||||
| 值 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| `null` | `Done=false`(指针为 nil) |
|
|
||||||
| `"stop"` | `Done=true`, `FinishReason="stop"` |
|
|
||||||
| `""` | `Done=false`(**空串不算终止信号**,注释说明是 sensenova 每块都发 `""`) |
|
|
||||||
| 缺失 | `Done=false` |
|
|
||||||
| `42` | 整块 FALSE |
|
|
||||||
|
|
||||||
### 2.8 非法 UTF-8:Go 侧替换为 U+FFFD
|
|
||||||
|
|
||||||
```
|
|
||||||
{"content":"\xff\xfe"} → content="\uFFFD\uFFFD"(两个替换字符)
|
|
||||||
```
|
|
||||||
|
|
||||||
⇒ C 侧的 `\uXXXX` 与字符串取值必须与 Go 的替换语义一致
|
|
||||||
(`utf8.RuneError` 编码为 `EF BF BD`,**一个非法字节 = 一个 U+FFFD**,
|
|
||||||
不是按序列整体丢弃)。
|
|
||||||
|
|
||||||
### 2.9 转义
|
|
||||||
|
|
||||||
`\" \\ \/ \n` 等正常解码;`你好😀` 直接 UTF-8 透传。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、不可协商的约束
|
|
||||||
|
|
||||||
1. **只吃 `(const char*, size_t)`**,不要求 NUL 结尾(否则又是 `strlen` + 拷贝的
|
|
||||||
老问题,见第一刀 §7.1 的 82% 自找开销教训)
|
|
||||||
2. **不 malloc**:结果用 **span(指针+长度)** 回给调用方,Go 侧零拷贝切片
|
|
||||||
3. **无状态纯函数、线程安全**
|
|
||||||
4. **顶层**:`ha_json_scan_chunks` 出 (span × N, 浅扫,含字符串内的 `{`/`}`,
|
|
||||||
让**顶层逗号分隔**可被切分——这正是协议层的需要:内容里的逗号不该错切顶层)
|
|
||||||
5. **语法**必须与 `encoding/json` 一致(含尾逗号非法、`null`/标量顶层非法、
|
|
||||||
重复键后者胜、大小写不敏感键匹配)
|
|
||||||
|
|
||||||
### 设计:scan(结构) + extract(取值) 两段分离
|
|
||||||
|
|
||||||
理由:`content` 可能是一个**很大**的多模态数组;而 `stringifyContent` 只需要
|
|
||||||
「text 字段拼起来」。若 scan 阶段就为每个字符串做 `\u` 解码并分配缓冲,
|
|
||||||
就等于把「解码」付给了不需要它的调用方。
|
|
||||||
|
|
||||||
故:
|
|
||||||
- **scan**:只出结构 span(键 span / 值 span)。零分配、零解码。
|
|
||||||
还要能**二次进数组内部**(取 `text` 字段)——故 API 需 `ha_json_skip`。
|
|
||||||
- **extract**:按 span 取值。字符串解码 (\u + 非法字节替换)、整数、布尔分别独立函数。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、落地记录(2026-09-26)
|
|
||||||
|
|
||||||
| 项 | 状态 | 证据 |
|
|
||||||
|---|---|---|
|
|
||||||
| Go 侧真值表 | ✅ | 本文 §二(含三处「纠正自己的错表」) |
|
|
||||||
| `ha_json_scan.{c,h}` | ✅ | 零分配、span 返回、scan/extract 两段分离 |
|
|
||||||
| C 契约测试 | ✅ | `test_ha_json_scan.c`:**119 项断言全过** |
|
|
||||||
| 黄金对照(逐值比对 Go) | ✅ | `codec_jsongolden_test.go`:语法/成员/解码/整数/随机字节 5 组全过 |
|
|
||||||
| 模糊测试 | ✅ | `test_fuzz_ha_json_scan.c`:**4948 万次运行零崩溃** |
|
|
||||||
| 接入 Go 生产路径 | ⏳ | **有意未做**:库先验完再换产线,接线是独立一步 |
|
|
||||||
|
|
||||||
### ★ 本刀被测试抓出的真实缺陷(7 个,全部记入代码注释防复发)
|
|
||||||
|
|
||||||
写 C 时**同一份逻辑我读了三遍都认为正确**,是测试把它们逐个揪出来的。
|
|
||||||
这正是「黄金对照 + 模糊测试」不可省的理由 —— 手写解析器的错不是崩溃,
|
|
||||||
而是**静默分叉**(少一个字符、某些块被丢弃),生产里极难归因。
|
|
||||||
|
|
||||||
| # | 缺陷 | 症状 | 谁抓到 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | 代理对合成成功后**未跳过** unconditionally 的 U+FFFD 发射 | `\ud83d\ude00`(😀)→ 两个 U+FFFD | 契约测试 |
|
|
||||||
| 2 | 过长编码检查用了**只含首字节位**的 cp | `你`(e4 bd a0) → 6 个 U+FFFD | 契约测试 |
|
|
||||||
| 3 | `members_next` 只报值起点、**不消费值** | 游标停在值前 → 下个成员解析到上一个值 | **模糊测试第一轮** |
|
|
||||||
| 4 | 扫描阶段**不校验**转义字符合法性 | `{"a":"\q"}` C 判合法、`json.Valid`=false | 黄金对照 |
|
|
||||||
| 5 | 扫描阶段**不校验** `\u` 后四位十六进制 | `{"a":"\u00"}` 同上 | 黄金对照 |
|
|
||||||
| 6 | `get_int` **接受前导零** | `007`/`00` C 认、JSON 非法 | 黄金对照 |
|
|
||||||
| 7 | cgo 桥接把 C 结构体声明为 Go 局部变量 | `cgo argument has Go pointer to unpinned Go pointer` panic | Go 运行时 |
|
|
||||||
|
|
||||||
**另外纠正了我自己两次错误的「真值」**(比代码 bug 更危险,因为它会变成错误的规格):
|
|
||||||
- 第一版真值表里 `content:{}` 的花括号**少了一层**,于是把「我写错了 JSON」
|
|
||||||
误读成「Go 对 content 类型严格」。修正后实测:`content:{}` → `"{}"`(**宽松**)。
|
|
||||||
- 由此才看出一对**方向相反**的语义:`content` 走 `interface{}` **宽松**
|
|
||||||
(`{}`→`"{}"`、`true`→`"true"`、`1.5`→`"1.5"`),而 `reasoning_content` /
|
|
||||||
`usage` / `finish_reason` 是**强类型严格**(`123` ⇒ 整块作废)。
|
|
||||||
若照错误的表去写 C,会产出一个「比 Go 更严格」的实现,静默丢弃本该生效的块。
|
|
||||||
|
|
||||||
### 两个设计决定(来自缺陷 3、7)
|
|
||||||
|
|
||||||
1. **`members_next` 返回完整值 span 并内部跳过它**
|
|
||||||
—— 让「返回 1」蕴含「该成员良构」。要求调用方自己推进游标的 API 是错的:
|
|
||||||
忘一次就会解析到上一个值(缺陷 3),而这种错**不会报错**。
|
|
||||||
2. **`members_complete()` 区分「正常扫到 `}`」与「输入畸形」**
|
|
||||||
—— 复刻 Go 的严格性必须能分辨二者,否则畸形输入会被当正常结束。
|
|
||||||
|
|
||||||
### 刻意保留的能力(当前调用方用不到,但设计上不该省)
|
|
||||||
|
|
||||||
`\uXXXX` 解码(含代理对合成)。本次内核的 Go 基线里没有这种输入(实测确认),
|
|
||||||
但**上游网关的行为不由我们控制** —— 日志与已拦缺陷记录显示,网关确会发
|
|
||||||
`content` 为 JSON 字符串的形态。留着它是防止未来某条上游路径切到转义形态时,
|
|
||||||
内容**静默变成 `?0?d?d?0`**(那正是 SDK `ha_json.c` 的缺陷 1 的形态)。
|
|
||||||
代价是约 10 行代码 + 一组已通过的测试。
|
|
||||||
|
|
||||||
## 五、接线前探明的六个**语义**(决定「C 化到什么程度」)
|
|
||||||
|
|
||||||
第二刀把库验完后,接线前又探了一轮 wire 语义。其中两条**直接推翻了
|
|
||||||
「整条 parseOpenAICompatibleStreamChunkFull 全 C 化」的设想**。
|
|
||||||
|
|
||||||
### 5.1 重复键是**字段级合并**(`json.Unmarshal` 的数组语义)
|
|
||||||
|
|
||||||
```
|
|
||||||
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{"reasoning_content":"r"}}]}
|
|
||||||
→ content="a" reasoning="r" ← 两个都保留!
|
|
||||||
{"choices":[{"delta":{"content":"a"}}],"choices":[{"delta":{}}]}
|
|
||||||
→ content="a" ← 第二次是空 delta,也没把 content 清掉
|
|
||||||
{"usage":{"prompt_tokens":1},"usage":{"completion_tokens":2}}
|
|
||||||
→ usage={1,2,0} ← 字段级合并
|
|
||||||
```
|
|
||||||
|
|
||||||
**机制**:`d.saveError(&d.array)` 保存目标;`object()` 收尾时执行
|
|
||||||
`v.SetIndex(i, subv.v)`,而 subv 解析时拿到的是**已存在元素的指针**
|
|
||||||
⇒ 第二次 unmarshal 是**叠加**在第一次之上的,不是替换。
|
|
||||||
|
|
||||||
⇒ 「第二个 element 整体覆盖第一个」是**错的**。正确实现需要维护
|
|
||||||
**「本次哪些字段出现过」的** 逐字段表**。这能做,但要显式建模。
|
|
||||||
|
|
||||||
### 5.2 `stringifyContent` 的默认分支 = `json.Marshal(interface{})`(**再编码**)
|
|
||||||
|
|
||||||
这是最关键的一条。`content` 是 `interface{}`,落到 default 分支时
|
|
||||||
**重新序列化一遍**:
|
|
||||||
|
|
||||||
| content 输入 | stringifyContent 输出 |
|
|
||||||
|---|---|
|
|
||||||
| `{"b":1,"a":2}` | `{"a":2,"b":1}`(**键排序**) |
|
|
||||||
| `{"k":"<a>&b"}` | `{"k":"\u003ca\u003e\u0026b"}`(**HTML 转义**) |
|
|
||||||
| `1e2` | `100`(float64 归一) |
|
|
||||||
| `1.0` | `1` |
|
|
||||||
| `123456789012345678` | `123456789012345680`(float64 舍入) |
|
|
||||||
| `1e21` | `1e+21` |
|
|
||||||
|
|
||||||
要让 C 版与 Go 逐值一致,就必须复刻 Go 的:
|
|
||||||
① 浮点**最短往返**格式化(`strconv.AppendFloat` 的 Ryu 语义,位数随值变化)
|
|
||||||
② `map` **按键排序**(Go 的 map 无序 ⇒ 排序是 Marshal 的确定性来源)
|
|
||||||
③ 字符串的 **HTML 转义 + `
/
` 转义**
|
|
||||||
④ int → **float64 舍入**再格式化
|
|
||||||
|
|
||||||
这不是「顺手写一下」的量级,而是一整套序列化器 + 一个浮点格式化器。
|
|
||||||
|
|
||||||
### 5.3 结论:C 化**降级**为「结构导航」层,序列化留在 Go
|
|
||||||
|
|
||||||
本条不是为了少做事,而是因为上面两条决定了一个可检验的事实:
|
|
||||||
|
|
||||||
> **C 负责把 JSON 定位到「哪个值在哪里」(零分配、零解码);
|
|
||||||
> Go 负责把「已定位的原始字节」变成 `interface{}`(`json.Unmarshal`),
|
|
||||||
> 再按既有逻辑变成字符串。**
|
|
||||||
|
|
||||||
C 层因此**无需**理解重复键的合并语义(5.1)、**无需**实现浮点格式化
|
|
||||||
与键排序(5.2)—— 它只回答「`choices[0].delta.content` 的 span 在哪」。
|
|
||||||
|
|
||||||
代价与收益(如实记录):
|
|
||||||
|
|
||||||
| | 收益 | 代价 |
|
|
||||||
|---|---|---|
|
|
||||||
| C 定位 | 免除 `json.Unmarshal` 的**反射建树**(每块 12~21 allocs 的主因) | 命中字段仍要一次小 `Unmarshal` |
|
|
||||||
| 保留 Go 序列化 | 5.1/5.2 的语义**逐字**保持,不需要两套实现 | 值转换仍有少量 alloc |
|
|
||||||
|
|
||||||
**唯一例外(已实测可达)**:`arguments` 若 upstream 发的是**非字符串**
|
|
||||||
对象/数组,Go 侧会 `json.Marshal` 重新编码(`{"b":2,"a":1}` → `{"a":1,"b":2}`),
|
|
||||||
**重新编码的键序可能与原文不同**。这类值必须走 Go(见接线实现的注释)。
|
|
||||||
|
|
||||||
### 5.4 其余四条语义(接线时直接照做即可)
|
|
||||||
|
|
||||||
| # | 语义 | 实测 |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | **key 大小写敏感**(map key) | `{"TEXT":"up"}` 取不到 `text`;但 `{"CHOICES":[{"DELTA":{"CONTENT":"ci"}}]}` 有效(struct 字段名不敏感) |
|
|
||||||
| 2 | `content` 数组:非对象元素**静默跳过** | `["a",{"text":"b"}]` → `"b"` |
|
|
||||||
| 3 | `content` 数组:`text` 非字符串**静默跳过** | `[{"text":123},{"text":"b"}]` → `"b"` |
|
|
||||||
| 4 | `index` 非整数 ⇒ **整块作废** | `{"index":1.5}` → false |
|
|
||||||
| 5 | usage 的 cache 字段类型错也**让整块作废** | `{"prompt_cache_hit_tokens":"x","prompt_tokens":1}` → false |
|
|
||||||
|
|
||||||
第 1 条与 ha_json_scan 的 `ha_json_key_eq`(大小写不敏感)**语义相反**,
|
|
||||||
两者用途不同、互不冲突(见 §5.3)—— 但必须在代码里注明,否则后人会「统一」掉。
|
|
||||||
|
|
||||||
## 六、接线实测:**本架构比原实现慢**(诚实记录,已默认关闭)
|
|
||||||
|
|
||||||
第三刀把 `ha_json_scan` 接进了生产路径(`chunkParseFast`),
|
|
||||||
**6 万+ 差分用例证明它与原实现逐值等价**(含语法、成员、解码、整数、
|
|
||||||
随机 JSON、随机字节五组)。但基准给出了**否定结论**,故**默认关闭**。
|
|
||||||
|
|
||||||
### 6.1 实测对比(`codec_chunkfast_bench_test.go`,20000 次迭代)
|
|
||||||
|
|
||||||
| 场景 | 新路径(Entry) | 原实现(GoOnly) | 结论 |
|
|
||||||
|---|---:|---:|---|
|
|
||||||
| content_zh | 2245 ns / 20 allocs | 2038 ns / 13 allocs | 更慢 |
|
|
||||||
| content_ascii | **2016 ns / 20 allocs** | **1325 ns / 13 allocs** | 慢 52% |
|
|
||||||
| toolcall | **5854 ns / 33 allocs** | **3270 ns / 21 allocs** | 慢 79% |
|
|
||||||
| usage | 3170 ns / 24 allocs | 2832 ns / 12 allocs | 更慢 |
|
|
||||||
| finish | 1560 ns / 20 allocs | 1098 ns / 12 allocs | 更慢 |
|
|
||||||
|
|
||||||
分配数**也变多**(20 vs 13)—— 与本刀「消除 GC 抖动」的初衷相反。
|
|
||||||
|
|
||||||
### 6.2 根因(逐项测出来的,不是猜的)
|
|
||||||
|
|
||||||
| 测量 | 数值 | 含义 |
|
|
||||||
|---|---:|---|
|
|
||||||
| 裸 cgo 调用(无 out-param) | **168 ns** | 一次性边界成本 |
|
|
||||||
| 带 out-param 的键查找 | **205 ns / 2 allocs** | 边界 + out-param 逃逸到堆 |
|
|
||||||
| 一次解析需要的键查找次数 | **5+** | choices→[0]→delta→content/reasoning/tool_calls→finish_reason |
|
|
||||||
|
|
||||||
⇒ **5 × 205ns ≈ 1µs 的边界与分配成本,恰好把收益全部吃掉。**
|
|
||||||
而 Go 侧是**一次** `json.Unmarshal` 遍历建整棵树。
|
|
||||||
|
|
||||||
**根因一句话**:本架构是「用很多次廉价调用,换一次昂贵调用」——
|
|
||||||
在这个尺寸上不划算。逐字段往返是设计错误,不是实现调优能救的。
|
|
||||||
|
|
||||||
### 6.3 天花板实验:方向对,但当前实现没到
|
|
||||||
|
|
||||||
为判断「还值不值得改」,我测了一个假设性上界 —— **假设拿到 span 完全免费**
|
|
||||||
(span 预先算好),只测本设计中**必须由 Go 做**的那部分:
|
|
||||||
|
|
||||||
| | ns/op | allocs |
|
|
||||||
|---|---:|---:|
|
|
||||||
| 我设计里的 Go 侧工作(零边界成本) | **505** | **7** |
|
|
||||||
| 原实现(整块 json.Unmarshal) | 1239 | 13 |
|
|
||||||
|
|
||||||
⇒ 若边界成本能压到近零,**仍有 2.4× 时间与 46% 分配的空间**。
|
|
||||||
故这不是「C 化没意义」,而是「**逐字段往返**这个交互方式是错的」。
|
|
||||||
|
|
||||||
### 6.4 正确的下一步(已由实测指明)
|
|
||||||
|
|
||||||
改造方向不是调优现有代码,而是**减少跨界次数**:
|
|
||||||
|
|
||||||
1. **一次 C 调用返回全部字段的 span**(批量),而不是逐字段往返
|
|
||||||
—— 把 5+ 次边界压成 1 次
|
|
||||||
2. **结果写入调用方栈上的 C 结构体**,消除 out-param 逃逸(那 2 allocs)
|
|
||||||
3. 仅在 content/usage **确需重新编码**时回退 Go
|
|
||||||
|
|
||||||
### 6.5 为什么把「一个没有启用的优化」连代码一起提交
|
|
||||||
|
|
||||||
- **正确性基准**:6 万+ 差分用例已把 C 与 Go 的逐值等价钉死,
|
|
||||||
这是改造的**已验证起点**(field-locating 与全部回退判据都验证正确了)
|
|
||||||
- **一条永不静默回退的机制**:`TestChunkFast_BenchGate` 断言
|
|
||||||
`chunkFastEnabled` 必须为 `false`。后来者看到「快速路径写得挺全 +
|
|
||||||
差分测试全过」,很自然会以为它已生效并打开它 —— 而实测它更慢。
|
|
||||||
断言把这个事实钉住,改动即判红。
|
|
||||||
- **诚实**:不把「写了但没效果」包装成「已完成」。
|
|
||||||
|
|
||||||
> 教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
|
|
||||||
> 第一刀推翻过一次(`C.CString` 造成 82% 自找开销),这一刀又推翻一次
|
|
||||||
> (逐字段往返造成 5+ 次边界)。两次都是**测量**推翻了直觉。
|
|
||||||
|
|
||||||
## 七、第三刀返工:批量定位(把 5+ 次边界压成 1 次)—— **部分成功,仍默认关闭**
|
|
||||||
|
|
||||||
§六 的否定结论指出根因是「逐字段往返」。本节按 §6.4 做架构改造并重测。
|
|
||||||
|
|
||||||
### 7.1 改造内容
|
|
||||||
|
|
||||||
| 项 | 改造前 | 改造后 |
|
|
||||||
|---|---|---|
|
|
||||||
| cgo 边界次数 | **5+**(每字段一次 findKey) | **1**(`ha_sse_chunk_locate`) |
|
|
||||||
| 键查找方式 | 每个键各扫一遍对象(6 趟) | **单趟分派**(遍历成员表一次就分发) |
|
|
||||||
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内解码进**一块** sbuf(1 次分配) |
|
|
||||||
| 成员表遍历 | 6 趟 | **2 趟**(顶层 + delta) |
|
|
||||||
|
|
||||||
顺带修掉两处自己造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
|
|
||||||
`choices` 数组的「数个数 + 取首元素」合一趟;`choice0` 内的
|
|
||||||
delta/finish_reason 合一趟。
|
|
||||||
|
|
||||||
### 7.2 实测(50000 次迭代 × 3 轮,取中位;`benchtime` 与机器同前)
|
|
||||||
|
|
||||||
| 场景 | 改造后 Entry | 原实现 GoOnly | 判定 |
|
|
||||||
|---|---:|---:|---|
|
|
||||||
| content_zh | **1540** ns / 5 allocs | 1871 ns / 13 allocs | ✅ **快 18%**,分配 -62% |
|
|
||||||
| content_ascii | 1250 ns / 5 allocs | 1304 ns / 13 allocs | ⚠ 持平,分配 -62% |
|
|
||||||
| finish | **820** ns / 6 allocs | 921 ns / 12 allocs | ✅ 快 11% |
|
|
||||||
| usage | 2530 ns / 9 allocs | 2591 ns / 12 allocs | ✅ 持平偏快 |
|
|
||||||
| toolcall | **3450** ns / 20 allocs | **3000** ns / 21 allocs | ❌ **慢 15%** |
|
|
||||||
|
|
||||||
**从「五项全输」变成「三项赢 / 一项持平 / 一项输」**,且**所有场景的
|
|
||||||
分配数都下降**(13→5、12→6、12→9)。
|
|
||||||
|
|
||||||
### 7.3 为什么 `toolcall` 仍输(根因已定位)
|
|
||||||
|
|
||||||
分解测量:
|
|
||||||
|
|
||||||
| 组成 | 成本 |
|
|
||||||
|---|---:|
|
|
||||||
| C 侧一次 `ha_sse_chunk_locate`(纯 C,零边界零分配) | **766 ns** |
|
|
||||||
| Go 侧 `[]openAIToolCall` unmarshal | **1305 ns / 15 allocs** |
|
|
||||||
| 对照:Go 整块 unmarshal(一次搞定) | ~2980 ns |
|
|
||||||
|
|
||||||
问题在第二行:tool_calls 的元素是**对象**,`Arguments interface{}` 需要
|
|
||||||
真实的 `map[string]interface{}`,所以**必须**走 encoding/json 的反射建树。
|
|
||||||
而我们为了定位又先做了一遍 C 扫描 —— 于是「扫两遍」必然慢于「扫一遍」。
|
|
||||||
|
|
||||||
⇒ **这不是 C 慢,是「同一份数据被解析了两次」**:
|
|
||||||
C 负责定位(读一遍),encoding/json 负责建树(再读一遍)。
|
|
||||||
对**标量**字段(content / reasoning / finish)C 能一次到位,所以那些场景赢;
|
|
||||||
对**需要建树**的字段(tool_calls / usage)C 的定位是纯开销。
|
|
||||||
|
|
||||||
**解法(下一步)**:tool_calls / usage 命中时**完全跳过 C 定位**,
|
|
||||||
直接让 encoding/json 整块处理 —— 也就是「**按字段类型决定要不要 C 化**」。
|
|
||||||
这需要一次「试解析」来判断字段是否需要建树,或改为「先看顶层键集合再决策」。
|
|
||||||
|
|
||||||
### 7.4 当前状态:仍默认关闭
|
|
||||||
|
|
||||||
「五项全输」→「三项赢一项输」,不足以打开默认开关,理由:
|
|
||||||
|
|
||||||
1. **toolcall 是真实负载里最常见的一类块**(任何一次工具调用流),
|
|
||||||
而它仍慢 15%。在真实会话里,工具调用往往比纯文本多。
|
|
||||||
2. **收益幅度不足以抵消风险**:18% 的时间收益 vs 引入一层
|
|
||||||
与 `encoding/json` 语义并存的第二实现。而 tool_calls 路径的
|
|
||||||
分配数几乎没降(20 vs 21)—— 本刀的原始动机(消除 GC 抖动)
|
|
||||||
在最需要它的场景**没有兑现**。
|
|
||||||
|
|
||||||
⇒ 继续做的前提是**先把 §7.3 的解法做掉**(按字段类型决定是否 C 化),
|
|
||||||
让 toolcall 也不输,再重测。届时再决定是否开启。
|
|
||||||
|
|
||||||
### 已知边界(诚实记录)
|
|
||||||
|
|
||||||
- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同,
|
|
||||||
32 位平台需截断检查)。当前未做平台相关处理 —— 内核只发布 linux/amd64 与
|
|
||||||
linux/arm64(均 64 位),故暂不构成问题,但若将来上 32 位需补。
|
|
||||||
- 契约测试里 `check_str` 的重载写法偏笨拙(C 无重载),但已够用。
|
|
||||||
|
|
||||||
> ⚠️ 纪律:与第一刀同 —— **C 与纯 Go 逐值等价由黄金对照测试钉死**,
|
|
||||||
> 且**不做按长度分派**(两条语义可能分叉的实现绝不允许同时在产线)。
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user