mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-29 22:05:25 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7e5d2a446 | |||
| c16411a77d | |||
| 5759e58bad | |||
| 275a054287 | |||
| 56c694cd7c | |||
| dc9e7d0495 | |||
| 29c61f0e07 | |||
| 82e8d9dbac | |||
| 1b8218afa9 | |||
| deeda22650 | |||
| 1d7c330cfc | |||
| bd73a9b241 | |||
| e417c69fc8 | |||
| a176cc3e20 | |||
| 255d6479ad | |||
| 9b6abe1b73 | |||
| 0a6e2b7dc4 | |||
| e97cafc8de | |||
| 5af2a86816 | |||
| 7717bf5ca5 | |||
| db207fd9b7 | |||
| 953fbb2f50 | |||
| 7ef9bc2ad3 | |||
| cfa72df3e9 | |||
| fe1c4cdb09 | |||
| a01fe21ab1 | |||
| f09891f054 | |||
| efb396d7b3 | |||
| 4852d70d77 | |||
| 63b6eafaf0 | |||
| 21221f20c5 | |||
| 11303e3ee4 | |||
| f4f6968987 |
251
.github/workflows/ci.yml
vendored
Normal file
251
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# HomeAgent SDK 仓 CI。
|
||||||
|
#
|
||||||
|
# 与 release.yml 分工:本文件管「推送到 main / PR 时的检查」,release.yml 管
|
||||||
|
# 「release/** 推送时的发版」。此前本仓**只有** release.yml —— main 的推送与
|
||||||
|
# PR 完全没有检查,而 SDK 是被外部插件开发者直接依赖的契约面(sdk/plugin.go
|
||||||
|
# 的接口一旦破,所有外部插件编译失败),必须有门。
|
||||||
|
#
|
||||||
|
# 设计原则与主仓一致:**CI 里每条命令都是本地已实测通过的**。
|
||||||
|
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态。
|
||||||
|
#
|
||||||
|
# 明确不进 CI 的:
|
||||||
|
# - `hmapdev skill install`(会写 ~/.claude、~/.codex 等**开发者本机目录**)
|
||||||
|
# - 需要内核仓在场的检查(本仓独立可测;sync-lua-sdk.sh 会在缺内核时
|
||||||
|
# 明确 skip 而不是假装成功)
|
||||||
|
# - 任何网络/真机依赖
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, 'release/**']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# SDK 本身不需要 cgo(hmapdev 与 sdk 包都是纯 Go)—— 与主仓相反。
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
GOFLAGS: -buildvcs=false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── 根模块:SDK 对外接口所在 ──
|
||||||
|
go:
|
||||||
|
name: Go build / vet / test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
- name: go build ./...
|
||||||
|
run: go build ./...
|
||||||
|
- name: go vet ./...
|
||||||
|
run: go vet ./...
|
||||||
|
- name: go test ./...
|
||||||
|
run: go test ./... -count=1 -timeout 10m
|
||||||
|
|
||||||
|
# ── hmapdev 工具链(嵌套 module,根模块的 ./... 不会进入它)──
|
||||||
|
hmapdev:
|
||||||
|
name: hmapdev (${{ matrix.goos }}/${{ matrix.goarch }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
ext: ''
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
ext: ''
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
ext: ''
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
ext: ''
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
ext: '.exe'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# tools/hmapdev 是**独立 module**(有自己的 go.mod)——
|
||||||
|
# 根模块的 `go test ./...` 不会进入它,必须单独跑,否则它的测试
|
||||||
|
# 永远不在 CI 里执行。这是 Go 的模块边界,不是配置问题。
|
||||||
|
- name: go test(嵌套 module)
|
||||||
|
working-directory: tools/hmapdev
|
||||||
|
run: go test ./... -count=1 -timeout 10m
|
||||||
|
|
||||||
|
# main 包在 tools/hmapdev —— 不指定工作目录会在仓库根跑 `go build .`,
|
||||||
|
# 而根目录没有 Go 文件(包在 sdk/ 子目录),报 "no Go files in ..."。
|
||||||
|
- name: 交叉编译
|
||||||
|
working-directory: tools/hmapdev
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
out="hmapdev_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
|
||||||
|
go build -trimpath \
|
||||||
|
-ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=0.0.0-ci" \
|
||||||
|
-o "/tmp/$out" .
|
||||||
|
test -s "/tmp/$out" || { echo "ERROR: 未产出 $out"; exit 1; }
|
||||||
|
printf " ✓ %-28s %s 字节\n" "$out" "$(stat -c %s "/tmp/$out")"
|
||||||
|
file "/tmp/$out"
|
||||||
|
|
||||||
|
# ── 示例插件:发版产物里最容易被使用者直接安装的东西 ──
|
||||||
|
#
|
||||||
|
# 为什么必须验:示例是使用者的模板(拷了就改),且**发版会连同 .hmap 一起发**
|
||||||
|
# —— 插件二进制与内核是协议绑定的(ProtocolVersion + 统一共享内存区魔数),
|
||||||
|
# 示例编不出来就意味着这次发版发不出配套产物。
|
||||||
|
#
|
||||||
|
# ★ 示例**不能**用 `go build` 验:它们是插件(只有 plugin.go、没有 func main),
|
||||||
|
# 必须由 hmapdev 注入 main 包装。直接 go build 会得到
|
||||||
|
# "function main is undeclared in the main package" —— 那不是缺陷,
|
||||||
|
# 是构建方式不对(本地实测确认)。
|
||||||
|
examples:
|
||||||
|
name: Examples (${{ matrix.target }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# windows 不在列:build-examples.sh 会明确拒绝(协议 2 的统一共享
|
||||||
|
# 内存区未移植到 Windows),那是有意的设计而不是待修缺陷。
|
||||||
|
target: ['linux/amd64', 'darwin/arm64']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 构建 hmapdev(宿主)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd tools/hmapdev
|
||||||
|
go build -o "$GITHUB_WORKSPACE/build/hmapdev_host" .
|
||||||
|
|
||||||
|
# 用发版同一个脚本(package/build-examples.sh),避免 CI 与发版路径分叉。
|
||||||
|
- name: 构建全部示例
|
||||||
|
env:
|
||||||
|
HMAPDEV: ${{ github.workspace }}/build/hmapdev_host
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bash package/build-examples.sh "$TARGET" /tmp/examples
|
||||||
|
echo
|
||||||
|
n=$(find /tmp/examples -name '*.hmap' | wc -l)
|
||||||
|
echo " .hmap 产物数: $n"
|
||||||
|
# 示例目录数应与产物数一致(少一个都要红)
|
||||||
|
want=$(find example -mindepth 2 -maxdepth 2 -name plugin.go | wc -l)
|
||||||
|
echo " 示例源码数: $want"
|
||||||
|
[ "$n" -ge "$want" ] || { echo "ERROR: 产物少于示例数"; exit 1; }
|
||||||
|
for f in /tmp/examples/*.hmap; do
|
||||||
|
printf " %9.1fKB %s\n" \
|
||||||
|
"$(stat -c %s "$f" | awk '{print $1/1024}')" "$(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 一致性与文档 ──
|
||||||
|
consistency:
|
||||||
|
name: Consistency & docs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# sdk.lua 有**四份**副本(本仓三份 + 内核仓一份),历史上漂移过,
|
||||||
|
# 出现「mock 有、内核没有」的静默失配。此处钉住本仓内的三份。
|
||||||
|
- name: Lua SDK 副本一致性
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
canon=sdk/lua/sdk.lua
|
||||||
|
[ -f "$canon" ] || { echo "ERROR: 缺 $canon"; exit 1; }
|
||||||
|
want=$(sha256sum "$canon" | cut -d' ' -f1)
|
||||||
|
fail=0
|
||||||
|
for f in tools/hmapdev/assets/sdk.lua example/luademo/sdk.lua; do
|
||||||
|
have=$(sha256sum "$f" | cut -d' ' -f1)
|
||||||
|
if [ "$have" = "$want" ]; then
|
||||||
|
echo " ✓ $f"
|
||||||
|
else
|
||||||
|
echo " ✗ $f 与 $canon 不一致" >&2
|
||||||
|
echo " canon=$want" >&2
|
||||||
|
echo " this =$have" >&2
|
||||||
|
echo " 解法:bash scripts/sync-lua-sdk.sh" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$fail" = "0" ] || exit 1
|
||||||
|
|
||||||
|
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
|
||||||
|
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
|
||||||
|
# 读根 go.mod 会装 1.21,examples 构建必然失败
|
||||||
|
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
|
||||||
|
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
|
||||||
|
# 所以本地「能过」不代表 CI 能过。
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# skills/ 会随 SDK store 分发给所有 agent(hmapdev skill install)。
|
||||||
|
# skill list 会读 skills/ 目录并报告可用项 —— 目录结构坏掉会在这里红。
|
||||||
|
- name: 技能目录可被 hmapdev 识别
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# ★ 必须把 hmapdev 建在**仓库内**:resolveSkillsSource() 先从
|
||||||
|
# 可执行文件位置向上找仓库(tools/hmapdev → ../../skills),
|
||||||
|
# 落空才回退到 SDK store 的活跃版本 —— 而 CI 里没有 store,
|
||||||
|
# 于是报 "no active SDK version set"。建到 /tmp 就会落空。
|
||||||
|
mkdir -p build
|
||||||
|
( cd tools/hmapdev && go build -o "$GITHUB_WORKSPACE/build/hmapdev_ci" . )
|
||||||
|
./build/hmapdev_ci skill list
|
||||||
|
./build/hmapdev_ci skill path
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
# 文档站会被部署(deploy-sdk-site.sh)——站点构建坏了此前无从发现。
|
||||||
|
# --strict 把 warning 升级为 error:断链、缺失引用都会红。
|
||||||
|
#
|
||||||
|
# ★ 不能用 yaml.safe_load 校验 mkdocs.yml:它含 mkdocs-material 的
|
||||||
|
# `!!python/name:` 标签(配置 emoji 的官方写法),safe_load 处理不了,
|
||||||
|
# 会报 ConstructorError —— 那是校验方式不对,不是配置有问题。
|
||||||
|
- name: 文档站构建(mkdocs --strict)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 -m pip install --quiet mkdocs-material
|
||||||
|
mkdocs build --strict --site-dir /tmp/site
|
||||||
314
.github/workflows/release.yml
vendored
Normal file
314
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
# HomeAgent SDK 仓发布流水线:release/** 推送即发版。
|
||||||
|
#
|
||||||
|
# 与主仓的 Release 流水线同构(见主仓 .github/workflows/release.yml),
|
||||||
|
# 差异只在产物与打包命令:
|
||||||
|
#
|
||||||
|
# 主仓 → 3 个 deb + 1 个 tar.gz(含 719MB 向量模型)
|
||||||
|
# SDK → hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe
|
||||||
|
# + SHA256SUMS(约 140MB)
|
||||||
|
#
|
||||||
|
# 产物清单依据:gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个),
|
||||||
|
# 以及 docs/git-branching.md §七 的「发版产物清单」。
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release/**']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_tests:
|
||||||
|
description: '跳过发版前的 go test 门(仅用于已知红的历史维护线)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
GOFLAGS: -buildvcs=false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
name: Prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.ver.outputs.version }}
|
||||||
|
tag: ${{ steps.ver.outputs.tag }}
|
||||||
|
prerelease: ${{ steps.ver.outputs.prerelease }}
|
||||||
|
exists: ${{ steps.ver.outputs.exists }}
|
||||||
|
skip_tests: ${{ steps.ver.outputs.skip_tests }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# 版本事实源是 meta/meta.go 的 Version,故发版动作 =
|
||||||
|
# 在 release/vX.Y.x 上把它改成目标版本后推送。
|
||||||
|
#
|
||||||
|
# 幂等闸门:tag 已存在 ⇒ 整轮跳过(改文档不会重发版)。
|
||||||
|
#
|
||||||
|
# go test 门可跳过:新旧发布线的测试健康状况不同,硬门会让历史
|
||||||
|
# 维护线完全无法发版。发版人可在**改动 meta 的那个提交**里写
|
||||||
|
# [skip-release-tests] 显式跳过 —— 决定因此可被 git 历史审计。
|
||||||
|
# (标记查在改 meta 的提交上而不是 HEAD:发版提交之后常还会跟
|
||||||
|
# 几个提交,只看 HEAD 会让标记被顶掉、静默失效。)
|
||||||
|
- id: ver
|
||||||
|
name: 读取 meta.Version 并检查 tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \
|
||||||
|
meta/meta.go | head -1)
|
||||||
|
if [ -z "$V" ]; then
|
||||||
|
echo "ERROR: 无法从 meta/meta.go 读出 Version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v$V" >> "$GITHUB_OUTPUT"
|
||||||
|
case "$V" in
|
||||||
|
*-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
|
||||||
|
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/v$V" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " tag v$V 已存在 —— 跳过发版"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " 将为 v$V 发版"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SKIP="${{ inputs.skip_tests }}"
|
||||||
|
REL_COMMIT=$(git log -1 --format=%H -- meta/meta.go)
|
||||||
|
REL_MSG=$(git log -1 --pretty=%B "$REL_COMMIT")
|
||||||
|
case "$REL_MSG" in
|
||||||
|
*'[skip-release-tests]'*) MARKER=1 ;;
|
||||||
|
*) MARKER=0 ;;
|
||||||
|
esac
|
||||||
|
echo " 发版提交: ${REL_COMMIT:0:12}"
|
||||||
|
if [ "$SKIP" = "true" ] || [ "$MARKER" = "1" ]; then
|
||||||
|
echo "skip_tests=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " ⚠️ **已请求跳过发版前的 go test 门**"
|
||||||
|
else
|
||||||
|
echo "skip_tests=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " 发版前会跑 go test 门"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build hmapdev
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# 门:go build 是硬门;go test 可在发版 commit 里显式跳过。
|
||||||
|
#
|
||||||
|
# 测试分两处跑 —— tools/hmapdev 是**独立 module**,根模块的
|
||||||
|
# `go test ./...` 不会进入它(这是 Go 的模块边界,不是配置问题)。
|
||||||
|
- name: go build(硬门)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
- name: go test(根模块)
|
||||||
|
if: needs.prepare.outputs.skip_tests != 'true'
|
||||||
|
run: go test ./... -count=1 -timeout 15m
|
||||||
|
|
||||||
|
- name: go test(tools/hmapdev 独立 module)
|
||||||
|
if: needs.prepare.outputs.skip_tests != 'true'
|
||||||
|
working-directory: tools/hmapdev
|
||||||
|
run: go test ./... -count=1 -timeout 15m
|
||||||
|
|
||||||
|
- name: go test 被跳过(显式声明的后果)
|
||||||
|
if: needs.prepare.outputs.skip_tests == 'true'
|
||||||
|
run: |
|
||||||
|
echo "::warning title=go test 门已跳过::本次发版未跑 go test,产物可能建立在单元测试失败的代码上。"
|
||||||
|
|
||||||
|
- name: 打包 hmapdev(5 个平台)
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf build
|
||||||
|
bash package/build.sh all hmapdev
|
||||||
|
echo
|
||||||
|
echo " 产物:"
|
||||||
|
for f in build/*; do
|
||||||
|
printf " %8.1fMB %s\n" \
|
||||||
|
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 逐个确认 5 个平台都产出了 —— 只查目录非空会漏掉"少了一个平台"。
|
||||||
|
- name: 验证产物齐全
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
missing=0
|
||||||
|
for f in hmapdev_linux_amd64 hmapdev_linux_arm64 \
|
||||||
|
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
|
||||||
|
hmapdev_windows_amd64.exe; do
|
||||||
|
if [ -s "build/$f" ]; then
|
||||||
|
echo " ✓ $f"
|
||||||
|
else
|
||||||
|
echo " ✗ 缺 $f" >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$missing" = "0" ] || exit 1
|
||||||
|
|
||||||
|
# 校验和必须**全部产物齐全之后**一次算完(边打边算会漏包);
|
||||||
|
# 且只覆盖本批产物 —— build/ 可能残留上次的,故先清干净再建。
|
||||||
|
- name: 生成 SHA256SUMS
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/out
|
||||||
|
cp build/hmapdev_linux_amd64 build/hmapdev_linux_arm64 \
|
||||||
|
build/hmapdev_darwin_amd64 build/hmapdev_darwin_arm64 \
|
||||||
|
build/hmapdev_windows_amd64.exe /tmp/out/
|
||||||
|
cd /tmp/out
|
||||||
|
sha256sum hmapdev_* > SHA256SUMS
|
||||||
|
echo " SHA256SUMS:"
|
||||||
|
sed 's/^/ /' SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: hmapdev
|
||||||
|
path: /tmp/out/*
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish
|
||||||
|
needs: [prepare, build]
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: hmapdev
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: 打 tag
|
||||||
|
env:
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "$TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
- name: 建 release 并上传附件
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
|
PRE: ${{ needs.prepare.outputs.prerelease }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd dist
|
||||||
|
FLAGS=()
|
||||||
|
[ "$PRE" = "true" ] && FLAGS+=(--prerelease)
|
||||||
|
gh release create "$TAG" \
|
||||||
|
--title "HomeAgent SDK $VERSION" \
|
||||||
|
--notes "HomeAgent 插件 SDK $VERSION
|
||||||
|
|
||||||
|
\`hmapdev\` 工具链(Linux / macOS / Windows,amd64 + arm64)。
|
||||||
|
校验见 SHA256SUMS。
|
||||||
|
|
||||||
|
内核版本需与 SDK 的中版本对齐;协议不配套时插件握手会失败
|
||||||
|
(魔数不匹配),此时升级内核或改用对应版本的 SDK。" \
|
||||||
|
"${FLAGS[@]}" \
|
||||||
|
./hmapdev_* ./SHA256SUMS
|
||||||
|
echo "=== release 内容 ==="
|
||||||
|
gh release view "$TAG" --json assets \
|
||||||
|
--jq '.assets[] | " \(.name) \(.size) 字节"'
|
||||||
|
|
||||||
|
- name: 回读校验
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/back && cd /tmp/back
|
||||||
|
# 同主仓:/tmp/back 不是 git 仓库,gh 无法从上下文推断仓库,
|
||||||
|
# 必须显式 --repo。
|
||||||
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY"
|
||||||
|
for f in *; do
|
||||||
|
printf " %8.1fMB %s\n" \
|
||||||
|
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$f"
|
||||||
|
done
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
echo " ✓ 回读校验通过"
|
||||||
|
|
||||||
|
sync-gitcode:
|
||||||
|
name: Sync to gitcode
|
||||||
|
needs: [prepare, publish]
|
||||||
|
if: needs.prepare.outputs.exists == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- id: tok
|
||||||
|
name: 检查 gitcode 凭据
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.GITCODE_TOKEN }}" ]; then
|
||||||
|
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo " 未配置 GITCODE_TOKEN —— 跳过 gitcode 同步"
|
||||||
|
fi
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
if: steps.tok.outputs.ok == 'true'
|
||||||
|
with:
|
||||||
|
name: hmapdev
|
||||||
|
path: dist
|
||||||
|
- name: 推 tag 与附件到 gitcode
|
||||||
|
if: steps.tok.outputs.ok == 'true'
|
||||||
|
env:
|
||||||
|
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
|
||||||
|
TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "$TAG" 2>/dev/null || true
|
||||||
|
GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com"
|
||||||
|
git push "${GC_URL}/JianFeeeee/homeagent-sdk.git" "$TAG"
|
||||||
|
curl -sS --max-time 60 -X POST \
|
||||||
|
-H "private-token: ${GC_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://gitcode.com/api/v5/repos/JianFeeeee/homeagent-sdk/releases" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \
|
||||||
|
-o /tmp/.gcrel -w " 建 release → %{http_code}\n"
|
||||||
|
cd dist
|
||||||
|
# 脚本的路径语义是 os.path.join(ASSET_DIR, name) ⇒
|
||||||
|
# 必须 cd 进资产目录、ASSET_DIR=.、并传**裸文件名**。
|
||||||
|
# 传 "./x" 或 "dist/x" 都会拼成 dist/dist/x 而找不到文件。
|
||||||
|
#
|
||||||
|
# 为何显式列名而不是让它自动扫描:自动扫描只认 ARTIFACT_SUFFIXES
|
||||||
|
# 里的扩展名,而 hmapdev 的产物多数**没有扩展名**(只有 windows
|
||||||
|
# 那个是 .exe)⇒ 自动扫描会静默地一个都不传。
|
||||||
|
ASSET_DIR=. GITCODE_REPO=JianFeeeee/homeagent-sdk \
|
||||||
|
python3 ../scripts/upload-assets.py "$TAG" "$GC_TOKEN" \
|
||||||
|
hmapdev_linux_amd64 hmapdev_linux_arm64 \
|
||||||
|
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
|
||||||
|
hmapdev_windows_amd64.exe SHA256SUMS
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -35,3 +35,13 @@ z_entry.c
|
|||||||
|
|
||||||
# plugindev binary in tools/
|
# plugindev binary in tools/
|
||||||
tools/plugindev/plugindev
|
tools/plugindev/plugindev
|
||||||
|
|
||||||
|
# 文档站构建产物(由 tools/apidoc/build.sh 生成)
|
||||||
|
site_build/
|
||||||
|
# mkdocs 缓存
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# agent 入口的「全文汇总」是派生件:由 gensite 把 docs/ 下所有 Markdown 拼成一份,
|
||||||
|
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
|
||||||
|
# llms.txt(索引,小且稳定)仍提交。
|
||||||
|
docs/llms-full.txt
|
||||||
|
|||||||
682
LICENSE
682
LICENSE
@ -1,661 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Copyright (c) 2026 JianFeeeee
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this license document, but changing it is not allowed.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
Preamble
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
furnished to do so, subject to the following conditions:
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
share and change all versions of a program--to make sure it remains free
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
software for all its users.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
When we speak of free software, we are referring to freedom, not
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
SOFTWARE.
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
113
README.md
113
README.md
@ -2,9 +2,18 @@
|
|||||||
|
|
||||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||||
|
|
||||||
|
> 📖 **完整文档**:<https://sdk.homeagent.jianfgit.xyz/>
|
||||||
|
>
|
||||||
|
> 快速开始 / API 参考 / 指南 / 示例插件都在那里,**本 README 只是速览**,
|
||||||
|
> 接口细节以文档站为准。直接进入:[快速开始](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
|
||||||
|
> · [API 参考](https://sdk.homeagent.jianfgit.xyz/api/) · [能力边界](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
|
||||||
|
>
|
||||||
|
> 面向 agent 的纯文本入口:[`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
|
||||||
|
> (含全部 API 的 [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt))。
|
||||||
|
|
||||||
## 版本与兼容性
|
## 版本与兼容性
|
||||||
|
|
||||||
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。
|
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
|
||||||
|
|
||||||
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||||
|
|
||||||
@ -12,11 +21,15 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
| 1.2.0 起 | 1.2.0 |
|
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||||
|
| 1.3.0 起 | **1.3.0** |
|
||||||
|
|
||||||
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||||
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||||
|
|
||||||
|
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
|
||||||
|
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
|
||||||
|
|
||||||
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
||||||
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
||||||
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
||||||
@ -28,6 +41,40 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
|||||||
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
||||||
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
||||||
|
|
||||||
|
## 1.3.0 新增:注入优先级与动态输出通道
|
||||||
|
|
||||||
|
### 注入优先级(`InjectOptions.Priority`)
|
||||||
|
|
||||||
|
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
|
||||||
|
|
||||||
|
| 级别 | 常量 | 谁用 |
|
||||||
|
|---|---|---|
|
||||||
|
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
|
||||||
|
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
|
||||||
|
|
||||||
|
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
|
||||||
|
⇒ 存量插件不需要改一行、也不需要重编。
|
||||||
|
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
|
||||||
|
(现场保存/恢复对插件透明)。
|
||||||
|
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
|
||||||
|
|
||||||
|
### 动态输出通道(`UnregisterOutputChannel`)
|
||||||
|
|
||||||
|
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
|
||||||
|
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
|
||||||
|
|
||||||
|
1.3.0 起成对提供:
|
||||||
|
|
||||||
|
| API | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
|
||||||
|
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
|
||||||
|
|
||||||
|
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
|
||||||
|
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
|
||||||
|
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
|
||||||
|
派生规则与约束见下方「输出通道」一节。
|
||||||
|
|
||||||
## 注入行为与上下文裁剪(1.2.0)
|
## 注入行为与上下文裁剪(1.2.0)
|
||||||
|
|
||||||
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
|
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
|
||||||
@ -92,7 +139,7 @@ type Plugin interface {
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
|
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码 |
|
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
|
||||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||||
@ -139,6 +186,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
|
|||||||
|
|
||||||
### 输出通道
|
### 输出通道
|
||||||
|
|
||||||
|
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
|
||||||
|
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
|
||||||
|
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
|
||||||
|
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
|
||||||
|
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
|
||||||
|
> 内核**不会**替你净化。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
||||||
```
|
```
|
||||||
@ -291,15 +346,23 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
|
|||||||
## hmapdev 工具链
|
## hmapdev 工具链
|
||||||
|
|
||||||
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
|
||||||
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
|
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),
|
||||||
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
|
下载后加入 PATH 即可:
|
||||||
|
|
||||||
|
> ⚠️ **二进制的实际分发地址目前是 gitcode**(两个仓的 release 附件不同步):
|
||||||
|
> `hmapdev_linux_amd64` 等 5 个平台二进制 + `SHA256SUMS` 在
|
||||||
|
> <https://gitcode.com/JianFeeeee/homeagent-sdk/releases>。
|
||||||
|
> GitHub 侧(<https://github.com/JianFeeeee/homeagentsdk/releases>)从
|
||||||
|
> 下一个 SDK 版本(v1.4.0)起才会同步发布 —— 因为 SDK 仓**移仓后还没发过版**。
|
||||||
|
> 源码与文档一律以 GitHub 为准,**只有二进制暂时还得到 gitcode 取**。
|
||||||
|
|
||||||
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
|
||||||
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
> (旧目录会被自动沿用,不会丢已装版本)。
|
> (旧目录会被自动沿用,不会丢已装版本)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
|
# 从 release 附件下载(以 linux amd64 为例,<版本> 如 v1.3.0)
|
||||||
|
# 当前实际分发地址是 gitcode(见下方说明):
|
||||||
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
|
||||||
chmod +x hmapdev
|
chmod +x hmapdev
|
||||||
|
|
||||||
@ -441,6 +504,18 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
插件崩溃时平台自动拉起,保障服务可用性。
|
插件崩溃时平台自动拉起,保障服务可用性。
|
||||||
|
|
||||||
|
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
|
||||||
|
|
||||||
|
| 参数 | 值 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
|
||||||
|
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
|
||||||
|
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
|
||||||
|
|
||||||
|
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
|
||||||
|
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
|
||||||
|
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
|
||||||
|
|
||||||
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
||||||
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
||||||
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
||||||
@ -898,14 +973,30 @@ curl -X POST http://127.0.0.1:9876/plugins \
|
|||||||
|
|
||||||
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 文档站:**<https://sdk.homeagent.jianfgit.xyz/>**
|
||||||
|
- 快速开始:[环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/) · [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin/) · [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin/)
|
||||||
|
- API 参考:[总览](https://sdk.homeagent.jianfgit.xyz/api/) · [工具](https://sdk.homeagent.jianfgit.xyz/api/tools/) · [阶段钩子](https://sdk.homeagent.jianfgit.xyz/api/stages/) · [记忆](https://sdk.homeagent.jianfgit.xyz/api/memory/) · [输入/输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels/)
|
||||||
|
- 指南:[能力边界(哪些 API 外部可用)](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/) · [工具并发声明](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration/) · [流式多 tool_call](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index/) · [场景记忆](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory/) · [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging/)
|
||||||
|
- 版本与兼容:<https://sdk.homeagent.jianfgit.xyz/versions/>
|
||||||
|
- 核心仓(内核 / 记忆 / 调度):<https://github.com/JianFeeeee/HomeAgent>
|
||||||
|
- 介绍站:<https://introduce.homeagent.jianfgit.xyz/>
|
||||||
|
|
||||||
|
> 文档站为自托管(国内直连,不必挂代理)。构建与部署见核心仓的
|
||||||
|
> `deploy-sdk-site.sh`(`--check` 只比对、`--rollback` 回滚)。
|
||||||
|
|
||||||
## 许可
|
## 许可
|
||||||
|
|
||||||
SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。
|
SDK 以 **MIT** 发布,全文见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
**这对插件开发者是实质性约束**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
**这是刻意的宽松**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
|
||||||
插件因此是本 SDK 的衍生作品,**必须以相同许可(AGPL-3.0-only)发布**;并且因为 AGPL §13
|
若用 AGPL 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了
|
||||||
覆盖网络交互,通过 HTTP/WebSocket 等向用户提供服务的插件同样要向使用者提供源码。
|
让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈,
|
||||||
若你的插件需要闭源,唯一合规路径是另行取得本项目的例外/商业授权——目前不提供。
|
也无需取得任何例外或商业授权。第三方插件生态的安全与活跃正建立在这条之上。
|
||||||
|
|
||||||
|
前提是 SDK 本身**完全自包含**:`go.mod` 零外部依赖,`sdk/` 只依赖 Go 标准库
|
||||||
|
(`sync`),不引用核心仓的任何代码,因此 MIT 授权不与其他许可冲突。
|
||||||
|
|
||||||
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
|
||||||
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
|
||||||
|
|||||||
65
README_EN.md
65
README_EN.md
@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||||
|
|
||||||
|
> 📖 **Full documentation**: <https://sdk.homeagent.jianfgit.xyz/>
|
||||||
|
>
|
||||||
|
> Quick start / API reference / guides / example plugins all live there. **This README is a
|
||||||
|
> summary only** — the docs site is authoritative for interface details.
|
||||||
|
> Jump to: [Getting started](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
|
||||||
|
> · [API reference](https://sdk.homeagent.jianfgit.xyz/api/) · [Capability boundary](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
|
||||||
|
>
|
||||||
|
> Agent-friendly plain-text entry points: [`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
|
||||||
|
> and [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt) (the whole docs in one file).
|
||||||
|
|
||||||
## Version and Compatibility
|
## Version and Compatibility
|
||||||
|
|
||||||
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
|
||||||
|
|
||||||
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||||||
|
|
||||||
@ -12,13 +22,33 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||||
| 1.2.0 onward | 1.2.0 |
|
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||||
|
| 1.3.0 onward | **1.3.0** |
|
||||||
|
|
||||||
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||||||
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||||||
forced to chase releases or suspect your version is stale, when not one character of the interface
|
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||||||
has changed.
|
has changed.
|
||||||
|
|
||||||
|
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
|
||||||
|
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
|
||||||
|
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
|
||||||
|
|
||||||
|
## New in 1.3.0: Injection Priority and Dynamic Output Channels
|
||||||
|
|
||||||
|
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
|
||||||
|
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
|
||||||
|
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
|
||||||
|
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
|
||||||
|
Queued input has no level: anything can jump ahead of it.
|
||||||
|
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
|
||||||
|
channels that die with their resource (one channel per remote device) can now be unregistered;
|
||||||
|
previously they lingered and the model kept "successfully" sending into a dead channel.
|
||||||
|
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
|
||||||
|
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
|
||||||
|
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
|
||||||
|
external IDs — never use the raw ID.
|
||||||
|
|
||||||
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||||||
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||||||
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||||||
@ -423,6 +453,19 @@ enabled := sdk.AutoRestart()
|
|||||||
|
|
||||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||||
|
|
||||||
|
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
|
||||||
|
|
||||||
|
| Parameter | Value | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
|
||||||
|
| `procMaxRestarts` | `3` | Max restarts within the window |
|
||||||
|
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
|
||||||
|
|
||||||
|
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
|
||||||
|
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
|
||||||
|
This is not instant, invisible recovery — if your plugin must be back in seconds,
|
||||||
|
reconnect and rebuild your own state in `OnStart`.
|
||||||
|
|
||||||
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||||||
> external connection has been established, and that connection setup usually happens in a
|
> external connection has been established, and that connection setup usually happens in a
|
||||||
> background goroutine while the kernel reads the flag from another one — which is inherently
|
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||||||
@ -816,14 +859,18 @@ Or upload via the WebUI plugin management page, or manually place the `.hmap` in
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE).
|
The SDK is released under the **MIT license** — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
|
**This permissiveness is deliberate**: the SDK is **statically linked** into your plugin
|
||||||
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
|
(its source ends up in the plugin binary). Under a copyleft license such as AGPL that would
|
||||||
this SDK and **must be released under the same license**. Because AGPL §13 covers network
|
force plugin authors to open-source their work; MIT exists precisely so that plugin authors
|
||||||
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
|
can **pick their own license** — closed-source, commercial or private — with no obligation to
|
||||||
If you need a closed-source plugin, the only compliant route is a separate exception/commercial
|
contribute back and no need for any exception or commercial grant. The safety and vitality of
|
||||||
license from this project — none is offered today.
|
the third-party plugin ecosystem rest on this.
|
||||||
|
|
||||||
|
This is sound because the SDK is **fully self-contained**: `go.mod` has zero external
|
||||||
|
dependencies and `sdk/` imports only the Go standard library (`sync`), never any code from the
|
||||||
|
core repository — so the MIT grant conflicts with nothing.
|
||||||
|
|
||||||
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
|
||||||
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
|
||||||
|
|||||||
204
docs/api/bridge.md
Normal file
204
docs/api/bridge.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 桥接装配点(Bridge)
|
||||||
|
|
||||||
|
以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。
|
||||||
|
|
||||||
|
### `APIRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type APIRegistrar func(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
APIRegistrar registers a plugin API for external access.
|
||||||
|
|
||||||
|
<small>`plugin.go:410`</small>
|
||||||
|
|
||||||
|
### `InputChannelRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InputChannelRegistrar func(name string, def ChannelDef) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InputChannelRegistrar registers an input channel with its memory behavior.
|
||||||
|
|
||||||
|
<small>`plugin.go:413`</small>
|
||||||
|
|
||||||
|
### `OutputChannelRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||||
|
|
||||||
|
<small>`plugin.go:416`</small>
|
||||||
|
|
||||||
|
### `OutputChannelUnregistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type OutputChannelUnregistrar func(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
OutputChannelUnregistrar 注销一个输出通道。
|
||||||
|
|
||||||
|
为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
|
||||||
|
动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
|
||||||
|
必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
|
||||||
|
|
||||||
|
<small>`plugin.go:423`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetDocMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:718`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetEventSubscriber`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:742`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetIOInjector`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetIOInjector(io IOInjector)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetIOInjector sets the IO injector (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:699`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetInputChannelRegistrar`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:692`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetKnowledgeAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:724`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetLLMAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:730`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetMemoryAPI sets the memory API (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:706`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelRegistrar`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:678`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelUnregistrar`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上,桥接模板不注入。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:685`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetPluginMgrAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:749`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetSocialAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetSocialAPI(social SocialAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:736`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetTextMemoryAPI`
|
||||||
|
|
||||||
|
!!! info "桥接装配点"
|
||||||
|
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:712`</small>
|
||||||
|
|
||||||
|
### `ToolRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolRegistrar registers a tool dynamically.
|
||||||
|
|
||||||
|
<small>`plugin.go:404`</small>
|
||||||
|
|
||||||
79
docs/api/builtin-only.md
Normal file
79
docs/api/builtin-only.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 仅内置插件可用的 API
|
||||||
|
|
||||||
|
这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。
|
||||||
|
|
||||||
|
判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。
|
||||||
|
|
||||||
|
### `PriorityL4`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL4
|
||||||
|
```
|
||||||
|
|
||||||
|
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:165`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Events`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:550`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetEventSubscriber`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:742`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetOutputChannelUnregistrar`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
同上,桥接模板不注入。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
|
||||||
|
|
||||||
|
<small>`plugin.go:685`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.UnregisterOutputChannel`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) UnregisterOutputChannel(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
|
||||||
|
|
||||||
|
<small>`plugin.go:643`</small>
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
650
docs/api/channels.md
Normal file
650
docs/api/channels.md
Normal file
@ -0,0 +1,650 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 输入 / 输出通道
|
||||||
|
|
||||||
|
通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。
|
||||||
|
|
||||||
|
## `IOInjector`
|
||||||
|
|
||||||
|
IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
|
All methods accept (source, channel) where channel is the target output channel
|
||||||
|
for routing the agent's response.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`InjectInputMedia`](#ioinjectorinjectinputmedia) | |
|
||||||
|
| [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | |
|
||||||
|
| [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | |
|
||||||
|
| [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | |
|
||||||
|
| [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 |
|
||||||
|
| [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | |
|
||||||
|
| [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | |
|
||||||
|
| [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | |
|
||||||
|
| [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | |
|
||||||
|
| [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | |
|
||||||
|
| [`InjectText`](#ioinjectorinjecttext) | |
|
||||||
|
| [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | |
|
||||||
|
| [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 |
|
||||||
|
| [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 |
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:329`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:340`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:330`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:341`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSync(source, channel, text string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||||
|
用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:325`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:339`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:331`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:342`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:320`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:338`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:321`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:322`</small>
|
||||||
|
|
||||||
|
### `IOInjector.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||||
|
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
|
||||||
|
|
||||||
|
<small>`plugin.go:337`</small>
|
||||||
|
|
||||||
|
### `IOInjector.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
|
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
|
|
||||||
|
<small>`plugin.go:328`</small>
|
||||||
|
|
||||||
|
### `CapAudio`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapAudio
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:430`</small>
|
||||||
|
|
||||||
|
### `CapFile`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapFile
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:428`</small>
|
||||||
|
|
||||||
|
### `CapImage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:429`</small>
|
||||||
|
|
||||||
|
### `CapStructured`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapStructured
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:431`</small>
|
||||||
|
|
||||||
|
### `CapText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const CapText
|
||||||
|
```
|
||||||
|
|
||||||
|
Output capability flags
|
||||||
|
|
||||||
|
<small>`plugin.go:427`</small>
|
||||||
|
|
||||||
|
### `ChannelDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json: …
|
||||||
|
```
|
||||||
|
|
||||||
|
ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||||
|
NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||||
|
Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||||
|
ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||||
|
RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
|
||||||
|
ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
|
||||||
|
|
||||||
|
JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||||
|
没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||||
|
那样新增字段会被静默丢掉。
|
||||||
|
|
||||||
|
<small>`plugin.go:178`</small>
|
||||||
|
|
||||||
|
### `ContextPolicyNone`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ContextPolicyNone
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
|
||||||
|
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
|
||||||
|
<small>`plugin.go:44`</small>
|
||||||
|
|
||||||
|
### `ContextPolicyPrune`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const ContextPolicyPrune
|
||||||
|
```
|
||||||
|
|
||||||
|
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||||
|
|
||||||
|
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||||
|
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||||
|
插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||||
|
|
||||||
|
<small>`plugin.go:45`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||||
|
blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||||
|
发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||||
|
的「下一轮 tool message」语义。
|
||||||
|
等价于 InjectInputMediaOpts(..., InjectOptions{})。
|
||||||
|
|
||||||
|
<small>`plugin.go:805`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:844`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||||
|
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
|
||||||
|
|
||||||
|
<small>`plugin.go:811`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputMediaSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:851`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputSync`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputSync(source, channel, text string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||||
|
returning the reply text (empty string if none). Replies must be dispatched back
|
||||||
|
to the source channel by the caller.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
|
||||||
|
|
||||||
|
<small>`plugin.go:796`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInputSyncOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:835`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||||
|
blocks 随中断消息一起发给模型。
|
||||||
|
|
||||||
|
<small>`plugin.go:868`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptMediaOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:860`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
|
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
|
|
||||||
|
<small>`plugin.go:777`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectInterruptTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
|
||||||
|
|
||||||
|
中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
|
||||||
|
是否需要据此裁剪由调用方决定(默认不裁剪)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:828`</small>
|
||||||
|
|
||||||
|
### `InjectOptions`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InjectOptions struct { NoMemory bool ContextPolicy string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 // 空串 = 默认(输入/注<><E6B3A8> …
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||||
|
|
||||||
|
零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||||
|
因此调用方只有在确实需要改变行为时才需要填它。
|
||||||
|
|
||||||
|
为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
|
||||||
|
属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
|
||||||
|
也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
|
||||||
|
|
||||||
|
NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
|
ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||||
|
RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
|
||||||
|
|
||||||
|
中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||||
|
|
||||||
|
CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
|
||||||
|
|
||||||
|
空串 = 按注入的 source 查通道定义(既有行为)。
|
||||||
|
为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
|
||||||
|
而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
|
||||||
|
不指定就只能退到「按 source 查不到就不清洗」。
|
||||||
|
|
||||||
|
<small>`plugin.go:132`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectText`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectText(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectText injects a text message into the agent pipeline.
|
||||||
|
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||||
|
|
||||||
|
<small>`plugin.go:783`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectTextNoMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectTextNoMemory injects a text message without generating memory.
|
||||||
|
等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
|
||||||
|
|
||||||
|
<small>`plugin.go:789`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.InjectTextOpts`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
|
||||||
|
|
||||||
|
<small>`plugin.go:818`</small>
|
||||||
|
|
||||||
|
### `PriorityL1`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL1
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:161`</small>
|
||||||
|
|
||||||
|
### `PriorityL2`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL2
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:162`</small>
|
||||||
|
|
||||||
|
### `PriorityL3`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL3
|
||||||
|
```
|
||||||
|
|
||||||
|
中断优先级取值。
|
||||||
|
|
||||||
|
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
|
||||||
|
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
|
||||||
|
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:163`</small>
|
||||||
|
|
||||||
|
### `PriorityL4`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PriorityL4
|
||||||
|
```
|
||||||
|
|
||||||
|
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
|
||||||
|
|
||||||
|
<small>`plugin.go:165`</small>
|
||||||
|
|
||||||
|
### `RecallPolicyAuto`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const RecallPolicyAuto
|
||||||
|
```
|
||||||
|
|
||||||
|
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
|
||||||
|
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
|
||||||
|
<small>`plugin.go:65`</small>
|
||||||
|
|
||||||
|
### `RecallPolicyNone`
|
||||||
|
|
||||||
|
```go
|
||||||
|
const RecallPolicyNone
|
||||||
|
```
|
||||||
|
|
||||||
|
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
|
||||||
|
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
|
||||||
|
<small>`plugin.go:64`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterInputChannel`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterInputChannel registers an input channel with its memory behavior.
|
||||||
|
|
||||||
|
契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
|
||||||
|
注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**:
|
||||||
|
只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子";
|
||||||
|
没登记就划分会直接失败(`inputch 未注册`)。
|
||||||
|
|
||||||
|
只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名
|
||||||
|
inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
|
||||||
|
|
||||||
|
def.NoMemory: 此通道输入不参与记忆计算
|
||||||
|
def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
|
||||||
|
|
||||||
|
<small>`plugin.go:665`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterOutputChannel`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterOutputChannel registers an output channel that the output_send tool can route to.
|
||||||
|
|
||||||
|
与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__<name> 的回复发给谁);
|
||||||
|
入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
||||||
|
若该通道同时也是你的注入入口,两个都要登记。
|
||||||
|
|
||||||
|
name: channel name (e.g. "qq", "webui")。
|
||||||
|
|
||||||
|
❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
|
||||||
|
而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
|
||||||
|
而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
|
||||||
|
所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
|
||||||
|
若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
|
||||||
|
而不是把原始值直接当通道名。
|
||||||
|
caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||||
|
desc: description of the channel, expected meta format, and type enum
|
||||||
|
def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||||
|
handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:57` | `if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args…` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:57` | `s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[strin…` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
|
||||||
|
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:632`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetToolBlocks`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
|
||||||
|
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
|
||||||
|
|
||||||
|
<small>`plugin.go:876`</small>
|
||||||
|
|
||||||
|
### `ValidContextPolicy`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidContextPolicy(policy string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
|
||||||
|
|
||||||
|
<small>`plugin.go:49`</small>
|
||||||
|
|
||||||
|
### `ValidRecallPolicy`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ValidRecallPolicy(policy string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
|
||||||
|
|
||||||
|
<small>`plugin.go:69`</small>
|
||||||
|
|
||||||
86
docs/api/constants.md
Normal file
86
docs/api/constants.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 常量与枚举
|
||||||
|
|
||||||
|
SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。
|
||||||
|
|
||||||
|
## StageOnInput 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `StageOnInput` | |
|
||||||
|
| `StagePreAction` | |
|
||||||
|
| `StagePostAction` | |
|
||||||
|
| `StageBeforeToolcall` | |
|
||||||
|
| `StageAfterToolcall` | |
|
||||||
|
| `StageBeforeOutput` | |
|
||||||
|
| `StageAfterOutput` | |
|
||||||
|
|
||||||
|
## ContextPolicyNone 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ContextPolicyNone` | |
|
||||||
|
| `ContextPolicyPrune` | |
|
||||||
|
|
||||||
|
## RecallPolicyNone 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `RecallPolicyNone` | |
|
||||||
|
| `RecallPolicyAuto` | |
|
||||||
|
|
||||||
|
## ScenePolicyAuto 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ScenePolicyAuto` | |
|
||||||
|
| `ScenePolicyNone` | |
|
||||||
|
|
||||||
|
## PriorityL1 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `PriorityL1` | |
|
||||||
|
| `PriorityL2` | |
|
||||||
|
| `PriorityL3` | |
|
||||||
|
| `PriorityL4` | PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 |
|
||||||
|
|
||||||
|
## EventRawInput 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `EventRawInput` | |
|
||||||
|
| `EventAgentOutput` | |
|
||||||
|
| `EventAgentLLMChain` | |
|
||||||
|
| `EventToolCall` | |
|
||||||
|
| `EventReasoning` | |
|
||||||
|
| `EventStage` | |
|
||||||
|
| `EventSystem` | |
|
||||||
|
| `EventReasoningDelta` | 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 |
|
||||||
|
| `EventContentDelta` | |
|
||||||
|
|
||||||
|
## StageScopeGlobal 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `StageScopeGlobal` | StageScopeGlobal receives all stage events (default). |
|
||||||
|
| `StageScopeOwnTools` | StageScopeOwnTools only receives events for this plugin's own tool calls |
|
||||||
|
|
||||||
|
## CapText 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `CapText` | |
|
||||||
|
| `CapFile` | |
|
||||||
|
| `CapImage` | |
|
||||||
|
| `CapAudio` | |
|
||||||
|
| `CapStructured` | |
|
||||||
|
|
||||||
|
## ProxyAuthHomeAgent 等
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ProxyAuthHomeAgent` | ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 |
|
||||||
|
| `ProxyAuthNone` | ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 |
|
||||||
|
|
||||||
69
docs/api/events.md
Normal file
69
docs/api/events.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 事件(Events)
|
||||||
|
|
||||||
|
订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。
|
||||||
|
|
||||||
|
## `EventSubscriber`
|
||||||
|
|
||||||
|
EventSubscriber allows plugins to subscribe to kernel events.
|
||||||
|
This is a restricted interface: plugins can subscribe but the kernel
|
||||||
|
controls which events are delivered.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Subscribe`](#eventsubscribersubscribe) | |
|
||||||
|
|
||||||
|
### `EventSubscriber.Subscribe`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
|
||||||
|
```go
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:378`</small>
|
||||||
|
|
||||||
|
### `Event`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event struct { Type EventType `json:"type"` Source string `json:"source"` Payload map[string]interface{} `json:"payload"` T …
|
||||||
|
```
|
||||||
|
|
||||||
|
Event represents a system event published by the kernel.
|
||||||
|
|
||||||
|
<small>`plugin.go:364`</small>
|
||||||
|
|
||||||
|
### `EventHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EventHandler func(evt *Event)
|
||||||
|
```
|
||||||
|
|
||||||
|
EventHandler processes a system event.
|
||||||
|
|
||||||
|
<small>`plugin.go:372`</small>
|
||||||
|
|
||||||
|
### `EventType`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EventType string
|
||||||
|
```
|
||||||
|
|
||||||
|
EventType identifies the kind of system event.
|
||||||
|
|
||||||
|
<small>`plugin.go:346`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Events`
|
||||||
|
|
||||||
|
!!! warning "仅内核内置插件可用"
|
||||||
|
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Events() EventSubscriber
|
||||||
|
```
|
||||||
|
|
||||||
|
Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:550`</small>
|
||||||
|
|
||||||
39
docs/api/index.md
Normal file
39
docs/api/index.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# API 参考
|
||||||
|
|
||||||
|
本页所有内容**从源码生成**(`tools/apidoc`),签名与说明直接取自 `sdk/*.go` 的
|
||||||
|
文档注释。因此不存在「文档写了一套、代码是另一套」的情况——发现不一致时,
|
||||||
|
改的是源码注释,不是这里。
|
||||||
|
|
||||||
|
## 怎么找 API
|
||||||
|
|
||||||
|
<div id="api-search"></div>
|
||||||
|
|
||||||
|
用上面的搜索框可以:
|
||||||
|
|
||||||
|
- **按名称搜**:`InjectText`、`RegisterTool`、`memory.recall`
|
||||||
|
- **按描述搜**:`注册工具`、`注入`、`重载`、`崩溃`
|
||||||
|
- **按签名搜**:`(string) error`、`[]ContentBlock`
|
||||||
|
- 带 <span class="api-badge api-badge-builtin">仅内置</span>
|
||||||
|
标记的条目在**外部插件里拿不到**,多数情况下你不需要它
|
||||||
|
|
||||||
|
## 章节划分
|
||||||
|
|
||||||
|
按「你想做什么」组织,不是按 Go 的符号类别:
|
||||||
|
|
||||||
|
| 章节 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [工具(Tools)](tools.md) | 注册 LLM 可调用的工具——插件最常用的能力形态 |
|
||||||
|
| [阶段钩子(Stages)](stages.md) | 在处理管道的固定点位插入逻辑 |
|
||||||
|
| [记忆(Memory)](memory.md) | 三层记忆的读写:图 / 文档 / 文本,以及知识库 |
|
||||||
|
| [输入/输出通道](channels.md) | 与外界交换消息,以及往流水线里注入内容 |
|
||||||
|
| [配置(Settings)](settings.md) | 声明插件配置项,内核渲染到 WebUI |
|
||||||
|
| [生命周期(Lifecycle)](lifecycle.md) | 启动、停止、卸载、自动重启 |
|
||||||
|
| [事件(Events)](events.md) | 订阅内核事件 |
|
||||||
|
| [LLM 调用](llm.md) | 插件主动调用模型 |
|
||||||
|
| [常量与枚举](constants.md) | 取值枚举 |
|
||||||
|
| [桥接装配点](bridge.md) | 由 `hmapdev` 生成的运行时调用,插件业务代码不碰 |
|
||||||
|
| [仅内置插件可用](builtin-only.md) | 边界汇总——外部插件拿不到的 API 全在这里 |
|
||||||
|
|
||||||
|
!!! tip "先看「能力边界」能省很多时间"
|
||||||
|
如果你正在设计插件,先读 [能力边界](../guide/capability-boundary.md):
|
||||||
|
它说明哪些能力外部插件有、哪些没有,以及**为什么**。
|
||||||
209
docs/api/lifecycle.md
Normal file
209
docs/api/lifecycle.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 生命周期(Lifecycle)
|
||||||
|
|
||||||
|
插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。
|
||||||
|
|
||||||
|
## `Plugin`
|
||||||
|
|
||||||
|
Plugin is the interface every plugin must implement.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Name`](#pluginname) | |
|
||||||
|
| [`Start`](#pluginstart) | |
|
||||||
|
| [`Stop`](#pluginstop) | |
|
||||||
|
|
||||||
|
### `Plugin.Name`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Name() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:14`</small>
|
||||||
|
|
||||||
|
### `Plugin.Start`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Start(sdk *PluginSDK) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:15`</small>
|
||||||
|
|
||||||
|
### `Plugin.Stop`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stop() error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:16`</small>
|
||||||
|
|
||||||
|
## `PluginMgrAPI`
|
||||||
|
|
||||||
|
PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||||
|
由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 |
|
||||||
|
| [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 |
|
||||||
|
| [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 |
|
||||||
|
|
||||||
|
### `PluginMgrAPI.IsPluginDisabled`
|
||||||
|
|
||||||
|
```go
|
||||||
|
IsPluginDisabled(name string) bool
|
||||||
|
```
|
||||||
|
|
||||||
|
IsPluginDisabled 查询插件是否被禁用。
|
||||||
|
|
||||||
|
<small>`plugin.go:389`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ListLoadedPlugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListLoadedPlugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
ListLoadedPlugins 列出已加载插件。
|
||||||
|
|
||||||
|
<small>`plugin.go:387`</small>
|
||||||
|
|
||||||
|
### `PluginMgrAPI.ReloadOne`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ReloadOne(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
ReloadOne 重载单个插件(停止后重新加载)。
|
||||||
|
|
||||||
|
<small>`plugin.go:385`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.AutoRestart`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) AutoRestart() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
AutoRestart 返回插件是否允许自动重启。
|
||||||
|
|
||||||
|
<small>`plugin.go:895`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.PluginMgr`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) PluginMgr() PluginMgrAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||||
|
May be nil if the host did not wire it.
|
||||||
|
|
||||||
|
<small>`plugin.go:757`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.PluginName`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) PluginName() string
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginName returns the name of the plugin.
|
||||||
|
|
||||||
|
<small>`plugin.go:501`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterOnRemoveHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterOnRemoveHandler(fn func())
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
|
||||||
|
注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
|
||||||
|
适用于清理外部资源、删除配置表、下线状态等删除后处理。
|
||||||
|
可注册多个;执行后清空(一次删除只执行一次)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:296` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
|
||||||
|
|
||||||
|
<small>`plugin.go:930`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterPluginAPI`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterPluginAPI(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterPluginAPI registers this plugin's API for access by other plugins.
|
||||||
|
|
||||||
|
<small>`plugin.go:606`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterStopHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterStopHandler(fn func())
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterStopHandler 注册插件停止阶段的清理回调。
|
||||||
|
注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
|
||||||
|
适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
|
||||||
|
可注册多个;执行后清空(进程停止前只执行一次)。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
|
||||||
|
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
|
||||||
|
|
||||||
|
<small>`plugin.go:905`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RunOnRemoveHandlers`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RunOnRemoveHandlers()
|
||||||
|
```
|
||||||
|
|
||||||
|
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
|
||||||
|
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
|
||||||
|
|
||||||
|
<small>`plugin.go:941`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RunStopHandlers`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RunStopHandlers()
|
||||||
|
```
|
||||||
|
|
||||||
|
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
|
||||||
|
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
|
||||||
|
|
||||||
|
<small>`plugin.go:916`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.SetAutoRestart`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) SetAutoRestart(enabled bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
SetAutoRestart 设置插件崩溃后内核是否自动重启它。
|
||||||
|
默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||||
|
|
||||||
|
重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
|
||||||
|
且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
|
||||||
|
注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:47` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:47` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:63` | `s.SetAutoRestart(true)` |
|
||||||
|
|
||||||
|
<small>`plugin.go:888`</small>
|
||||||
|
|
||||||
50
docs/api/llm.md
Normal file
50
docs/api/llm.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# LLM 调用
|
||||||
|
|
||||||
|
让插件自己调用模型(而不是只等模型来调你)。
|
||||||
|
|
||||||
|
## `LLMAPI`
|
||||||
|
|
||||||
|
LLMAPI provides access to the LLM provider manager.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`CurrentSource`](#llmapicurrentsource) | |
|
||||||
|
| [`ListSources`](#llmapilistsources) | |
|
||||||
|
| [`SetSource`](#llmapisetsource) | |
|
||||||
|
|
||||||
|
### `LLMAPI.CurrentSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
CurrentSource() string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:7`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.ListSources`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListSources() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:5`</small>
|
||||||
|
|
||||||
|
### `LLMAPI.SetSource`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetSource(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`llm.go:6`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.LLM`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) LLM() LLMAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM returns the LLM provider API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:536`</small>
|
||||||
|
|
||||||
383
docs/api/memory.md
Normal file
383
docs/api/memory.md
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 记忆(Memory)
|
||||||
|
|
||||||
|
三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。
|
||||||
|
|
||||||
|
## `DocMemoryAPI`
|
||||||
|
|
||||||
|
DocMemoryAPI provides access to the document vector store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Insert`](#docmemoryapiinsert) | |
|
||||||
|
| [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 |
|
||||||
|
| [`Query`](#docmemoryapiquery) | |
|
||||||
|
| [`Remove`](#docmemoryapiremove) | |
|
||||||
|
| [`Stats`](#docmemoryapistats) | |
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Insert`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Insert(doc *Doc) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:76`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.InsertWithMedia`
|
||||||
|
|
||||||
|
```go
|
||||||
|
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||||
|
```
|
||||||
|
|
||||||
|
InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
|
||||||
|
内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
|
||||||
|
媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
|
||||||
|
因此图片按自己的向量被召回,不依赖任何生成的描述文本。
|
||||||
|
|
||||||
|
<small>`memory.go:81`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Query`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Query(text string, topK int) []*Doc
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:75`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Remove`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Remove(id string)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:82`</small>
|
||||||
|
|
||||||
|
### `DocMemoryAPI.Stats`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Stats() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:83`</small>
|
||||||
|
|
||||||
|
## `KnowledgeAPI`
|
||||||
|
|
||||||
|
KnowledgeAPI provides access to the knowledge store.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Add`](#knowledgeapiadd) | |
|
||||||
|
| [`List`](#knowledgeapilist) | |
|
||||||
|
| [`Search`](#knowledgeapisearch) | |
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Add`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Add(name, content string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:6`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:7`</small>
|
||||||
|
|
||||||
|
### `KnowledgeAPI.Search`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Search(query string, topK int) ([]*Knowledge, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`knowledge.go:5`</small>
|
||||||
|
|
||||||
|
## `MemoryAPI`
|
||||||
|
|
||||||
|
MemoryAPI provides access to the graph memory (entity-relation store).
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Commit`](#memoryapicommit) | |
|
||||||
|
| [`Introspect`](#memoryapiintrospect) | |
|
||||||
|
| [`MergeEntities`](#memoryapimergeentities) | |
|
||||||
|
| [`Purge`](#memoryapipurge) | |
|
||||||
|
| [`Recall`](#memoryapirecall) | |
|
||||||
|
|
||||||
|
### `MemoryAPI.Commit`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Commit(triples []Triple) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:6`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Introspect`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Introspect() (map[string]interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:7`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.MergeEntities`
|
||||||
|
|
||||||
|
```go
|
||||||
|
MergeEntities(source, target string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:8`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Purge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Purge(criteria map[string]string, mode string) (int, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:9`</small>
|
||||||
|
|
||||||
|
### `MemoryAPI.Recall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:5`</small>
|
||||||
|
|
||||||
|
## `SocialAPI`
|
||||||
|
|
||||||
|
SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||||
|
External plugins can query person traits and social networks but cannot modify them.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`GetNetwork`](#socialapigetnetwork) | |
|
||||||
|
| [`GetPerson`](#socialapigetperson) | |
|
||||||
|
| [`GetRelations`](#socialapigetrelations) | |
|
||||||
|
| [`GetTrait`](#socialapigettrait) | |
|
||||||
|
| [`ListPersons`](#socialapilistpersons) | |
|
||||||
|
|
||||||
|
### `SocialAPI.GetNetwork`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:104`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetPerson`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:101`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetRelations`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:103`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.GetTrait`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:102`</small>
|
||||||
|
|
||||||
|
### `SocialAPI.ListPersons`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:105`</small>
|
||||||
|
|
||||||
|
## `TextMemoryAPI`
|
||||||
|
|
||||||
|
TextMemoryAPI provides access to chronological text event storage.
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`Append`](#textmemoryapiappend) | |
|
||||||
|
|
||||||
|
### `TextMemoryAPI.Append`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Append(evt TextEvent) error
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:44`</small>
|
||||||
|
|
||||||
|
### `Doc`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Doc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Score …
|
||||||
|
```
|
||||||
|
|
||||||
|
Doc represents a document in the document store.
|
||||||
|
|
||||||
|
MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
|
||||||
|
|
||||||
|
<small>`memory.go:89`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.DocMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) DocMemory() DocMemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
DocMemory returns the document memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:522`</small>
|
||||||
|
|
||||||
|
### `Entity`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Entity struct { Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
Entity represents a named entity in the knowledge graph.
|
||||||
|
|
||||||
|
<small>`memory.go:13`</small>
|
||||||
|
|
||||||
|
### `Knowledge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Knowledge struct { Name string `json:"name"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 // // 为何加这个字段:对<EFBC9A><E5AFB9> …
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge represents a knowledge entry.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
|
||||||
|
|
||||||
|
<small>`knowledge.go:11`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Knowledge`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Knowledge() KnowledgeAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge returns the knowledge store API (may be nil if not available).
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
|
||||||
|
|
||||||
|
<small>`plugin.go:529`</small>
|
||||||
|
|
||||||
|
### `MediaAttachment`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MediaAttachment struct { Digest string `json:"digest,omitempty"` MIME string `json:"mime,omitempty"` Data []byte `json:"data,omitempty"` Name string `json:"name,omite …
|
||||||
|
```
|
||||||
|
|
||||||
|
TextEvent represents a single text memory event.
|
||||||
|
MediaAttachment 描述一份与记忆关联的媒体。
|
||||||
|
|
||||||
|
两个方向共用一个类型:
|
||||||
|
- 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
|
||||||
|
- 读出(Query):内核只填 Digest/MIME,**不回 Data**——
|
||||||
|
一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
|
||||||
|
需要字节时拿 Digest 单独取。
|
||||||
|
|
||||||
|
刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
|
||||||
|
它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
|
||||||
|
|
||||||
|
<small>`memory.go:58`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Memory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Memory() MemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory returns the graph memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:508`</small>
|
||||||
|
|
||||||
|
### `PersonProfile`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PersonProfile struct { Name string `json:"name"` Traits map[string]string `json:"traits,omitempty"` Relations []SocialRelation `json:"relations,omitemp …
|
||||||
|
```
|
||||||
|
|
||||||
|
PersonProfile represents a person's complete profile (traits + social relations).
|
||||||
|
|
||||||
|
<small>`memory.go:109`</small>
|
||||||
|
|
||||||
|
### `Relation`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Relation struct { SourceName string `json:"source_name"` TargetName string `json:"target_name"` RelationType string `json:"relation_type"` Confidence float6 …
|
||||||
|
```
|
||||||
|
|
||||||
|
Relation represents a relationship between two entities.
|
||||||
|
|
||||||
|
<small>`memory.go:20`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Social`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Social() SocialAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Social returns the social graph API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:543`</small>
|
||||||
|
|
||||||
|
### `SocialRelation`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SocialRelation struct { Person string `json:"person"` Relation string `json:"relation"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
SocialRelation represents a social relationship between two persons.
|
||||||
|
|
||||||
|
<small>`memory.go:116`</small>
|
||||||
|
|
||||||
|
### `TextEvent`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TextEvent struct { Role string `json:"role"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` Channel …
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`memory.go:65`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.TextMemory`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) TextMemory() TextMemoryAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
TextMemory returns the text memory API (may be nil if not available).
|
||||||
|
|
||||||
|
<small>`plugin.go:515`</small>
|
||||||
|
|
||||||
|
### `Triple`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Triple struct { Subject string `json:"subject"` Relation string `json:"relation"` Object string `json:"object"` Confidence float64 `json:"c …
|
||||||
|
```
|
||||||
|
|
||||||
|
Triple represents a subject-relation-object triple for the knowledge graph.
|
||||||
|
|
||||||
|
SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
|
||||||
|
所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
|
||||||
|
|
||||||
|
<small>`memory.go:31`</small>
|
||||||
|
|
||||||
1044
docs/api/misc.md
Normal file
1044
docs/api/misc.md
Normal file
File diff suppressed because it is too large
Load Diff
197
docs/api/settings.md
Normal file
197
docs/api/settings.md
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 配置(Settings)
|
||||||
|
|
||||||
|
声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。
|
||||||
|
|
||||||
|
## `SettingsAPI`
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): |
|
||||||
|
| [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. |
|
||||||
|
| [`Dump`](#settingsapidump) | Dump returns all config values. |
|
||||||
|
| [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_<name> table). |
|
||||||
|
| [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. |
|
||||||
|
| [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. |
|
||||||
|
| [`List`](#settingsapilist) | List returns all keys matching the given prefix. |
|
||||||
|
| [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. |
|
||||||
|
| [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. |
|
||||||
|
| [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. |
|
||||||
|
| [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. |
|
||||||
|
| [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. |
|
||||||
|
| [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. |
|
||||||
|
| [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. |
|
||||||
|
|
||||||
|
### `SettingsAPI.DataDir`
|
||||||
|
|
||||||
|
```go
|
||||||
|
DataDir() string
|
||||||
|
```
|
||||||
|
|
||||||
|
DataDir returns the plugin-specific data directory (guaranteed to exist):
|
||||||
|
<daemon data>/plugin_data/<plugin_name>. Plugins should persist any
|
||||||
|
runtime files (generated images, caches, downloads) here.
|
||||||
|
|
||||||
|
<small>`settings.go:25`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Defs`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Defs(prefix string) []*ConfigDef
|
||||||
|
```
|
||||||
|
|
||||||
|
Defs returns config definitions matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:40`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Dump`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Dump() map[string]interface{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dump returns all config values.
|
||||||
|
|
||||||
|
<small>`settings.go:43`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Get`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Get(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Get reads the plugin's own config value (config_<name> table).
|
||||||
|
|
||||||
|
<small>`settings.go:5`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetCore(key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetCore reads the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:14`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.GetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
GetPlugin reads another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:28`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.List`
|
||||||
|
|
||||||
|
```go
|
||||||
|
List(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
List returns all keys matching the given prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:11`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListCore(prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListCore lists core config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:20`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.ListPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
ListPlugin(plugin, prefix string) ([]string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ListPlugin lists another plugin's config keys matching the prefix.
|
||||||
|
|
||||||
|
<small>`settings.go:34`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Plugins`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Plugins() []string
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins returns a list of all plugin config namespaces.
|
||||||
|
|
||||||
|
<small>`settings.go:46`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.RegisterDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
RegisterDef(def ConfigDef)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterDef registers a config definition for UI display.
|
||||||
|
|
||||||
|
<small>`settings.go:37`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.Set`
|
||||||
|
|
||||||
|
```go
|
||||||
|
Set(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Set writes a config value to the plugin's own config table.
|
||||||
|
|
||||||
|
<small>`settings.go:8`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetCore`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetCore(key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetCore writes to the core config table.
|
||||||
|
|
||||||
|
<small>`settings.go:17`</small>
|
||||||
|
|
||||||
|
### `SettingsAPI.SetPlugin`
|
||||||
|
|
||||||
|
```go
|
||||||
|
SetPlugin(plugin, key string, value interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
SetPlugin writes to another plugin's config table.
|
||||||
|
|
||||||
|
<small>`settings.go:31`</small>
|
||||||
|
|
||||||
|
### `ConfigDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigDef struct { Key string `json:"key"` Default interface{} `json:"default,omitempty"` Type string `json:"type"` DisplayName string …
|
||||||
|
```
|
||||||
|
|
||||||
|
ConfigDef describes a configuration field for the WebUI.
|
||||||
|
|
||||||
|
<small>`settings.go:50`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.Settings`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) Settings() SettingsAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings returns the settings API for reading/writing plugin configuration.
|
||||||
|
sett 在 New 时一次性写入且无 setter,故不需要加锁。
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:68` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:63` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:67` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:505`</small>
|
||||||
|
|
||||||
154
docs/api/stages.md
Normal file
154
docs/api/stages.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 阶段钩子(Stages)
|
||||||
|
|
||||||
|
在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。
|
||||||
|
|
||||||
|
### `StageContext.IsResponded`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) IsResponded() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
<small>`plugin.go:213`</small>
|
||||||
|
|
||||||
|
### `StageContext.Lock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) Lock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:158` | `p.sessMu.Lock()` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:128` | `p.srvMu.Lock()` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:37` | `p.runMu.Lock()` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:211`</small>
|
||||||
|
|
||||||
|
### `StageContext.RLock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) RLock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:267` | `p.mu.RLock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:649` | `p.mu.RLock()` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:209`</small>
|
||||||
|
|
||||||
|
### `StageContext.RUnlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) RUnlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:273` | `p.mu.RUnlock()` |
|
||||||
|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:650` | `defer p.mu.RUnlock()` |
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:210`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterStage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope)
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterStage registers a handler for a pipeline stage.
|
||||||
|
|
||||||
|
scope: StageScopeGlobal (default) — receives all stage events.
|
||||||
|
StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:152` | `s.RegisterStage(sdk.StagePreAction, p.stagePreAction)` |
|
||||||
|
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:719` | `s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)` |
|
||||||
|
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
|
||||||
|
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
|
||||||
|
|
||||||
|
<small>`plugin.go:571`</small>
|
||||||
|
|
||||||
|
### `Stage`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Stage string
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage represents a point in the message processing pipeline.
|
||||||
|
|
||||||
|
<small>`plugin.go:26`</small>
|
||||||
|
|
||||||
|
### `StageContext`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageContext struct { mu sync.RWMutex RawMessage string UserID string GroupID string ContextMsgs []map[string]interface{} L …
|
||||||
|
```
|
||||||
|
|
||||||
|
StageContext provides context for stage handlers.
|
||||||
|
|
||||||
|
<small>`plugin.go:189`</small>
|
||||||
|
|
||||||
|
### `StageHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageHandler func(ctx *StageContext) error
|
||||||
|
```
|
||||||
|
|
||||||
|
StageHandler is a function that handles a pipeline stage event.
|
||||||
|
|
||||||
|
<small>`plugin.go:23`</small>
|
||||||
|
|
||||||
|
### `StageRegistrar`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
StageRegistrar registers a stage handler.
|
||||||
|
|
||||||
|
<small>`plugin.go:407`</small>
|
||||||
|
|
||||||
|
### `StageScope`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StageScope int
|
||||||
|
```
|
||||||
|
|
||||||
|
StageScope controls which events a stage handler receives.
|
||||||
|
|
||||||
|
<small>`plugin.go:393`</small>
|
||||||
|
|
||||||
|
### `StageContext.Unlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (c *StageContext) Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:164` | `p.sessMu.Unlock()` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:129` | `defer p.srvMu.Unlock()` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:40` | `p.runMu.Unlock()` |
|
||||||
|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` |
|
||||||
|
|
||||||
|
<small>`plugin.go:212`</small>
|
||||||
|
|
||||||
77
docs/api/tools.md
Normal file
77
docs/api/tools.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 工具(Tools)
|
||||||
|
|
||||||
|
注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。
|
||||||
|
|
||||||
|
### `ContentBlock`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` AudioURL *AudioURL `jso …
|
||||||
|
```
|
||||||
|
|
||||||
|
ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。
|
||||||
|
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
|
||||||
|
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
|
||||||
|
|
||||||
|
<small>`plugin.go:954`</small>
|
||||||
|
|
||||||
|
### `PluginSDK.RegisterTool`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
|
||||||
|
```
|
||||||
|
|
||||||
|
RegisterTool registers a tool that the LLM can call.
|
||||||
|
|
||||||
|
**示例插件里的真实用法**
|
||||||
|
|
||||||
|
| 插件 | 位置 | 代码 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:76` | `s.RegisterTool(tp+"a2a_query", sdk.ToolDef{` |
|
||||||
|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:70` | `s.RegisterTool(tp+"acp_query", sdk.ToolDef{` |
|
||||||
|
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
|
||||||
|
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:85` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
|
||||||
|
|
||||||
|
<small>`plugin.go:557`</small>
|
||||||
|
|
||||||
|
### `ToolCall`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolCall represents a model's request to call a tool.
|
||||||
|
|
||||||
|
<small>`plugin.go:227`</small>
|
||||||
|
|
||||||
|
### `ToolDef`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Description string …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolDef describes a tool that the plugin exposes.
|
||||||
|
|
||||||
|
<small>`plugin.go:279`</small>
|
||||||
|
|
||||||
|
### `ToolHandler`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolHandler is a function that handles a tool call.
|
||||||
|
|
||||||
|
<small>`plugin.go:20`</small>
|
||||||
|
|
||||||
|
### `ToolResult`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Success bool `json:"suc …
|
||||||
|
```
|
||||||
|
|
||||||
|
ToolResult represents the result of a tool call.
|
||||||
|
|
||||||
|
<small>`plugin.go:235`</small>
|
||||||
|
|
||||||
3080
docs/assets/api-index.json
Normal file
3080
docs/assets/api-index.json
Normal file
File diff suppressed because it is too large
Load Diff
27
docs/assets/logo-mark.svg
Normal file
27
docs/assets/logo-mark.svg
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||||
|
|
||||||
|
<g transform="translate(200,200)">
|
||||||
|
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
|
||||||
|
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
|
||||||
|
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
|
||||||
|
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
|
||||||
|
|
||||||
|
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
|
||||||
|
|
||||||
|
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
28
docs/assets/logo.svg
Normal file
28
docs/assets/logo.svg
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
|
||||||
|
<rect width="400" height="400" fill="#F8FAFC"/>
|
||||||
|
|
||||||
|
<g transform="translate(200,200)">
|
||||||
|
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
|
||||||
|
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
|
||||||
|
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
|
||||||
|
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
|
||||||
|
|
||||||
|
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
|
||||||
|
|
||||||
|
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
|
||||||
|
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
|
||||||
|
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
60
docs/examples/index.md
Normal file
60
docs/examples/index.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
|
||||||
|
|
||||||
|
# 示例插件
|
||||||
|
|
||||||
|
SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。
|
||||||
|
|
||||||
|
每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。
|
||||||
|
|
||||||
|
## `a2a`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `acp`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `ai_image`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `RegisterTool` · `SetAutoRestart` · `Settings`
|
||||||
|
|
||||||
|
## `bili`
|
||||||
|
|
||||||
|
用到的 API:`Lock` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
|
||||||
|
|
||||||
|
## `browser`
|
||||||
|
|
||||||
|
用到的 API:`Error` · `InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock`
|
||||||
|
|
||||||
|
## `calendar`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOnRemoveHandler` · `RegisterStopHandler`
|
||||||
|
|
||||||
|
## `deepsearch`
|
||||||
|
|
||||||
|
用到的 API:`RegisterStopHandler`
|
||||||
|
|
||||||
|
## `memo`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOnRemoveHandler` · `RegisterStage`
|
||||||
|
|
||||||
|
## `qq`
|
||||||
|
|
||||||
|
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOutputChannel` · `RegisterStage`
|
||||||
|
|
||||||
|
## `recoverydiag`
|
||||||
|
|
||||||
|
用到的 API:`Knowledge`
|
||||||
|
|
||||||
|
## `rss`
|
||||||
|
|
||||||
|
用到的 API:`RegisterOnRemoveHandler`
|
||||||
|
|
||||||
|
## `sanitizer`
|
||||||
|
|
||||||
|
用到的 API:`RegisterStage`
|
||||||
|
|
||||||
|
## `weather`
|
||||||
|
|
||||||
|
用到的 API:`RegisterOutputChannel` · `RegisterStage`
|
||||||
|
|
||||||
75
docs/guide/capability-boundary.md
Normal file
75
docs/guide/capability-boundary.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# 能力边界:哪些 API 外部插件能用
|
||||||
|
|
||||||
|
HomeAgent 有两类插件:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 分发 |
|
||||||
|
|---|---|---|
|
||||||
|
| **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** |
|
||||||
|
| **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 |
|
||||||
|
|
||||||
|
SDK 包是**同一个** `gitcode.com/JianFeeeee/homeagent-sdk/sdk`,但两类插件拿到的
|
||||||
|
**能力不同**:外部插件跑在独立进程里,由内核通过桥接注入能力(IPC,不是共享内存里的直接调用)。
|
||||||
|
|
||||||
|
本页说明边界在哪、为什么,以及**怎么在写代码前就知道某个 API 是否可用**。
|
||||||
|
|
||||||
|
## 一句话规则
|
||||||
|
|
||||||
|
> **公开 SDK 包里声明的符号,不等于外部插件拿得到。**
|
||||||
|
|
||||||
|
原因是:有些能力只有进程内的内置插件才可能拥有(比如直接读事件发布通道、
|
||||||
|
直接注入到内核 IO 层)。外部插件通过桥接运行时拿到的是一份**受注入的能力集合**。
|
||||||
|
|
||||||
|
## 外部插件**不可用**的 API
|
||||||
|
|
||||||
|
这些 API 在公开包里存在,但在外部插件路径上拿不到。文档里每条都带
|
||||||
|
<span class="api-badge api-badge-builtin">仅内置</span> 标记,
|
||||||
|
完整清单见 [仅内置插件可用](../api/builtin-only.md)。
|
||||||
|
|
||||||
|
| API | 外部插件的实际情况 | 该用什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sdk.PluginSDK.Events()` | **恒为 nil**。桥接运行时不注入 event subscriber(`SetEventSubscriber` 全仓无调用点) | 桥接运行时已按你的声明完成 `events.subscribe`;Lua 插件用 `sdk.events.subscribe` |
|
||||||
|
| `sdk.PluginSDK.SetEventSubscriber` | 无人调用 | 同上 |
|
||||||
|
| `UnregisterOutputChannel` | 桥接只注入 registrar、**不注入 unregistrar**,调用是**静默无效**(返回 nil,不报错也不注销) | `RegisterOutputChannel` 可用;注销需重载插件 |
|
||||||
|
| `SocialAPI` 的写操作 | 公开接口只有 6 个**只读**方法 | 读用 `s.GetPerson` 等;写需内置插件 |
|
||||||
|
| `EventSubscriber.Publish` | 公开接口**刻意只有 Subscribe**,没有 Publish | 只订阅 |
|
||||||
|
| `PriorityL4` | 声明会被内核**夹到 L3** | 用 L1–L3 |
|
||||||
|
| `RegisterChannel` / `ListChannels` / `OutputChan` / `InjectInput` / `InjectInterrupt` | 只存在于内核内部 SDK | `RegisterInputChannel` / `RegisterOutputChannel` / `InjectText` 等公开方法 |
|
||||||
|
| `PluginMgr()` 的完整能力 | 公开 `PluginMgrAPI` **只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` / `IsPluginDisabled`) | 就这 3 个;`ReloadPlugins`/`Disable`/`Remove` 属内部接口 |
|
||||||
|
|
||||||
|
!!! warning "两处常见的文档错误(本站已更正)"
|
||||||
|
1. **`PluginMgr()` 不是「仅内置可用」**。桥接模板显式注入了它
|
||||||
|
(`base.SetPluginMgrAPI(procPluginMgr{})`),公开 `PluginMgrAPI` 也注明
|
||||||
|
「外部插件可调用」。真正的区别是**方法数量**:公开面 3 个,内部面 9 个。
|
||||||
|
容易混淆是因为两个包里有**同名但不同**的接口:
|
||||||
|
`sdk.PluginMgrAPI`(3 方法)与 `internal/sdk.PluginManager`(9 方法)。
|
||||||
|
2. **`Events()` 恒为 nil 这件事以前没写清**。旧文档把 `Events()` 当作
|
||||||
|
可用的订阅入口,但桥接运行时不注入 subscriber。外部插件的事件订阅
|
||||||
|
实际由生成的运行时通过 `events.subscribe` 完成。
|
||||||
|
|
||||||
|
## 判定依据来自哪里
|
||||||
|
|
||||||
|
本站的「仅内置」标记不是猜的,逐条来自:
|
||||||
|
|
||||||
|
1. **`tools/hmapdev/templates/proc_main.go.tmpl`** —— 外部插件运行时**实际注入**
|
||||||
|
哪些能力,看 `buildPluginSDK()` 里的 `base.Set*` 调用。
|
||||||
|
2. **`internal/sdk`** —— 内置插件用的完整接口,与公开包对照。
|
||||||
|
3. **内核 RPC 协议表**(`internal/plugin/proc/protocol.go`)—— 外部插件**能发哪些请求**。
|
||||||
|
|
||||||
|
每条裁定的具体依据写在该 API 的告警框里,可以直接核对。
|
||||||
|
|
||||||
|
## 怎么快速确认
|
||||||
|
|
||||||
|
- 用 [API 搜索](../api/index.md) 搜 API 名或功能描述,带
|
||||||
|
<span class="api-badge api-badge-builtin">仅内置</span> 的就是外部不可用
|
||||||
|
- 直接看 [仅内置插件可用](../api/builtin-only.md) 汇总页
|
||||||
|
- 拿不准时,**读 `example/` 下的示例插件** —— 它们全是外部插件,
|
||||||
|
能被它们编译通过的写法,外部就一定可用
|
||||||
|
|
||||||
|
## 为什么这样设计
|
||||||
|
|
||||||
|
不是为了限制,而是**IPC 边界决定了能力边界**:外部插件跑在独立进程里,
|
||||||
|
内核只能通过显式的注入点把能力交过去。凡是需要「持有内核内部数据结构」
|
||||||
|
的能力(事件发布通道、IO 通道、插件注册表全量操作),进程外都无法安全暴露。
|
||||||
|
|
||||||
|
这套边界同时带来好处:插件崩溃不会带崩内核(进程隔离),
|
||||||
|
以及**插件可以闭源**(SDK 是 MIT,见[首页](../index.md#_3))。
|
||||||
111
docs/guide/first-lua-plugin.md
Normal file
111
docs/guide/first-lua-plugin.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# 第一个 Lua 插件
|
||||||
|
|
||||||
|
Lua 插件适合**轻量、快速原型**:不需要 Go 编译环境,改完重启内核即可生效。
|
||||||
|
但它有一个必须理解的限制 —— 执行模型是**被动回调**。
|
||||||
|
|
||||||
|
## 执行模型(先读这段)
|
||||||
|
|
||||||
|
Lua 插件跑在内核进程内的 gopher-lua 解释器里(单 Lua 状态 + 互斥锁):
|
||||||
|
|
||||||
|
- **被动回调**:`main.lua` 只在加载时执行一次。此后工具、阶段钩子、
|
||||||
|
输入输出通道全部由内核事件驱动回调你的 Lua 函数。**插件不能自己启动后台任务。**
|
||||||
|
- **没有并发**:Lua 侧没有 goroutine、协程调度,也没有 `os` / `io` 库和 socket 监听。
|
||||||
|
唯一主动出站通道是 `sdk.http.get/post`(同步请求)。
|
||||||
|
- **任何阻塞循环都会持锁卡死该插件的全部调用。**
|
||||||
|
|
||||||
|
!!! warning "要常驻服务就用 Go 插件"
|
||||||
|
需要监听端口、后台轮询、定时任务的,请用 [Go 插件](first-plugin.md)
|
||||||
|
(可自行启动 goroutine)。Lua 侧的等价做法是**事件驱动**:把逻辑挂在
|
||||||
|
工具、阶段钩子或通道回调上。
|
||||||
|
|
||||||
|
## 生成工程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev init myluaplugin --lua
|
||||||
|
cd myluaplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
myluaplugin/
|
||||||
|
├── plg.json — entry: "main.lua", targets: "lua"
|
||||||
|
├── main.lua — 插件实现
|
||||||
|
├── sdk.lua — SDK 模拟层(支持独立测试)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一个完整的插件
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- main.lua
|
||||||
|
local plugin = {
|
||||||
|
name = "myluaplugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
function plugin.start(sdk)
|
||||||
|
sdk.log("info", "myluaplugin starting...")
|
||||||
|
|
||||||
|
sdk.register_tool("myluaplugin_hello", {
|
||||||
|
description = "向指定的人打招呼",
|
||||||
|
parameters = {
|
||||||
|
type = "object",
|
||||||
|
properties = {
|
||||||
|
who = { type = "string", description = "要打招呼的对象" }
|
||||||
|
},
|
||||||
|
required = { "who" }
|
||||||
|
}
|
||||||
|
}, function(args)
|
||||||
|
return { content = "hello, " .. (args.who or "world") .. "!" }
|
||||||
|
end)
|
||||||
|
|
||||||
|
sdk.log("info", "myluaplugin started")
|
||||||
|
end
|
||||||
|
|
||||||
|
function plugin.stop()
|
||||||
|
sdk.log("info", "myluaplugin stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地测试
|
||||||
|
|
||||||
|
`sdk.lua` 是纯 Lua 的 SDK 模拟实现,可以直接用解释器跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua
|
||||||
|
# [lua-plugin] info: myluaplugin starting...
|
||||||
|
# [lua-plugin] register_tool: myluaplugin_hello
|
||||||
|
# [lua-plugin] info: myluaplugin started
|
||||||
|
```
|
||||||
|
|
||||||
|
在内核里运行时,`sdk.*` 由 Go 层注入,`sdk.lua` 里所有 `-- !impl` 标记的函数
|
||||||
|
会被替换成真实实现。
|
||||||
|
|
||||||
|
## API 约定的两点
|
||||||
|
|
||||||
|
- **注册类函数调用即时报错**(抛 Lua error)—— 注册失败不会静默。
|
||||||
|
- **数据类函数统一返回 `(result, err)`**,`err` 为 nil 表示成功。
|
||||||
|
核心未装配的子系统(如 SocialAPI)返回空值而非报错。
|
||||||
|
|
||||||
|
Lua 侧的 `sdk.*` 能力与外部 Go 插件对齐至 SDK 1.3.0(需内核 1.4.0+)。
|
||||||
|
|
||||||
|
!!! note "历史提醒"
|
||||||
|
1.1–1.3 期间,媒体 / 注入标志位 / 优先级能力只在 Go 侧有,Lua 侧静默缺失。
|
||||||
|
现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住
|
||||||
|
「`sdk.lua` 承诺的每个函数都有运行时绑定」。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # → dist/myluaplugin_lua.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
Lua 插件直接打包源码,不经过编译。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [能力边界](capability-boundary.md) —— Lua 与 Go 外部插件的能力面一致
|
||||||
|
- [打包与发布](packaging.md)
|
||||||
|
- [示例](../examples/index.md) —— `example/luademo` 是 Lua 版参考实现
|
||||||
162
docs/guide/first-plugin.md
Normal file
162
docs/guide/first-plugin.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# 第一个 Go 插件
|
||||||
|
|
||||||
|
以下是一个**能直接跑起来**的最小插件:注册一个工具、声明一项配置、处理停止与卸载。
|
||||||
|
|
||||||
|
## 1. 生成工程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev init myplugin
|
||||||
|
cd myplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
myplugin/
|
||||||
|
├── plg.json — 插件元信息(名称、版本、入口、目标平台)
|
||||||
|
├── plugin.go — 插件实现
|
||||||
|
├── go.mod — 模块定义
|
||||||
|
├── README.md
|
||||||
|
└── thirdpart/ — 外部源码存放目录(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
`hmapdev build` 时会在构建目录自动生成子进程运行时(`z_proc_gen.go` 等),
|
||||||
|
**不需要手工创建,也不要提交**。
|
||||||
|
|
||||||
|
## 2. 插件实现
|
||||||
|
|
||||||
|
插件的全部契约是一个 `Plugin` 接口([API 参考](../api/lifecycle.md#plugin)):
|
||||||
|
|
||||||
|
| 方法 | 何时调用 |
|
||||||
|
|---|---|
|
||||||
|
| `Name() string` | 内核需要标识这个插件时 |
|
||||||
|
| `Start(*sdk.PluginSDK) error` | 插件加载后。**在这里注册工具、通道、配置** |
|
||||||
|
| `Stop() error` | 插件停止时(重载、禁用、内核退出都会触发) |
|
||||||
|
|
||||||
|
再加一个工厂函数。**名字必须是 `NewPluginFactory`** —— 生成的运行时按这个名字调用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "不要写成 `NewPlugin`"
|
||||||
|
生成的子进程运行时调用的入口是 `NewPluginFactory`。仓库里有 3 个早期示例
|
||||||
|
同时保留了两个名字(`NewPlugin` 只是遗留别名),但新插件只写
|
||||||
|
`NewPluginFactory` 即可。写错名字的后果是**编译能过、加载时找不到入口**。
|
||||||
|
|
||||||
|
## 3. 一个完整的例子
|
||||||
|
|
||||||
|
这是一个「打招呼」工具,带一项配置:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
|
||||||
|
// ① 声明配置项:内核会把它渲染到 WebUI 设置页
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "plugin.myplugin.greeting",
|
||||||
|
Default: "hello",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "问候语",
|
||||||
|
Description: "打招呼时使用的前缀",
|
||||||
|
Category: "myplugin",
|
||||||
|
})
|
||||||
|
|
||||||
|
// ② 注册工具:模型看到 Description 后决定是否调用
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||||
|
Name: tp + "hello",
|
||||||
|
Description: "向指定的人打招呼",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"who": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要打招呼的对象",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"who"},
|
||||||
|
},
|
||||||
|
}, p.handleHello)
|
||||||
|
|
||||||
|
// ③ 卸载(插件被删除)前清理自己产生的数据。
|
||||||
|
// 注意与 Stop 的区别:Stop 在每次重载时也会触发。
|
||||||
|
s.RegisterOnRemoveHandler(func() {
|
||||||
|
fmt.Printf("[%s] 清理数据\n", p.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||||
|
who, _ := args["who"].(string)
|
||||||
|
|
||||||
|
greeting := "hello"
|
||||||
|
if v, err := p.sdk.Settings().Get("plugin.myplugin.greeting"); err == nil && v != "" {
|
||||||
|
greeting = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("%s, %s!", greeting, who),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 工具返回值的两条约定
|
||||||
|
|
||||||
|
`ToolHandler` 返回 `(interface{}, error)`,模型侧看到的是一条 tool message:
|
||||||
|
|
||||||
|
- **正常结果**:返回一个 map,把要展示给模型的文本放在 `content` 字段。
|
||||||
|
未识别的字段也会一并传给模型,可以放结构化数据。
|
||||||
|
- **业务失败**:返回 `map[string]interface{}{"isError": true, "content": "原因"}`
|
||||||
|
**并返回 nil error**。这样模型能看到失败原因并自行调整;
|
||||||
|
若返回 Go 的 `error`,那是**工具调用本身出错**,语义不同。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 构建与安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # 默认产出多平台 bundle
|
||||||
|
# → dist/myplugin_bundle.hmap
|
||||||
|
|
||||||
|
hmapdev build --no-bundle # 只构建当前平台
|
||||||
|
# → dist/myplugin_linux_amd64.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装到内核:在 WebUI 的插件管理页上传 `.hmap`,或从 URL / 本地路径安装。
|
||||||
|
详见 [打包与发布](packaging.md)。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [能力边界](capability-boundary.md) —— 哪些 API 外部插件能用
|
||||||
|
- [工具(Tools)](../api/tools.md) —— `ToolDef` 的完整字段
|
||||||
|
- [记忆(Memory)](../api/memory.md) —— 让插件读写长期记忆
|
||||||
|
- [示例插件](../examples/index.md) —— `example/memo` 是个完整的可读实现
|
||||||
68
docs/guide/getting-started.md
Normal file
68
docs/guide/getting-started.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# 环境与工具链
|
||||||
|
|
||||||
|
`hmapdev` 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
|
||||||
|
最终产出 `.hmap` 插件包(工具名即取自这个包格式)。
|
||||||
|
|
||||||
|
!!! note "改名说明"
|
||||||
|
1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||||
|
`~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
|
||||||
|
(旧目录会自动继续沿用)。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
从源码构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/JianFeeeee/homeagentsdk
|
||||||
|
cd homeagentsdk/tools/hmapdev
|
||||||
|
go build -o hmapdev
|
||||||
|
# 把 hmapdev 放进 PATH,或直接用 ./hmapdev
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以从 SDK 的 release 附件下载预编译二进制(`hmapdev_linux_amd64` 等,
|
||||||
|
共 5 个平台:linux/darwin/windows × amd64/arm64)。
|
||||||
|
|
||||||
|
> 仓已迁到 GitHub;gitcode 仅作国内镜像(源码同步,**release 附件暂时仍在那里**):
|
||||||
|
> `https://gitcode.com/JianFeeeee/homeagent-sdk/releases`。
|
||||||
|
> Go 模块路径仍是 `gitcode.com/JianFeeeee/homeagent-sdk` —— 这是有意保留的,
|
||||||
|
> 改模块路径会让现有插件的 `go.mod` 全面失效。
|
||||||
|
|
||||||
|
## SDK 版本管理
|
||||||
|
|
||||||
|
`hmapdev` 会维护一份本地 SDK 存储,`init` 时按 `plg.json` 里的 `sdk` 字段
|
||||||
|
选择版本。两者**必须**一致,否则编译出的插件与内核协议可能错配。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk list # 已安装的 SDK 版本
|
||||||
|
hmapdev sdk current # 当前使用的版本
|
||||||
|
hmapdev sdk latest # 最新可用版本
|
||||||
|
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||||
|
hmapdev sdk use v1.2.0 # 切换版本
|
||||||
|
hmapdev sdk path # 当前 SDK 路径
|
||||||
|
```
|
||||||
|
|
||||||
|
存储在 `~/.homeagent/hmapdev/sdk/<version>/`。
|
||||||
|
|
||||||
|
!!! warning "版本未命中会**明确报错**"
|
||||||
|
`plg.json` 声明的 `sdk` 版本若不在本地存储里,`hmapdev` 不会退回某个默认版本,
|
||||||
|
而是报错并让你先 `hmapdev sdk install`。这是有意的:静默降级会产出与内核
|
||||||
|
协议不匹配的插件,那种失败要到运行时才暴露。
|
||||||
|
|
||||||
|
## 源码调试
|
||||||
|
|
||||||
|
不编译直接跑插件源码,输出调用轨迹:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev debug [dir] # dir 默认当前目录
|
||||||
|
```
|
||||||
|
|
||||||
|
写 Lua 插件时更简单——`sdk.lua` 是 SDK 模拟层,可以直接用解释器跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lua main.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [第一个 Go 插件](first-plugin.md)
|
||||||
|
- [第一个 Lua 插件](first-lua-plugin.md)
|
||||||
56
docs/guide/multi-platform.md
Normal file
56
docs/guide/multi-platform.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 多平台构建
|
||||||
|
|
||||||
|
## 默认就是多平台
|
||||||
|
|
||||||
|
`hmapdev build` 默认 bundle 模式,一次产出含三个平台的单个 `.hmap`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/myplugin_bundle.hmap
|
||||||
|
└── plugin.bin.linux.amd64
|
||||||
|
└── plugin.bin.darwin.amd64
|
||||||
|
└── plugin.bin.windows.amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
安装时内核挑当前平台那份,重命名为 `plugin.bin`。
|
||||||
|
|
||||||
|
## 逐平台构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build --no-bundle # 按 plg.json 的 targets 构建
|
||||||
|
hmapdev build --target linux/arm64 # 追加一个目标
|
||||||
|
```
|
||||||
|
|
||||||
|
`plg.json` 里声明目标:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "myplugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"targets": "linux/amd64,windows/amd64"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
单平台输出文件名:`{name}_{os}_{arch}.hmap`。
|
||||||
|
|
||||||
|
## 交叉编译
|
||||||
|
|
||||||
|
子进程插件**不再需要 cgo**,所以交叉编译不需要目标平台的 C 工具链 ——
|
||||||
|
这是 v1.0.0 的收益之一。
|
||||||
|
|
||||||
|
!!! note "bundle 模式忽略 `targets`"
|
||||||
|
固定构建 linux/amd64、darwin/amd64、windows/amd64。如果你只需要其中一个,
|
||||||
|
用 `--no-bundle` 更快。
|
||||||
|
|
||||||
|
## 平台能力差异
|
||||||
|
|
||||||
|
历史上有过一处真实的平台断层,现已消除:
|
||||||
|
|
||||||
|
- **v1.0.0 之前**:Windows 上插件只看到 **3 个 stage 字段、且无法写回**。
|
||||||
|
- **v1.0.0 起**:Windows 与其他平台**共用同一套 RPC 实现**,16 字段全可见 + 写回。
|
||||||
|
|
||||||
|
因此**不必**为 Windows 写条件分支 —— 除非你的插件自己用了平台专有的外部命令。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [打包与发布](packaging.md)
|
||||||
|
- [环境与工具链](getting-started.md)
|
||||||
114
docs/guide/packaging.md
Normal file
114
docs/guide/packaging.md
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# 打包与发布
|
||||||
|
|
||||||
|
`hmapdev build` 一次完成编译与打包,产出 `.hmap` 分发包(zip 格式,内含
|
||||||
|
`plugin.json` 清单 + 二进制)。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # 默认 bundle(多平台合集)
|
||||||
|
hmapdev build --no-bundle # 只构建 plg.json targets 里的平台
|
||||||
|
hmapdev build --target linux/arm64 # 在 targets 基础上追加目标
|
||||||
|
hmapdev build --outdir out # 指定输出目录(默认 dist)
|
||||||
|
hmapdev build --sdk-path <path> # 覆盖 go.mod 的 replace 指向的 SDK
|
||||||
|
hmapdev build --replace <mod@path> # 追加 go.mod replace(可多次)
|
||||||
|
```
|
||||||
|
|
||||||
|
执行流程:
|
||||||
|
|
||||||
|
1. 读 `plg.json` 的 `targets` / `bundle` 决定构建目标
|
||||||
|
2. 生成子进程运行时代码(`z_proc_gen.go`、`z_proc_shm_*.go`)
|
||||||
|
3. **Go 插件**:`go build`(普通可执行文件,`CGO_ENABLED=0`)
|
||||||
|
**Lua 插件**:直接打包源码,不编译
|
||||||
|
4. 生成 `plugin.json` 输出清单
|
||||||
|
5. 打成 `.hmap`
|
||||||
|
|
||||||
|
## 两个 JSON 的区别
|
||||||
|
|
||||||
|
这一点经常混淆:
|
||||||
|
|
||||||
|
| 文件 | 谁维护 | 作用 | 关键字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `plg.json` | **你** | 项目元信息,构建输入 | `targets`、`bundle` |
|
||||||
|
| `plugin.json` | `hmapdev` 自动生成 | 构建产物清单 | `entry`、`platforms` |
|
||||||
|
|
||||||
|
`plg.json` 里的 `sdk` 字段声明**本插件针对的 SDK 版本**;未命中本地 SDK 存储
|
||||||
|
会明确报错(见[环境与工具链](getting-started.md))。
|
||||||
|
|
||||||
|
## 多平台(bundle)
|
||||||
|
|
||||||
|
`build` 默认就是 bundle 模式:一次编译 linux/amd64、darwin/amd64、windows/amd64,
|
||||||
|
产出一个含全部平台二进制的 `.hmap`;安装时内核挑当前平台那份。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build # → dist/myplugin_bundle.hmap
|
||||||
|
hmapdev build --no-bundle # → dist/myplugin_linux_amd64.hmap 等
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note "bundle 模式会忽略 `plg.json` 的 `targets`"
|
||||||
|
固定构建上述三个平台。交叉编译需要对应工具链(如 Linux 上构建 darwin 需要
|
||||||
|
clang / macOS SDK),缺工具链时会失败 —— 此时用 `--no-bundle` 只构建当前平台。
|
||||||
|
|
||||||
|
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分,安装时重命名为 `plugin.bin`。
|
||||||
|
|
||||||
|
## 产物形态
|
||||||
|
|
||||||
|
子进程插件是**普通可执行文件**,不分平台后缀:
|
||||||
|
|
||||||
|
| 平台 | 二进制 |
|
||||||
|
|---|---|
|
||||||
|
| Linux / macOS / Windows | `plugin.bin` |
|
||||||
|
|
||||||
|
!!! warning "v1.0.0 破坏性变更:不再加载 `.so` / `.dll`"
|
||||||
|
外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
|
||||||
|
|
||||||
|
- `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。
|
||||||
|
新内核遇到旧产物会跳过并报可操作错误,不崩溃。
|
||||||
|
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev`
|
||||||
|
(原 `plugindev`)重编即可。
|
||||||
|
- `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写 `plugin.so` 也无妨),
|
||||||
|
现在只用于区分 Lua 插件。
|
||||||
|
- 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
三种方式(`9876` 是 pluginmgr 的本地端口,默认只监听 `127.0.0.1`、无鉴权):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 URL 安装(仅 http/https,流式下载不落盘)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 从本地路径安装(读取文件,不移动原文件)
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
|
||||||
|
# 直接上传二进制
|
||||||
|
curl -X POST http://127.0.0.1:9876/plugins \
|
||||||
|
--data-binary @dist/myplugin_bundle.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后调用 `/api/v1/plugins/reload` 或重启内核生效。
|
||||||
|
|
||||||
|
走 WebUI 的 HTTP API(默认 `8080`,需 `api_key` 鉴权,内部代理到 pluginmgr):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/plugins \
|
||||||
|
-H "Authorization: Bearer <api_key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"path": "/path/to/myplugin.hmap"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在 WebUI 的插件管理页面上传。
|
||||||
|
|
||||||
|
## 发布前自查
|
||||||
|
|
||||||
|
- [ ] `plg.json` 的 `sdk` 版本与目标内核匹配
|
||||||
|
- [ ] `version` 已递增(内核按版本判断是否需要重装)
|
||||||
|
- [ ] 若插件有外部状态,`SetAutoRestart(false)` 或在 `Start` 里重建连接
|
||||||
|
(崩溃重启是**线性退避** 1s→2s→3s,5 分钟内第 4 次崩溃即停止,
|
||||||
|
见[生命周期](../api/lifecycle.md#pluginsdksetautorestart))
|
||||||
|
- [ ] `RegisterOnRemoveHandler` 里清理自己写下的数据文件
|
||||||
|
- [ ] 在 `-race` 下跑一遍:插件的 `Start` 与工具的并发访问是最常见的竞态来源
|
||||||
112
docs/guide/parallel-tool-declaration.md
Normal file
112
docs/guide/parallel-tool-declaration.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# 工具并发声明:`ParallelSafe` / `Serial`
|
||||||
|
|
||||||
|
> 对应 `sdk.ToolDef` 的两个字段。内核在**同一轮**收到多个 `tool_call` 时,
|
||||||
|
> 依据它们决定并发还是整批串行。
|
||||||
|
>
|
||||||
|
> 状态:已随 2026-09 的并行内核落地并在生产启用。
|
||||||
|
|
||||||
|
## 1. 为什么是"保守 opt-in"
|
||||||
|
|
||||||
|
**默认整批串行。** 只有当**批内每一个**工具都显式声明 `ParallelSafe: true`
|
||||||
|
时,那一批才并发;**只要有一个不声明,整批退回串行**。
|
||||||
|
|
||||||
|
这不是"漏了声明导致退化"的将就,而是刻意的设计:
|
||||||
|
|
||||||
|
- 存量插件**不改一行**就得到保守行为(整批串行),不会被升级意外并发
|
||||||
|
- 声明是**责任**而非特权 —— 声明者必须自己确认线程安全
|
||||||
|
- 宁可慢,不可错:一次错误的并发可能让两个工具抢同一个 SQLite 写、
|
||||||
|
同一台设备、或同一个输出通道
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 同批全是安全工具 → 并发
|
||||||
|
tools: [a(ParallelSafe), b(ParallelSafe)] ⇒ 并发
|
||||||
|
|
||||||
|
// 只要有一个没声明 → 整批串行
|
||||||
|
tools: [a(ParallelSafe), b(默认)] ⇒ 串行
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 三个条件都满足才可以声明 `ParallelSafe`
|
||||||
|
|
||||||
|
1. **handler 自身线程安全** —— 不持有跨调用的可变状态
|
||||||
|
2. **不与同批其它工具争抢同一资源** —— SQLite 写、设备、同一输出通道
|
||||||
|
3. **执行顺序无关** —— 顺序敏感的工具应留 `false`,由内核保序
|
||||||
|
|
||||||
|
第 3 条常被忽略:内核能保证**用户可见的消息**按声明顺序落盘,但**工具
|
||||||
|
之间的实际执行先后**在并发模式下不确定。有顺序依赖就留 `false`。
|
||||||
|
|
||||||
|
## 3. `Serial`:显式的反向标记
|
||||||
|
|
||||||
|
```go
|
||||||
|
Serial bool `json:"serial,omitempty"`
|
||||||
|
```
|
||||||
|
|
||||||
|
`ParallelSafe` 的零值 `false` 已经表达"串行",插件**无法区分**:
|
||||||
|
|
||||||
|
- "我没想过"
|
||||||
|
- "我确认过**必须**串行,且有原因"
|
||||||
|
|
||||||
|
一旦工具作者需要把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
|
||||||
|
这个区分就是必需的 —— 否则只能靠命名约定传递意图。
|
||||||
|
|
||||||
|
适用场景:读操作但有隐含顺序约束(终端 `read`/`resize` 这类共享会话
|
||||||
|
状态);写操作虽已加锁但需要串行以获得可预测的交错顺序。
|
||||||
|
|
||||||
|
**优先级:`Serial` 胜出。** 即使同时写了 `ParallelSafe: true`,`Serial`
|
||||||
|
仍然生效 —— 显式声明"必须串行"不允许被 `ParallelSafe` 或任何默认值覆盖。
|
||||||
|
|
||||||
|
## 4. 写法
|
||||||
|
|
||||||
|
声明字段放在 `ToolDef` 结构体的**末尾**,遵循既有 `NoMemory` 的风格:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterTool(sdk.ToolDef{
|
||||||
|
Name: "my_readonly_query",
|
||||||
|
Description: "……",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
Handler: h.query,
|
||||||
|
ParallelSafe: true, // 声明在末尾
|
||||||
|
}, s)
|
||||||
|
```
|
||||||
|
|
||||||
|
需要"故意串行"时:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.RegisterTool(sdk.ToolDef{
|
||||||
|
Name: "my_terminal_input",
|
||||||
|
Handler: h.input,
|
||||||
|
Serial: true, // 胜出,忽略 ParallelSafe
|
||||||
|
}, s)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 内置工具
|
||||||
|
|
||||||
|
核心仓的内置工具用 `toolDefOptions` / `parallelOpts()` 声明
|
||||||
|
(`internal/agent/core/tooldefs.go`),最终由 `buildToolDefs` 汇总成
|
||||||
|
`ToolDef.ParallelSafe`。
|
||||||
|
|
||||||
|
内核只提供并行调度基础设施,**不硬编码任何工具名的安全状态表** ——
|
||||||
|
状态由每个工具在自己的声明结构里给出,查询时走聚合表。
|
||||||
|
|
||||||
|
## 6. 效果(实测)
|
||||||
|
|
||||||
|
同批 N 个各约 250ms 的工具,新版内核"内部并发"对"强制串行":
|
||||||
|
|
||||||
|
| N | 加速比 |
|
||||||
|
| --- | --- |
|
||||||
|
| 2 | ~1.41× |
|
||||||
|
| 4 | ~2.22× |
|
||||||
|
| 8 | ~3.88× |
|
||||||
|
|
||||||
|
批耗时几乎不随 N 增长,串行批严格线性。
|
||||||
|
|
||||||
|
> 压测时**必须同时记录实际执行的工具数**,不能只看耗时。
|
||||||
|
> 某次对照中旧版本耗时更短、但因适配器缺 `stream_index` 导致
|
||||||
|
> **实际处理 0 个工具** —— 那不是性能提升,是全失败。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
设计背景与踩坑见**核心仓**文档(不在本仓):
|
||||||
|
|
||||||
|
- `docs/zh/toolcall-contract-and-sequence-design.md` —— 契约与序列设计
|
||||||
|
- `docs/zh/toolcall-parallel-execution-plan.md` —— 阶段、实测压测数据
|
||||||
|
- `docs/zh/deploy-runbook.md` —— 生产部署(含适配器 `stream_index` 相关)
|
||||||
113
docs/guide/scene-memory.md
Normal file
113
docs/guide/scene-memory.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# 场景记忆(Scene Memory)
|
||||||
|
|
||||||
|
> 场景式记忆是内核 v1.3 起的能力。它不新增 API 面,只影响**你的输入被怎样记住与取回**。
|
||||||
|
> 与 `NoMemory` / `ContextPolicy` / `RecallPolicy` 并列为第四项声明:`ScenePolicy`。
|
||||||
|
|
||||||
|
## 它解决什么问题
|
||||||
|
|
||||||
|
三层记忆按**字面相关性**召回:你得说出相近的词,记忆才会被取回来。
|
||||||
|
场景记忆补上另一半:按**场合**召回。
|
||||||
|
|
||||||
|
同一场合再次出现时,当时挂在这个场合上的约定、偏好、人物关系会自动回来——
|
||||||
|
与这次说了什么措辞无关。
|
||||||
|
|
||||||
|
```
|
||||||
|
你:以后在群里回消息简短点
|
||||||
|
└─ 这条记忆挂到场面「chan:qq + peer:group_xxx」上
|
||||||
|
|
||||||
|
一周后,同一个群里有人问「上次说的格式是什么」
|
||||||
|
└─ 场面重现(还没等你提到「格式」),那条约定已经被取回
|
||||||
|
```
|
||||||
|
|
||||||
|
## 场面是自己长出来的
|
||||||
|
|
||||||
|
场景**不需要声明**。每轮交互,内核采集一组可观察信号当这轮<E8BF99><E8BDAE><EFBFBD>「场面指纹」:
|
||||||
|
|
||||||
|
| 特征 | 来源 | 权重 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `chan` | 输入通道名 | 1.0 | 最强的同一性信号 |
|
||||||
|
| `peer` / `peer_group` | 注入点给的 `payload` 里的 `group_id`/`user_id`/`chat_id` 等 | 1.0 | 群与私聊分开,避免互相命中 |
|
||||||
|
| `tool` | 触发这一步的工具名 | 0.8 | 行为信号 |
|
||||||
|
| `topic` | 清洗后输入的内容词 | 0.4 | 软信号,同场面的不同话题不该被拆开 |
|
||||||
|
| `part` | 时段(夜间/上午/下午/晚间) | 0.2 | 最弱,只做辅助 |
|
||||||
|
|
||||||
|
指纹反复重合时,一场场面就成形了。相似度按**加权 Jaccard** 算
|
||||||
|
(共享特征的权重和 ÷ 并集的权重和)——不加权的话,一次偶然的话题重合
|
||||||
|
会把两个不同场面并成一个。
|
||||||
|
|
||||||
|
**同类场面出现第二次才被认定。** 一次性的交互不建场面:
|
||||||
|
那不是「场面」,建了只会让图库被一次性事件撑满。
|
||||||
|
|
||||||
|
## 声明你的参与姿态
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.ChannelDef{
|
||||||
|
ScenePolicy: sdk.ScenePolicyNone, // 这条通道不参与场面识别
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或单次注入覆盖:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sdk.InjectOptions{
|
||||||
|
ScenePolicy: sdk.ScenePolicyNone,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 取值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `""`(空)/ `ScenePolicyAuto` | **参与**(默认,保持既有行为) |
|
||||||
|
| `ScenePolicyNone` | **不参与**:不产任何场面指纹,也不派生场景键 |
|
||||||
|
|
||||||
|
**默认是参与而不是不参与**,与 `ContextPolicy` 刻意相反。原因是场景只
|
||||||
|
**附加**检索路径、不改记忆本体,默认关会让存量通道突然失去场景召回;
|
||||||
|
而「关」是少数意图(纯内部信号)。
|
||||||
|
|
||||||
|
声明 `none` 之后连时段特征都不产——一个不参与的门面不该在场面索引里
|
||||||
|
留下任何足迹。
|
||||||
|
|
||||||
|
### 谁该考虑关掉
|
||||||
|
|
||||||
|
内核自循环(`system`)、心跳(`timer`)、内部状态汇报(`kernel`)这类
|
||||||
|
纯内部信号。它们每次触发都在撑一个场面,会把不相干的交互聚到一起。
|
||||||
|
|
||||||
|
反过来说,**多标一个通道通常没有代价**:一个没人往上面写记忆的场面,
|
||||||
|
召回时返回空。关不关都不影响正确性——所以拿不准时,默认参与就好。
|
||||||
|
|
||||||
|
## 怎么给场面命名
|
||||||
|
|
||||||
|
场景键有两种来源:
|
||||||
|
|
||||||
|
**通道派生(默认)**——`evt.Source` 派生出 `chan:qq` 这类键。你不用管。
|
||||||
|
|
||||||
|
**显式声明(进阶)**——在注入时给出更有语义的键:
|
||||||
|
|
||||||
|
```go
|
||||||
|
p.sdk.InjectInterruptTextOpts("qq", "qq", text, sdk.InjectOptions{
|
||||||
|
ScenePolicy: sdk.ScenePolicyAuto,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以通过 `payload["scene"]` 传层级键(支持 `string` / `[]string` /
|
||||||
|
`[]interface{}` 三种形态):
|
||||||
|
|
||||||
|
```go
|
||||||
|
"chan:qq/peer:group_1027"
|
||||||
|
```
|
||||||
|
|
||||||
|
召回走**前缀匹配**(`chan:qq` 能覆盖 `chan:qq/peer:xxx`),用 `/` 兜底
|
||||||
|
以免 `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。
|
||||||
|
|
||||||
|
## 场面记忆不改变什么
|
||||||
|
|
||||||
|
- **不改记忆本体**:场景是记忆的**附加索引**,删掉场景不删记忆。
|
||||||
|
- **不让模型负责**:`memory_commit` 的 `scene` 留空即可,内核会挂到本轮
|
||||||
|
解析出的场面上。留空是安全的一侧——猜错的场面会把无关记忆钉死。
|
||||||
|
- **不影响同步通道**:`webui` / `cli` / 终端走 `ResponseCh`,不经
|
||||||
|
`output_send__*`,与场面无关。
|
||||||
|
|
||||||
|
## 相关 API
|
||||||
|
|
||||||
|
- `ChannelDef.ScenePolicy` —— 通道级声明(见 [输入/输出通道](../api/channels.md))
|
||||||
|
- `InjectOptions.ScenePolicy` —— 单次注入覆盖(见 [其他类型](../api/misc.md))
|
||||||
|
- `ScenePolicyAuto` / `ScenePolicyNone` / `ValidScenePolicy` —— 常量与校验
|
||||||
81
docs/guide/security.md
Normal file
81
docs/guide/security.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# 受限 SDK 与安全
|
||||||
|
|
||||||
|
外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**:
|
||||||
|
外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。
|
||||||
|
|
||||||
|
## 三层隔离
|
||||||
|
|
||||||
|
| 层 | 机制 | 防住了什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** |
|
||||||
|
| **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 |
|
||||||
|
| **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 |
|
||||||
|
|
||||||
|
第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益:
|
||||||
|
在此之前,插件 panic 会带崩 `homed`。
|
||||||
|
|
||||||
|
## 受限接口是怎么实现的
|
||||||
|
|
||||||
|
**按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的
|
||||||
|
接口声明**,公开的那个只保留安全子集:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 公开 SDK:6 个只读方法
|
||||||
|
type SocialAPI interface {
|
||||||
|
GetPerson(name string) (*PersonProfile, error)
|
||||||
|
GetTrait(name, trait string) (string, bool)
|
||||||
|
GetRelations(name string) ([]SocialRelation, error)
|
||||||
|
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||||
|
ListPersons() ([]string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**,
|
||||||
|
不是靠运行时检查拦截。
|
||||||
|
|
||||||
|
同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 插件可以订阅,但由内核决定投递哪些事件
|
||||||
|
type EventSubscriber interface {
|
||||||
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进程边界带来的约束
|
||||||
|
|
||||||
|
事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`,
|
||||||
|
但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber`
|
||||||
|
在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe`
|
||||||
|
RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。
|
||||||
|
|
||||||
|
这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。
|
||||||
|
详见[能力边界](capability-boundary.md)。
|
||||||
|
|
||||||
|
## 共享内存中的数据面
|
||||||
|
|
||||||
|
工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**,
|
||||||
|
RPC 只传偏移描述符。因此:
|
||||||
|
|
||||||
|
- 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大;
|
||||||
|
- StageContext 在同一份状态上读改写,消除了副本模型的 lost update
|
||||||
|
(实测由 35.8~36.8% 降到 0)。
|
||||||
|
|
||||||
|
`SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 ——
|
||||||
|
公开 SDK 只暴露普通字符串与 map。
|
||||||
|
|
||||||
|
## 插件作者的实践建议
|
||||||
|
|
||||||
|
- **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。
|
||||||
|
- **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护
|
||||||
|
(`example/memo` 用 `sync.RWMutex`)。
|
||||||
|
- **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。
|
||||||
|
- **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西,
|
||||||
|
别让它们污染记忆(`InjectOptions{NoMemory: true}`)。
|
||||||
|
- **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。
|
||||||
|
|
||||||
|
## 许可与分发
|
||||||
|
|
||||||
|
SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。
|
||||||
|
这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会
|
||||||
|
强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。
|
||||||
108
docs/guide/stream-tool-call-index.md
Normal file
108
docs/guide/stream-tool-call-index.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 流式多 `tool_call`:适配器必须透传 `index`
|
||||||
|
|
||||||
|
> 面向在 Lua 里写适配器(`transform_stream_chunk`)的插件作者。
|
||||||
|
>
|
||||||
|
> 状态:已随 2026-09 的并行内核落地;生产 `openai.lua` 等适配器已修复。
|
||||||
|
|
||||||
|
## 1. 问题
|
||||||
|
|
||||||
|
OpenAI 兼容的流式响应里,同一轮的多个 `tool_call` 以**分片**形式到达,
|
||||||
|
靠 `index` 字段区分归属:
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":0,"id":"call_a","function":{"name":"alpha","arguments":""}}]}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":1,"id":"call_b","function":{"name":"beta","arguments":""}}]}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[
|
||||||
|
{"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**每个 SSE chunk 通常只含一个 `tool_call` 元素。** 内核按 `index` 分桶累积
|
||||||
|
`id` / `name` / `arguments`。
|
||||||
|
|
||||||
|
## 2. 适配器必须做的事
|
||||||
|
|
||||||
|
`transform_stream_chunk` 的输出 JSON 里,每个 tool call 分片都要带
|
||||||
|
**`stream_index`**(值取上游的 `index`):
|
||||||
|
|
||||||
|
```lua
|
||||||
|
table.insert(tcs, {
|
||||||
|
id = tc.id or "",
|
||||||
|
type = tc.type or "function",
|
||||||
|
name = name,
|
||||||
|
raw_arguments = raw_args,
|
||||||
|
-- ★ 必须透传上游 index(键名是 stream_index,不是 index)。
|
||||||
|
-- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片。
|
||||||
|
stream_index = tc.index or 0
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### ★ 键名是 `stream_index`,不是 `index`
|
||||||
|
|
||||||
|
内核的 `ToolCall.StreamIndex` 标签是 `json:"stream_index"`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
StreamIndex int `json:"stream_index,omitempty"`
|
||||||
|
```
|
||||||
|
|
||||||
|
写成 `index` 会被 Go 的解码器**静默丢弃**(无匹配字段),
|
||||||
|
`StreamIndex` 恒为 0 ⇒ 全部落进 `accs[0]`。
|
||||||
|
|
||||||
|
## 3. 不透传的实际后果
|
||||||
|
|
||||||
|
不是"少个字段",而是**多工具并行调用整体失效**:
|
||||||
|
|
||||||
|
| 现象 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` 相互覆盖 | 全进 `accs[0]`,后写的赢 |
|
||||||
|
| `arguments` 碎片混拼 | 两个工具的 JSON 片段交错拼接 |
|
||||||
|
| 报"参数不是合法 JSON" | 上面拼接的产物解析失败 |
|
||||||
|
| 工具被当成**空参数**调用 | 同上 |
|
||||||
|
|
||||||
|
2026-09-27 的对照压测里,旧适配器耗时**更短**但**实际处理 0 个工具** ——
|
||||||
|
每个工具都因参数非法失败。⇒ 压测**必须同时统计实际执行数**,不能只看耗时。
|
||||||
|
|
||||||
|
## 4. 还有两个容易踩的点
|
||||||
|
|
||||||
|
**① 不能按 `name` 过滤分片**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ✗ 错:后续块的 name 为空但携带 arguments
|
||||||
|
if tc.function and tc.function.name then ... end
|
||||||
|
|
||||||
|
-- ✓ 对:无 name 但有 arguments 的分片也要收,累积时再校验 name
|
||||||
|
```
|
||||||
|
|
||||||
|
**② 扁平结构 + `stream_index`**
|
||||||
|
|
||||||
|
部分协议族(`server` / `kimicode` / `anthropic` / `ollama`)的流式 tool call
|
||||||
|
是**扁平**结构(`name`/`arguments` 直接在 `tc` 上,不在 `tc.function` 里),
|
||||||
|
同样要带 `stream_index`。
|
||||||
|
|
||||||
|
## 5. 自检
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 适配器是否透传
|
||||||
|
grep -n "stream_index" /home/newqqagent/adapters/<你的>.lua
|
||||||
|
|
||||||
|
# 2) 实测:发一个同轮多工具的请求,看是否两个都真被执行
|
||||||
|
# 内核日志里 executing tool 应出现两次(可能并发)
|
||||||
|
journalctl -u homeagent.service --since "-2 min" | grep "executing tool"
|
||||||
|
|
||||||
|
# 3) 有没有参数解析失败
|
||||||
|
journalctl -u homeagent.service --since "-2 min" | grep -E "参数|合法 JSON"
|
||||||
|
```
|
||||||
|
|
||||||
|
> `gemini.lua` 目前**没有**流式 tool call 实现,因此不涉及本条。
|
||||||
|
> Gemini 协议是 `functionCall` 而非 `tool_calls`,不能照搬 OpenAI 的做法。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- `docs/guide/parallel-tool-declaration.md` —— 并发声明 `ParallelSafe`/`Serial`
|
||||||
|
- 核心仓 `docs/zh/toolcall-parallel-execution-plan.md` —— 压测数据与协议族清单
|
||||||
49
docs/index.md
Normal file
49
docs/index.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
用 **Go** 或 **Lua** 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
|
||||||
|
<div id="api-search"></div>
|
||||||
|
|
||||||
|
## 从这里开始
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-rocket-launch: **第一次写插件**
|
||||||
|
|
||||||
|
装工具链、生成工程、写一个工具、打包成 `.hmap` 装进内核跑起来。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 快速开始](guide/getting-started.md)
|
||||||
|
|
||||||
|
- :material-book-open-variant: **API 参考**
|
||||||
|
|
||||||
|
逐个符号的签名与说明,直接取自源码注释。附示例插件里的真实调用点。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 工具(Tools)](api/tools.md)
|
||||||
|
|
||||||
|
- :material-shield-lock: **能力边界**
|
||||||
|
|
||||||
|
哪些 API 外部插件能用、哪些仅内置插件可用,以及为什么。**先看这个能省很多时间。**
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 能力边界](guide/capability-boundary.md)
|
||||||
|
|
||||||
|
- :material-code-braces: **示例插件**
|
||||||
|
|
||||||
|
`example/` 下有多个真实可编译的插件,覆盖常见形态。
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: 示例总览](examples/index.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
SDK 以 **MIT** 发布 —— 插件作者可**自由选择自己的许可**(闭源、商业、私有均可),
|
||||||
|
不必同许可、也不必回馈。原因:SDK 会随插件一起静态链接(源码进入插件二进制),
|
||||||
|
若用传染性许可,插件作者就被强制开源;MIT 让第三方插件生态不必承担这个代价。
|
||||||
|
|
||||||
|
内核本身是 **AGPL-3.0-only**,但那是内核的许可,与外部插件无关 ——
|
||||||
|
SDK 完全自包含(`go.mod` 零外部依赖,只依赖 Go 标准库),不引用内核任何代码。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
本文档站的 API 参考从源码生成,对应 SDK 版本见 [版本与兼容](versions.md)。
|
||||||
264
docs/javascripts/api-search.js
Normal file
264
docs/javascripts/api-search.js
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* API 即时检索。
|
||||||
|
*
|
||||||
|
* 为什么要自建:Material 内置搜索按「整页文本」建索引,搜 `InjectText`
|
||||||
|
* 会把所有提到它的页面都列出来,但**分不清哪一条是它的定义**;而且内置
|
||||||
|
* 索引要等 mkdocs build 才生成,改一行 API 也得重建。
|
||||||
|
*
|
||||||
|
* 这里读的是 `assets/api-index.json`——由 tools/apidoc/gensite 直接产出,
|
||||||
|
* 每条记录带 名称/签名/描述/类别/所属页面/是否仅内置/源文件:行号。
|
||||||
|
* 因此可以做到:
|
||||||
|
* - 按名称搜(精确/前缀优先)
|
||||||
|
* - 按描述搜(中文按字、英文按词,都对 API 的文档注释做匹配)
|
||||||
|
* - 按签名搜(如 "(string) error")
|
||||||
|
* - 过滤「仅内置」——外部插件作者最容易被这个绊住
|
||||||
|
*
|
||||||
|
* 设计取舍:纯前端、零依赖、不阻塞页面。索引 ~130 条、约 40KB,一次拉取足够。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var INDEX_URL = (function () {
|
||||||
|
// 文档站可能部署在子路径下,按当前页面深度回推到站点根。
|
||||||
|
var path = window.location.pathname;
|
||||||
|
var marker = "/api/";
|
||||||
|
var i = path.indexOf(marker);
|
||||||
|
if (i >= 0) return path.slice(0, i) + "/assets/api-index.json";
|
||||||
|
// guide/ 等目录同样回退一层。
|
||||||
|
var lastSlash = path.lastIndexOf("/");
|
||||||
|
return path.slice(0, lastSlash) + "/assets/api-index.json";
|
||||||
|
})();
|
||||||
|
|
||||||
|
var state = { all: [], loaded: false, loading: false };
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (state.loaded || state.loading) return Promise.resolve(state.all);
|
||||||
|
state.loading = true;
|
||||||
|
return fetch(INDEX_URL)
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
state.all = data || [];
|
||||||
|
state.loaded = true;
|
||||||
|
return state.all;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
state.all = [];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 打分 ---------- */
|
||||||
|
//
|
||||||
|
// 三级优先级:名称命中 > 描述命中 > 签名命中。
|
||||||
|
// 名称命中里再分「完全相等 / 前缀 / 子串」,因为用户敲 `InjectText` 时
|
||||||
|
// 想要的是那个符号,不是所有名字里含它的。
|
||||||
|
|
||||||
|
function score(item, q) {
|
||||||
|
var name = (item.n || "").toLowerCase();
|
||||||
|
var ql = q.toLowerCase();
|
||||||
|
var s = 0;
|
||||||
|
|
||||||
|
if (name === ql) s += 1000;
|
||||||
|
else if (name.indexOf(ql) === 0) s += 600;
|
||||||
|
else if (name.indexOf(ql) > 0) s += 350;
|
||||||
|
|
||||||
|
// 中文检索关键词(keywords.json 产出,字段 g)。
|
||||||
|
// 为什么需要:SDK 里 66/100 个符号是英文注释(`RegisterTool registers a
|
||||||
|
// tool that the LLM can call.`),懂中文的人搜「注册工具」会一条都找不到。
|
||||||
|
// 关键词命中给较高权重(仅次于名称精确命中),因为它就是为「按功能找」准备的。
|
||||||
|
var kws = item.g || [];
|
||||||
|
for (var ki = 0; ki < kws.length; ki++) {
|
||||||
|
var kw = String(kws[ki]).toLowerCase();
|
||||||
|
if (kw === ql) { s += 480; break; }
|
||||||
|
if (kw.indexOf(ql) >= 0) { s += 300; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限定符:PluginSDK.RegisterTool / IOInjector.InjectText。
|
||||||
|
// 额外支持「去掉 API/SDK 后缀」与「去掉点号」两种写法,
|
||||||
|
// 因为读者习惯写 `memory.recall`(RPC 名),而 Go 名是 `MemoryAPI.Recall`。
|
||||||
|
var qual = ((item.r || "") + "." + name).toLowerCase();
|
||||||
|
if (item.r && qual.indexOf(ql) >= 0) s += 200;
|
||||||
|
if (item.r) {
|
||||||
|
var flat = qual.replace(/[._]/g, "").replace(/apis?dk|sdk|api/g, "");
|
||||||
|
var qflat = ql.replace(/[._\s]/g, "");
|
||||||
|
if (qflat && flat.indexOf(qflat) >= 0) s += 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desc = (item.d || "").toLowerCase();
|
||||||
|
if (desc.indexOf(ql) >= 0) s += 120;
|
||||||
|
|
||||||
|
// 签名按 token 匹配:把查询拆词(去掉括号/逗号等标点),全部命中才算。
|
||||||
|
// 这样 `(string) error`、`ContentBlock 媒体` 这类片段都能搜到。
|
||||||
|
// 注意必须先去标点:否则 token `(string)` 永远匹配不到签名里的 `string`。
|
||||||
|
var sig = (item.s || "").toLowerCase();
|
||||||
|
if (sig.indexOf(ql) >= 0) s += 60;
|
||||||
|
var toks = ql
|
||||||
|
.replace(/[()\[\]{},;:]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(function (t) { return t.length > 1; });
|
||||||
|
if (toks.length && sig.length) {
|
||||||
|
var allSig = toks.every(function (t) { return sig.indexOf(t) >= 0; });
|
||||||
|
if (allSig) s += 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文按字匹配:中文没有词边界,逐字命中比整串更实用。
|
||||||
|
// 注意只把它当作**弱信号**:光靠逐字会把「注册工具」匹到凡是含「工具」
|
||||||
|
// 字样的任何东西(实测 ContextPolicyNone 的说明里有「工具调用」也会命中)。
|
||||||
|
// 所以阈值卡在 60% 以上才算有效命中。
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(q)) {
|
||||||
|
var hit = 0;
|
||||||
|
var hay = desc + " " + kws.join(" ");
|
||||||
|
for (var i = 0; i < q.length; i++) {
|
||||||
|
if (hay.indexOf(q[i]) >= 0) hit++;
|
||||||
|
}
|
||||||
|
var ratio = hit / q.length;
|
||||||
|
if (ratio >= 0.6) s += Math.round(hit * 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开 API 略优先于「仅内置」——后者通常是噪声。
|
||||||
|
if (s > 0 && !item.b) s += 15;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(q) {
|
||||||
|
var qq = (q || "").trim();
|
||||||
|
if (!qq) return [];
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < state.all.length; i++) {
|
||||||
|
var sc = score(state.all[i], qq);
|
||||||
|
if (sc > 0) out.push({ item: state.all[i], score: sc });
|
||||||
|
}
|
||||||
|
out.sort(function (a, b) {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return (a.item.n || "").length - (b.item.n || "").length;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 渲染 ---------- */
|
||||||
|
//
|
||||||
|
// 挂在 Material 首页/目录页的一个容器上:#api-search。
|
||||||
|
// 没找到容器就不做任何事——这样同一份 JS 可以安全地全站引入。
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(mount, q) {
|
||||||
|
mount.innerHTML = "";
|
||||||
|
if (!q.trim()) {
|
||||||
|
mount.appendChild(el("p", "api-hint",
|
||||||
|
"输入 API 名称、描述或签名片段。例:InjectText、注册工具、崩溃、memory.recall、ContentBlock"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var results = search(q);
|
||||||
|
if (!results.length) {
|
||||||
|
mount.appendChild(el("p", "api-hint", "没有匹配的 API。试试更短的词,或按功能描述搜(如「注入」「重载」)。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var head = el("p", "api-count", "命中 " + results.length + " 个 API");
|
||||||
|
mount.appendChild(head);
|
||||||
|
|
||||||
|
var list = el("ul", "api-results");
|
||||||
|
results.slice(0, 40).forEach(function (r) {
|
||||||
|
var it = r.item;
|
||||||
|
var li = el("li", "api-result");
|
||||||
|
|
||||||
|
var title = el("a", "api-name", (it.r ? it.r + "." : "") + it.n);
|
||||||
|
// 锚点必须用**完整标题文本**(`PluginSDK.InjectText`,点号被 slug 丢掉),
|
||||||
|
// 不是裸方法名 —— 否则跳到页面顶部而到不了那一条。
|
||||||
|
title.href = pageURL(it.p) + "#" + anchorOf((it.r ? it.r + "." : "") + it.n);
|
||||||
|
li.appendChild(title);
|
||||||
|
|
||||||
|
if (it.b) {
|
||||||
|
var badge = el("span", "api-badge api-badge-builtin", "仅内置");
|
||||||
|
badge.title = "外部(第三方)插件运行时拿不到这个 API";
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(el("code", "api-sig", it.s || ""));
|
||||||
|
|
||||||
|
if (it.d) {
|
||||||
|
var d = el("span", "api-desc", it.d);
|
||||||
|
li.appendChild(d);
|
||||||
|
}
|
||||||
|
// 关键词是给检索用的;显示出来能让读者明白“为什么这条被匹配到”。
|
||||||
|
if (it.g && it.g.length) {
|
||||||
|
li.appendChild(el("span", "api-kw", it.g.slice(0, 6).join(" · ")));
|
||||||
|
}
|
||||||
|
if (it.f) {
|
||||||
|
li.appendChild(el("span", "api-loc", it.f + (it.l ? ":" + it.l : "")));
|
||||||
|
}
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
mount.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageURL(page) {
|
||||||
|
if (!page) return "#";
|
||||||
|
// 所有 API 章节都在 /api/ 下(生成物),示例页在 /examples/。
|
||||||
|
// 从当前 URL 回推到站点根,保证部署在子路径下也能用。
|
||||||
|
var path = window.location.pathname;
|
||||||
|
var i = path.indexOf("/api/");
|
||||||
|
var root;
|
||||||
|
if (i >= 0) {
|
||||||
|
root = path.slice(0, i + 1);
|
||||||
|
} else {
|
||||||
|
var j = path.indexOf("/guide/");
|
||||||
|
if (j >= 0) root = path.slice(0, j + 1);
|
||||||
|
else if (path.indexOf("/examples/") >= 0) root = path.slice(0, path.indexOf("/examples/") + 1);
|
||||||
|
else root = path.slice(0, path.lastIndexOf("/") + 1);
|
||||||
|
}
|
||||||
|
var dir = page === "examples" ? "examples" : "api";
|
||||||
|
return root + dir + "/" + page + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// anchorOf 复现 MkDocs 的 slug:小写、去掉非 [a-z0-9_-] 的字符(点号被去掉)、
|
||||||
|
// 下划线保留、空格转连字符。
|
||||||
|
function anchorOf(name) {
|
||||||
|
return String(name)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_ -]/g, "")
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
var box = document.getElementById("api-search");
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
var input = el("input", "api-input");
|
||||||
|
input.type = "search";
|
||||||
|
input.placeholder = "搜索 API:名称、描述、签名…";
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
input.setAttribute("spellcheck", "false");
|
||||||
|
|
||||||
|
var out = el("div", "api-output");
|
||||||
|
box.appendChild(input);
|
||||||
|
box.appendChild(out);
|
||||||
|
|
||||||
|
load().then(function () {
|
||||||
|
render(out, "");
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
render(out, input.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 支持 ?q= 直达(可从别处链接到一次检索)。
|
||||||
|
var m = /[?&]q=([^&]+)/.exec(window.location.search);
|
||||||
|
if (m) {
|
||||||
|
input.value = decodeURIComponent(m[1].replace(/\+/g, " "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", mount);
|
||||||
|
} else {
|
||||||
|
mount();
|
||||||
|
}
|
||||||
|
})();
|
||||||
36
docs/llms.txt
Normal file
36
docs/llms.txt
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
>
|
||||||
|
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
|
||||||
|
>
|
||||||
|
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
|
||||||
|
> 不含 HTML 样板;也可以直接取 https://sdk.homeagent.jianfgit.xyz/llms-full.txt 一次读完全部文档。
|
||||||
|
|
||||||
|
- [HomeAgent 插件 SDK](https://sdk.homeagent.jianfgit.xyz/index.md): 用 Go 或 Lua 为 HomeAgent 编写插件
|
||||||
|
- [桥接装配点(Bridge)](https://sdk.homeagent.jianfgit.xyz/api/bridge.md): 以下方法不是给插件业务代码调的——它们由 hmapdev 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例
|
||||||
|
- [仅内置插件可用的 API](https://sdk.homeagent.jianfgit.xyz/api/builtin-only.md): 这些 API 存在于公开 SDK 包里,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级
|
||||||
|
- [输入 / 输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels.md): 通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口
|
||||||
|
- [常量与枚举](https://sdk.homeagent.jianfgit.xyz/api/constants.md): SDK 里的取值枚举
|
||||||
|
- [事件(Events)](https://sdk.homeagent.jianfgit.xyz/api/events.md): 订阅内核事件
|
||||||
|
- [API 参考](https://sdk.homeagent.jianfgit.xyz/api/index.md): 本页所有内容从源码生成(tools/apidoc),签名与说明直接取自 sdk/*
|
||||||
|
- [生命周期(Lifecycle)](https://sdk.homeagent.jianfgit.xyz/api/lifecycle.md): 插件的启动、停止与卸载回调
|
||||||
|
- [LLM 调用](https://sdk.homeagent.jianfgit.xyz/api/llm.md): 让插件自己调用模型(而不是只等模型来调你)
|
||||||
|
- [记忆(Memory)](https://sdk.homeagent.jianfgit.xyz/api/memory.md): 三层记忆的读写接口:图记忆(三元组关系)、文档记忆(带元数据的文档)、文本记忆(事件流水)
|
||||||
|
- [其他类型](https://sdk.homeagent.jianfgit.xyz/api/misc.md): 剩余的类型与方法:PluginSDK 本体的访问器、StageContext 的并发控制,以及多模态辅助类型
|
||||||
|
- [配置(Settings)](https://sdk.homeagent.jianfgit.xyz/api/settings.md): 声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表
|
||||||
|
- [阶段钩子(Stages)](https://sdk.homeagent.jianfgit.xyz/api/stages.md): 在消息处理管道的固定点位插入自己的逻辑
|
||||||
|
- [工具(Tools)](https://sdk.homeagent.jianfgit.xyz/api/tools.md): 注册 LLM 可调用的工具
|
||||||
|
- [示例插件](https://sdk.homeagent.jianfgit.xyz/examples/index.md): SDK 仓 example/ 下有多个真实可编译的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态
|
||||||
|
- [能力边界:哪些 API 外部插件能用](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary.md): HomeAgent 有两类插件:
|
||||||
|
- [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin.md): Lua 插件适合轻量、快速原型:不需要 Go 编译环境,改完重启内核即可生效
|
||||||
|
- [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin.md): 以下是一个能直接跑起来的最小插件:注册一个工具、声明一项配置、处理停止与卸载
|
||||||
|
- [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
|
||||||
|
- [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个
|
||||||
|
- [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出
|
||||||
|
- [工具并发声明:`ParallelSafe` / `Serial`](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration.md): > 对应 sdk
|
||||||
|
- [场景记忆(Scene Memory)](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory.md): > 场景式记忆是内核 v1
|
||||||
|
- [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界:
|
||||||
|
- [流式多 `tool_call`:适配器必须透传 `index`](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index.md): > 面向在 Lua 里写适配器(transform_stream_chunk)的插件作者
|
||||||
|
- [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为
|
||||||
123
docs/stylesheets/extra.css
Normal file
123
docs/stylesheets/extra.css
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
/* API 即时检索与文档站的少量本地样式。
|
||||||
|
只补 Material 没覆盖的部分,不覆盖主题变量(保持深浅色自动适配)。 */
|
||||||
|
|
||||||
|
#api-search {
|
||||||
|
margin: 1.2rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--md-default-fg-color--lightest);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--md-default-bg-color);
|
||||||
|
color: var(--md-default-fg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-input:focus {
|
||||||
|
outline: 2px solid var(--md-accent-fg-color);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-hint {
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-count {
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 0.8rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-result {
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border-left: 3px solid var(--md-primary-fg-color);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: var(--md-code-bg-color);
|
||||||
|
border-radius: 0 0.2rem 0.2rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-name {
|
||||||
|
font-family: var(--md-code-font-family, monospace);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sig {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-desc {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「为何命中」的中文关键词(keywords.json 产出)。 */
|
||||||
|
.api-kw {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--md-accent-fg-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-loc {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--md-default-fg-color--lighter, var(--md-default-fg-color--light));
|
||||||
|
font-family: var(--md-code-font-family, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 0.08rem 0.34rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-badge-builtin {
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #b45309;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-color-scheme="slate"] .api-badge-builtin {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「仅内置」告警块里的依据说明通常很长,窄屏下允许更小字号。 */
|
||||||
|
@media screen and (max-width: 44.98em) {
|
||||||
|
.api-sig { font-size: 0.68rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 签名与长类型定义可能超出正文宽度(如 ContentBlock 的字段列表)。
|
||||||
|
highlight 代码块默认不换行,靠横向滚动;把默认改为换行显示,
|
||||||
|
因为文档读者更希望一眼看全签名而不是拖滚动条。 */
|
||||||
|
.md-typeset pre > code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 接口方法表里的签名可能很长,允许在任意位置折行。 */
|
||||||
|
.md-typeset table code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
77
docs/versions.md
Normal file
77
docs/versions.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# 版本与兼容
|
||||||
|
|
||||||
|
## SDK 版本语义
|
||||||
|
|
||||||
|
**SDK 版本跟随内核的中版本,patch 位恒为 `.0`。**
|
||||||
|
|
||||||
|
整条内核 `1.1.x` 线(1.1.0、1.1.1、1.1.7…)共用 **SDK 1.1.0**;
|
||||||
|
只有内核进入 `1.2.0` 这种中版本跃迁时,SDK 才升到 1.2.0。
|
||||||
|
|
||||||
|
这样插件作者只需关心「我在为哪个中版本写插件」,不必跟着内核的每个 bugfix 换依赖。
|
||||||
|
当前内核声明的兼容上限是 **SDK 1.3.0**。
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| SDK | 内核 | 变化 | 需要重编? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1.3.0** | 1.4.0+ | 驻留子 agent、`RecallPolicy` 等 | 想用新 API 才需要 |
|
||||||
|
| **1.2.0** | 1.2.0 / 1.3.x | `InjectOptions{NoMemory, ContextPolicy}`、六个 `*Opts` 变体、`ChannelDef.ContextPolicy` | 不需要 |
|
||||||
|
| **1.1.0** | 1.1.x | 多模态贯通:`Triple.SentenceText`、`Doc.Attachments`、`MediaAttachment`、`InsertWithMedia`、媒体注入方法 | 不需要 |
|
||||||
|
| **1.0.0** | 1.0.0+ | **运行模型变更**:C ABI 动态库 → 子进程 + 共享内存 | **需要** |
|
||||||
|
|
||||||
|
### 1.0.0 是唯一一次破坏性变更
|
||||||
|
|
||||||
|
- `.so` / `.dylib` / `.dll` **不再被加载**,遇到旧产物会跳过并报可操作错误(不崩溃)。
|
||||||
|
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` 重编即可。
|
||||||
|
- 产物从 `plugin.so` 变为 `plugin.bin`;不再需要 cgo。
|
||||||
|
|
||||||
|
### 1.1.0 / 1.2.0 是纯追加
|
||||||
|
|
||||||
|
两次都是**新增方法由插件调用、内核实现**,不调就不受影响。
|
||||||
|
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
|
||||||
|
想用新字段的重编即可。
|
||||||
|
|
||||||
|
!!! tip "什么时候必须重编"
|
||||||
|
只有两种情况:① 内核跨了中版本(如 1.1 → 1.2)且你用了新 API;
|
||||||
|
② 内核的 RPC 协议版本变了(`.hmap` 里的 `protocol` 字段与内核不匹配)。
|
||||||
|
后者的错配**不会静默失效** —— 握手时会显式拦下。
|
||||||
|
|
||||||
|
## RPC 协议版本
|
||||||
|
|
||||||
|
插件包里带 `protocol` 字段,必须等于内核的 `ProtocolVersion`(当前 **2**)。
|
||||||
|
|
||||||
|
协议 v2 引入了调用帧(tool / cleaner / output)与 `blocks_ref` 媒体块。
|
||||||
|
v1 插件遇上 v2 内核会拿到空参数,反过来 v2 插件发 `blocks_ref` 会被 v1 内核静默忽略 ——
|
||||||
|
**两边错配都不报错、只是静默失效**,所以协议版本在握手上显式校验。
|
||||||
|
|
||||||
|
## 怎么确认自己在用什么
|
||||||
|
|
||||||
|
装的 SDK 版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk current
|
||||||
|
hmapdev sdk list
|
||||||
|
```
|
||||||
|
|
||||||
|
插件声明的目标版本在 `plg.json` 的 `sdk` 字段。若该版本不在本地存储里,
|
||||||
|
`hmapdev` 会**明确报错**,不静默降级 —— 静默降级会产出与内核协议不匹配的包,
|
||||||
|
那种失败要到运行时才暴露。
|
||||||
|
|
||||||
|
## 文档站对应的版本
|
||||||
|
|
||||||
|
本页与 [API 参考](api/index.md) 由 `tools/apidoc` 从源码生成,
|
||||||
|
内容随源码一起演进。发现文档与代码不一致时,**改的是源码注释**,
|
||||||
|
`go run ./tools/apidoc` 重新生成即可(见下方「维护」)。
|
||||||
|
|
||||||
|
## 维护(给 SDK 维护者)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd homeagent-sdk
|
||||||
|
go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
|
||||||
|
go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs -examples ./example
|
||||||
|
mkdocs serve # 本地预览
|
||||||
|
mkdocs build # 产出 site_build/
|
||||||
|
```
|
||||||
|
|
||||||
|
API 面的**能力分层**(哪些 API 仅内置可用)记在 `tools/apidoc/tiers.json`,
|
||||||
|
每条裁定都附源码依据 —— 改这里而不是改生成物。
|
||||||
47
example/a2a/README.md
Normal file
47
example/a2a/README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# a2a · Agent-to-Agent 通信
|
||||||
|
|
||||||
|
让本 Agent 与其他 Agent **双向互调**:既能对外暴露自己的能力,也能去问别的 Agent。
|
||||||
|
|
||||||
|
## 两个方向
|
||||||
|
|
||||||
|
| 方向 | 怎么实现 |
|
||||||
|
|---|---|
|
||||||
|
| **入站**(别人问我) | 插件起一个 HTTP 服务端,暴露 `/agent-card`(能力描述)与 `/a2a`(JSON-RPC 入口) |
|
||||||
|
| **出站**(我问别人) | 提供 `a2a_query` / `a2a_discover` 工具,主动向远端 A2A Agent 发起请求 |
|
||||||
|
|
||||||
|
## HTTP 端点
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /agent-card` | 返回 Agent Card:本 Agent 的能力描述,供对方发现 |
|
||||||
|
| `POST /a2a` | JSON-RPC 2.0 入口,接收对方的任务请求 |
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `a2a_a2a_query` | 向另一个 A2A Agent 发查询并取回复 |
|
||||||
|
| `a2a_a2a_discover` | 取对方的 Agent Card(能力描述) |
|
||||||
|
| `a2a_a2a_status` | 看本插件运行状态(监听地址、当前配置) |
|
||||||
|
| `a2a_a2a_configure` | 改配置并自动重启服务(可动态改监听地址) |
|
||||||
|
| `a2a_a2a_restart` | 重启 HTTP 服务端(连接异常或改配置后用) |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),按默认 `a2a_` 列出。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | `127.0.0.1:12000` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站、不入站) |
|
||||||
|
|
||||||
|
## 典型用法
|
||||||
|
|
||||||
|
1. **先发现再调用**:`a2a_discover` 拿对方能力 → 决定要不要发、发什么 → `a2a_query`。
|
||||||
|
跳过 discovery 直接问,容易问出对方不支持的东西。
|
||||||
|
2. **只出站**:把 `listen` 设为空,本 Agent 不外露端口,但仍能主动联系别人。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "a2a",
|
"name": "a2a",
|
||||||
"name_zh": "A2A 代理通信",
|
"name_zh": "A2A 代理通信",
|
||||||
"name_en": "A2A Agent Communication",
|
"name_en": "A2A Agent Communication",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
51
example/acp/README.md
Normal file
51
example/acp/README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# acp · Agent Client Protocol 通信
|
||||||
|
|
||||||
|
[ACP](https://agentclientprotocol.com/) 桥接:本 Agent 既能**当服务端**接别人的任务,也能**当客户端**去调别的 ACP Agent。
|
||||||
|
|
||||||
|
## 两个方向
|
||||||
|
|
||||||
|
| 角色 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| **服务端** | 在本机起 HTTP 服务,处理 `session/new` / `session/update`,接受其他 Agent 的任务请求 |
|
||||||
|
| **客户端** | 通过 `acp_query` 向远程 ACP Agent 发 `session/new` 并读回复 |
|
||||||
|
|
||||||
|
## 协议端点
|
||||||
|
|
||||||
|
- `POST /api/session` —— JSON-RPC,支持 `session/new` 与 `session/update`
|
||||||
|
- 客户端侧同时兼容**两种服务端**:SSE 型(流式 `session/reply`)与同步 JSON 型
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `acp_acp_query` | 向远程 ACP Agent 发起会话并等待回复,返回最终回答文本 |
|
||||||
|
| `acp_acp_status` | 查看运行状态与**当前活跃会话数** |
|
||||||
|
| `acp_acp_configure` | 改监听配置并重启 HTTP 服务 |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),按默认 `acp_` 列出。
|
||||||
|
|
||||||
|
`acp_query` 可指向的远端举例(源码注释给的):
|
||||||
|
|
||||||
|
- opencode:`http://127.0.0.1:13000`
|
||||||
|
- pi bridge:`http://127.0.0.1:12011`
|
||||||
|
- 回环到自身:`http://127.0.0.1:12001`
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `listen` | `127.0.0.1:12001` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站) |
|
||||||
|
|
||||||
|
## 与 a2a 的区别
|
||||||
|
|
||||||
|
| | a2a | acp |
|
||||||
|
|---|---|---|
|
||||||
|
| 面向 | Agent ↔ Agent 对等通信 | 客户端 → Agent 会话(每次一个 session) |
|
||||||
|
| 会话 | 一问一答 | 有 session 生命周期,可续 |
|
||||||
|
| 发现 | `/agent-card` | 无(需已知地址) |
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "acp",
|
"name": "acp",
|
||||||
"name_zh": "ACP 代理通信",
|
"name_zh": "ACP 代理通信",
|
||||||
"name_en": "ACP Agent Client Protocol",
|
"name_en": "ACP Agent Client Protocol",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -1,13 +1,34 @@
|
|||||||
# ai_image
|
# ai_image · 文生图
|
||||||
|
|
||||||
ai_image plugin
|
按文字提示生成图片,下载到本地并返回**文件路径**。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ai_image_generate` | 按 prompt 生成图片 |
|
||||||
|
|
||||||
|
返回值是**本地文件路径**(永久,不过期)。要把图给用户看,再用导出的通道
|
||||||
|
以 `type=image`、`payload=<该路径>` 发送。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `api_key` | 空 | OpenAI / Stable Diffusion 的 API Key |
|
||||||
|
| `base_url` | 空 | 自定义 OpenAI 兼容网关(**不带 `/v1` 尾缀**,如 `http://127.0.0.1:8081`)。留空走官方 `https://api.openai.com` |
|
||||||
|
| `provider` | `openai` | 服务方:`openai` / `stability` |
|
||||||
|
| `model` | `dall-e-3` | 模型名(如 `dall-e-3`、`sd-xl`) |
|
||||||
|
| `size` | `1024x1024` | 默认尺寸,也可 `1024x1792` / `1792x1024` |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **返回本地路径而不是远端 URL**:远端图床链接会过期,写进记忆就成了悬空指针。
|
||||||
|
下载到本地后路径稳定,可交给媒体存储做内容寻址。
|
||||||
|
- 配了 `base_url` 就能指向自建/兼容网关,不必依赖官方接口。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
43
example/bili/README.md
Normal file
43
example/bili/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# bili · B站视频下载
|
||||||
|
|
||||||
|
用 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 把 B 站视频下载到本地。
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
需要系统里装有 `yt-dlp`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U yt-dlp # 或 apt install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `bili_video` | 下载 B 站视频;不指定 `format` 时先返回可用清晰度列表,指定后真正下载并返回文件路径 |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `url` | 视频地址 |
|
||||||
|
| `format` | 格式 ID。常用:`30112`/`30080`=1080P、`30064`=720P、`30032`=480P、`30016`=360P。不指定则自动选最优 |
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `output_dir` | `/tmp/bili_videos` | 下载目录 |
|
||||||
|
| `proxy` | 空 | yt-dlp 使用的 HTTP 代理(如 `http://127.0.0.1:7890`)。留空则不设代理 |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **`output_dir` 有安全校验**:它是配置项,但会拒绝被配成系统目录,避免 yt-dlp 往任意位置写文件。
|
||||||
|
- 两阶段用法:先不传 `format` 拿到清晰度清单(`format_id` + `format_note`),再带上选定的 ID 下载。这样模型不会盲选一个不存在的格式。
|
||||||
|
- B 站在部分网络环境下需要代理,见上面的 `proxy`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,12 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
@ -17,6 +20,41 @@ type Plugin struct {
|
|||||||
name string
|
name string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
proxy string
|
proxy string
|
||||||
|
|
||||||
|
// runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。
|
||||||
|
//
|
||||||
|
// 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。
|
||||||
|
// 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段,
|
||||||
|
// 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。
|
||||||
|
runMu sync.Mutex
|
||||||
|
runCancel context.CancelFunc
|
||||||
|
runWG sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRun 登记一次外部命令运行,返回完成时调用 untrack。
|
||||||
|
func (p *Plugin) trackRun() (context.Context, func()) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
p.runMu.Lock()
|
||||||
|
p.runCancel = cancel
|
||||||
|
p.runWG.Add(1)
|
||||||
|
p.runMu.Unlock()
|
||||||
|
return ctx, func() {
|
||||||
|
p.runWG.Done()
|
||||||
|
p.runMu.Lock()
|
||||||
|
p.runCancel = nil
|
||||||
|
p.runMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。
|
||||||
|
func (p *Plugin) killRunGroup(pid int) {
|
||||||
|
if pid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内)
|
||||||
|
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
@ -30,13 +68,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
Key: "output_dir", Default: "/tmp/bili_videos",
|
Key: "output_dir", Default: "/tmp/bili_videos",
|
||||||
Type: "string", DisplayName: "下载目录",
|
Type: "string", DisplayName: "下载目录",
|
||||||
Description: "B站视频下载后的保存目录",
|
Description: "B站视频下载后的保存目录",
|
||||||
Category: p.name,
|
Category: p.name,
|
||||||
})
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "proxy", Default: "",
|
Key: "proxy", Default: "",
|
||||||
Type: "string", DisplayName: "HTTP 代理",
|
Type: "string", DisplayName: "HTTP 代理",
|
||||||
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
|
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
|
||||||
Category: p.name,
|
Category: p.name,
|
||||||
})
|
})
|
||||||
if v, _ := s.Settings().Get("proxy"); v != nil {
|
if v, _ := s.Settings().Get("proxy"); v != nil {
|
||||||
if str, ok := v.(string); ok {
|
if str, ok := v.(string); ok {
|
||||||
@ -67,7 +105,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error { return nil }
|
// Stop 取消并等待正在跑的下载。
|
||||||
|
//
|
||||||
|
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
|
||||||
|
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
|
||||||
|
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
|
||||||
|
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
|
||||||
|
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
p.runMu.Lock()
|
||||||
|
cancel := p.runCancel
|
||||||
|
p.runMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
|
||||||
|
p.runWG.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type ytdlpFormat struct {
|
type ytdlpFormat struct {
|
||||||
FormatID string `json:"format_id"`
|
FormatID string `json:"format_id"`
|
||||||
@ -84,11 +139,11 @@ type ytdlpFormat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ytdlpInfo struct {
|
type ytdlpInfo struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
WebpageURL string `json:"webpage_url"`
|
WebpageURL string `json:"webpage_url"`
|
||||||
Filename string `json:"_filename"`
|
Filename string `json:"_filename"`
|
||||||
Formats []ytdlpFormat `json:"formats"`
|
Formats []ytdlpFormat `json:"formats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
|
||||||
@ -119,11 +174,20 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
||||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
ctx, done := p.trackRun()
|
||||||
|
defer done()
|
||||||
|
// CommandContext:Stop 里的 cancel 能直接掐掉它。
|
||||||
|
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
|
||||||
|
// killRunGroup 能一次带走整棵子进程树。
|
||||||
|
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &out
|
cmd.Stderr = &out
|
||||||
cmd.Env = proxyEnv(p.proxy)
|
cmd.Env = proxyEnv(p.proxy)
|
||||||
|
setPgid(cmd)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)")
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,12 +273,16 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
dlArgs = append(dlArgs, "-f", format)
|
dlArgs = append(dlArgs, "-f", format)
|
||||||
}
|
}
|
||||||
dlArgs = append(dlArgs, url)
|
dlArgs = append(dlArgs, url)
|
||||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
|
||||||
cmd2.Env = proxyEnv(p.proxy)
|
cmd2.Env = proxyEnv(p.proxy)
|
||||||
var dlOut bytes.Buffer
|
var dlOut bytes.Buffer
|
||||||
cmd2.Stdout = &dlOut
|
cmd2.Stdout = &dlOut
|
||||||
cmd2.Stderr = &dlOut
|
cmd2.Stderr = &dlOut
|
||||||
|
setPgid(cmd2)
|
||||||
if err := cmd2.Run(); err != nil {
|
if err := cmd2.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("下载已取消(插件停止)")
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,6 +324,15 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg)
|
||||||
|
// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。
|
||||||
|
func setPgid(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Setpgid = true
|
||||||
|
}
|
||||||
|
|
||||||
func proxyEnv(proxy string) []string {
|
func proxyEnv(proxy string) []string {
|
||||||
env := os.Environ()
|
env := os.Environ()
|
||||||
if proxy != "" {
|
if proxy != "" {
|
||||||
|
|||||||
66
example/browser/README.md
Normal file
66
example/browser/README.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# browser · 统一浏览器
|
||||||
|
|
||||||
|
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
|
||||||
|
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
|
||||||
|
|
||||||
|
## 三种能力层
|
||||||
|
|
||||||
|
| 层 | 工具 | 何时用 |
|
||||||
|
|---|---|---|
|
||||||
|
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
|
||||||
|
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
|
||||||
|
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
|
||||||
|
| **interactive(CDP)** | `browser_start` + `navigate`/`click`/`type`/`scroll`/`html`/`screenshot` | 需要交互:登录、点按、翻页 |
|
||||||
|
|
||||||
|
`browser_fetch` 的 `mode`:
|
||||||
|
|
||||||
|
- `auto`(默认):先试 HTTP,**遇 403/429 才降级**用 Chromium 渲染
|
||||||
|
- `render`:强制 Chromium
|
||||||
|
- `quick`:纯 HTTP,不降级
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `browser_search` | 网页搜索 |
|
||||||
|
| `browser_fetch` | 抓取 URL 内容,三种 mode 见上 |
|
||||||
|
| `browser_render` | 无头 Chromium 渲染并提取文本(normal) |
|
||||||
|
| `browser_start` | 启动交互式浏览器会话(CDP) |
|
||||||
|
| `browser_navigate` | 导航到指定 URL |
|
||||||
|
| `browser_click` | 点击元素 |
|
||||||
|
| `browser_type` | 输入文本 |
|
||||||
|
| `browser_scroll` | 滚动页面 |
|
||||||
|
| `browser_html` | 取当前页 HTML |
|
||||||
|
| `browser_screenshot` | 截图 |
|
||||||
|
| `browser_install` | 安装 systemd 托管的共享浏览器后端 |
|
||||||
|
| `browser_close` | 关闭会话 |
|
||||||
|
|
||||||
|
## 共享浏览器后端
|
||||||
|
|
||||||
|
`browser_install` 安装 `homeagent-browser.service`(systemd 托管)。
|
||||||
|
装上之后**所有 agent 共享同一个 Chromium 实例与登录态**,各自占独立标签页互不干扰
|
||||||
|
(同 source 复用自己的标签页)。
|
||||||
|
|
||||||
|
前提:本机已有 chromium 二进制,没有会提示先装(`apt install chromium` 或等价)。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **搜索用 `cn.bing.com` 而不是 `www.bing.com`**:后者对程序化请求常回 302(同意/重定向页),
|
||||||
|
根本拿不到结果块。
|
||||||
|
- **标题取 `<h2>` 里的 `<a>`**:直接抓结果块里第一个 `<a>` 会拿到来源行而非标题。
|
||||||
|
- **摘要认 `b_lineclamp`**:旧版 Bing 用 `b_caption`,新版已迁走,两套都匹配。
|
||||||
|
- **有 SSRF 防护**:见源码 `SSRF` 段,抓取前校验目标地址,避免被诱导访问内网。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`testdata/bing_cn.html` 是搜索解析的固定样本,用它做离线断言,避免测试依赖真实网络。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -1,13 +1,57 @@
|
|||||||
# calendar
|
# calendar · 日历事件
|
||||||
|
|
||||||
calendar plugin
|
事件管理:支持**重复事件**与**多档提醒**。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `calendar_event_add` | 添加事件 |
|
||||||
|
| `calendar_event_list` | 列出即将到来的事件(含日期、时间、重复规则) |
|
||||||
|
| `calendar_event_update` | 更新事件(**只改传入的字段**;会重置提醒状态) |
|
||||||
|
| `calendar_event_delete` | 删除事件(连带**该事件及之后的所有重复实例**) |
|
||||||
|
| `calendar_today` | 今日事件 + 倒计时 |
|
||||||
|
| `calendar_week` | 本周事件,按天分组 |
|
||||||
|
| `calendar_month` | 月历网格,带事件标记点 |
|
||||||
|
| `calendar_search` | 按关键词搜标题 / 地点 / 备注 |
|
||||||
|
|
||||||
|
`calendar_event_add` 的时间格式:`YYYY-MM-DD HH:MM`;只给 `YYYY-MM-DD` 表示全天事件。
|
||||||
|
|
||||||
|
## 重复规则
|
||||||
|
|
||||||
|
`repeat` 取值:
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `none` | 不重复 |
|
||||||
|
| `daily` | 每天 |
|
||||||
|
| `weekday` | 每个工作日 |
|
||||||
|
| `weekly` | 每周 |
|
||||||
|
| `biweekly` | 每两周 |
|
||||||
|
| `monthly` | 每月 |
|
||||||
|
| `yearly` | 每年 |
|
||||||
|
| `lunar_yearly` | **按农历年**(生日、传统节日用) |
|
||||||
|
|
||||||
|
`lunar_yearly` 是刻意加的:农历节日按公历写死会逐年偏移。
|
||||||
|
|
||||||
|
## 提醒
|
||||||
|
|
||||||
|
`remind_before` 单位是**分钟**,可给多个、逗号分隔:
|
||||||
|
|
||||||
|
```
|
||||||
|
15,60,1440 # 提前 15 分钟 + 1 小时 + 1 天
|
||||||
|
0 或留空 # 不提醒
|
||||||
|
```
|
||||||
|
|
||||||
|
到点通过 `InjectInterruptText` 注入提醒,带 `NoMemory: true` ——
|
||||||
|
提醒是瞬时信号,不是记忆内容。通道 `calendar` 同样声明为 NoMemory。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
事件存为 JSON,插件重启后保留。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ module deepsearch-plugin
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -18,4 +18,4 @@ require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||||
|
|||||||
@ -6,7 +6,12 @@
|
|||||||
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
|
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.bin",
|
"entry": "plugin.bin",
|
||||||
"sdk": "1.2.0",
|
"tags": [
|
||||||
"tags": ["search", "web", "searxng", "retrieval", "news"],
|
"search",
|
||||||
|
"web",
|
||||||
|
"searxng",
|
||||||
|
"retrieval",
|
||||||
|
"news"
|
||||||
|
],
|
||||||
"targets": "linux/amd64"
|
"targets": "linux/amd64"
|
||||||
}
|
}
|
||||||
49
example/editdoc/README.md
Normal file
49
example/editdoc/README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# editdoc · Office 文档编辑
|
||||||
|
|
||||||
|
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
|
||||||
|
|
||||||
|
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
|
||||||
|
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
|
||||||
|
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `file` | 文档路径(必填) |
|
||||||
|
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
|
||||||
|
| `target` | 要查找的文本(`replace_text` 用) |
|
||||||
|
| `replacement` | 替换为的文本(`replace_text` 用) |
|
||||||
|
| `sheet` | 工作表名(xlsx 可选) |
|
||||||
|
| `row` | 行号(`set_cell` / `insert_row` 用) |
|
||||||
|
| `col` | 列号(`set_cell` 用) |
|
||||||
|
| `value` | 单元格值(`set_cell` 用) |
|
||||||
|
|
||||||
|
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
|
||||||
|
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
|
||||||
|
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
|
||||||
|
|
||||||
|
- **需要自备 `edit_doc.py`**:它不在本目录里。
|
||||||
|
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
44
example/files/README.md
Normal file
44
example/files/README.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# files · 沙箱文件操作
|
||||||
|
|
||||||
|
读写与编辑文件,**全部操作限制在沙箱目录内**。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `files_read` | 读文件内容,支持 `offset` / `limit` 读大文件 |
|
||||||
|
| `files_write` | 写文件,**自动创建父目录** |
|
||||||
|
| `files_edit` | 按精确字符串替换改文件 |
|
||||||
|
| `files_ls` | 列目录(目录名带 `/` 后缀) |
|
||||||
|
|
||||||
|
`files_edit` 用 `edits[]` 传多组替换,每组 `{old, new}`:
|
||||||
|
|
||||||
|
- 每个 `old` 必须在**原文件**中**恰好出现一次** —— 不唯一会报错,避免改错地方。
|
||||||
|
- 所有替换都针对**原内容**匹配,不要在同一个 `edits` 里写相互重叠的改动。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `dir` | 空 | 允许访问的根目录。留空用默认沙箱(主数据目录下的 `files_sandbox`)。**不建议设为 `/`** |
|
||||||
|
|
||||||
|
## 沙箱实现
|
||||||
|
|
||||||
|
路径校验不止一次,是两道:
|
||||||
|
|
||||||
|
1. **规范化后判断**:`filepath.Abs` + `filepath.Clean`,再用 `withinSandbox`
|
||||||
|
检查结果是否在根目录之下(`/` 作为特例放行)。
|
||||||
|
2. **解析符号链接后再判断**:`filepath.EvalSymlinks` 求出真实路径,**再查一次**沙箱。
|
||||||
|
|
||||||
|
第 2 步是关键:只做第 1 步的话,沙箱内一个指向外部的软链接就能绕过限制
|
||||||
|
(`.../sandbox/link -> /etc`)。报错文案也区分了这两种情况
|
||||||
|
(`path outside sandbox` vs `path escapes sandbox via symlink`)。
|
||||||
|
|
||||||
|
对不存在的路径(`write` 会用到),求真实路径时只对已存在的部分做 `EvalSymlinks`,
|
||||||
|
其余保留为未创建的尾部。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -1,13 +1,16 @@
|
|||||||
# luademo
|
# luademo
|
||||||
|
|
||||||
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
|
Lua 插件全功能示例,展示 Lua SDK 的完整能力面(对齐 SDK 1.3.0):
|
||||||
|
|
||||||
- **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤)
|
- **工具注册**:`no_memory` + `context_policy` + `cleaner`(记忆计算层过滤)
|
||||||
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
||||||
- **通道**:`register_output_channel` / `register_input_channel`(def 支持 no_memory/cleaner)
|
- **通道**:`register_output_channel` / `register_input_channel` / `unregister_output_channel`(def 支持 no_memory/context_policy/cleaner)
|
||||||
- **数据类 API**:`sdk.memory.*`、`sdk.doc.*`、`sdk.knowledge.*`、`sdk.text_memory.*`、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`
|
- **注入**:`inject_text` / `inject_interrupt` / `inject_text_no_memory`、`*_opts`(no_memory/context_policy/cleaner_name/priority)、`inject_input_sync`、`inject_*_media`、`set_tool_blocks`
|
||||||
|
- **数据类 API**:`sdk.memory.*`(含 sentence_text/media_digests)、`sdk.doc.*`(含 insert_with_media)、`sdk.knowledge.*`、`sdk.text_memory.*`(含 attachments)、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`、`sdk.events.*`、`sdk.plugin_mgr.*`
|
||||||
- **其他**:`register_api`、`set_auto_restart`
|
- **其他**:`register_api`、`set_auto_restart`
|
||||||
|
|
||||||
|
> `luademo_probe_v2` 巡检 1.1/1.2/1.3 新增面。它**故意不调用** `inject_input_sync`:工具 handler 在 LLM 回合内运行,同步注入会自己等自己(死锁)。
|
||||||
|
|
||||||
## 本地独立测试
|
## 本地独立测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -67,6 +67,65 @@ function plugin.start(sdk)
|
|||||||
return { content = res }
|
return { content = res }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- 工具:1.1/1.2/1.3 新增能力巡检(媒体块 / 注入标志位 / 事件 / 动态通道注销)
|
||||||
|
-- 注意:故意不在这里调用 sdk.inject_input_sync——工具handler 运行在 LLM 回合内,
|
||||||
|
-- 同步注入会等本轮回复,等于自己等自己(死锁)。同步注入只适合事件回调等外部入口。
|
||||||
|
sdk.register_tool("luademo_probe_v2", {
|
||||||
|
description = "Exercise media blocks, inject opts, events and channel unregister",
|
||||||
|
parameters = { type = "object", properties = {} },
|
||||||
|
no_memory = true,
|
||||||
|
context_policy = "prune",
|
||||||
|
}, function(args)
|
||||||
|
local res = {}
|
||||||
|
|
||||||
|
-- 多模态:设置下一轮 tool message 携带的内容块
|
||||||
|
sdk.set_tool_blocks({
|
||||||
|
{ type = "text", text = "luademo media block" },
|
||||||
|
{ type = "image_url", image_url = { url = "https://example.com/x.png", detail = "low" } },
|
||||||
|
})
|
||||||
|
res.set_tool_blocks = "ok"
|
||||||
|
|
||||||
|
-- 注入标志位(零值 opts 与旧三参数等价)
|
||||||
|
sdk.inject_text_opts("luademo", "luademo_in", "opts inject", {
|
||||||
|
no_memory = true, context_policy = "prune",
|
||||||
|
})
|
||||||
|
res.inject_text_opts = "ok"
|
||||||
|
|
||||||
|
-- 带媒体的中断注入
|
||||||
|
sdk.inject_interrupt_media("luademo", "luademo_in", "media inject", {
|
||||||
|
{ type = "audio_url", audio_url = { url = "https://example.com/a.mp3" } },
|
||||||
|
})
|
||||||
|
res.inject_interrupt_media = "ok"
|
||||||
|
|
||||||
|
-- 媒体入记忆:三元组带原句,文档带附件
|
||||||
|
local _, merr = sdk.memory.commit({{
|
||||||
|
subject = "luademo", relation = "shows", object = "image",
|
||||||
|
sentence_text = "luademo shows an image", media_digests = {},
|
||||||
|
}})
|
||||||
|
res.memory_commit_with_sentence = { err = merr }
|
||||||
|
local _, derr = sdk.doc.insert_with_media(
|
||||||
|
{ id = "luademo-media", title = "media", content = "with attachment" },
|
||||||
|
{ { mime = "image/png", name = "x.png", data = "aGVsbG8=" } })
|
||||||
|
res.doc_insert_with_media = { err = derr }
|
||||||
|
|
||||||
|
-- 事件订阅(返回取消订阅函数)
|
||||||
|
local unsub = sdk.events.subscribe("agent_output", function(evt)
|
||||||
|
sdk.log("info", "luademo event: " .. tostring(evt.type))
|
||||||
|
end)
|
||||||
|
res.events_subscribe = type(unsub)
|
||||||
|
if unsub then unsub() end
|
||||||
|
|
||||||
|
-- 插件管理(只读查询)
|
||||||
|
res.plugin_mgr_loaded = type(sdk.plugin_mgr.list_loaded())
|
||||||
|
|
||||||
|
-- 动态输出通道注销
|
||||||
|
sdk.register_output_channel("luademo_dyn", 0, "dynamic", {}, function(a) return { ok = true } end)
|
||||||
|
local _, uerr = sdk.unregister_output_channel("luademo_dyn")
|
||||||
|
res.unregister = { err = uerr }
|
||||||
|
|
||||||
|
return { content = res }
|
||||||
|
end)
|
||||||
|
|
||||||
-- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发)
|
-- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发)
|
||||||
sdk.register_stage("before_toolcall", function(ctx)
|
sdk.register_stage("before_toolcall", function(ctx)
|
||||||
local calls = ctx.tool_calls or {}
|
local calls = ctx.tool_calls or {}
|
||||||
|
|||||||
@ -1,67 +1,413 @@
|
|||||||
-- HomeAgent Lua Plugin SDK (standalone mock)
|
-- HomeAgent Lua Plugin SDK
|
||||||
|
-- Interface contract between Lua plugins and HomeAgent kernel.
|
||||||
|
-- !impl functions are replaced by Go implementations at runtime.
|
||||||
|
-- Standalone/debug: pure Lua mock implementations are used.
|
||||||
|
-- Usage: local sdk = require("sdk")
|
||||||
|
|
||||||
sdk = {}
|
sdk = {}
|
||||||
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
|
||||||
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
-- !impl
|
||||||
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
|
-- level: "debug" | "info" | "warn" | "error"
|
||||||
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
function sdk.log(level, msg)
|
||||||
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
|
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
|
||||||
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
|
end
|
||||||
function sdk.get_setting(key) return nil end
|
|
||||||
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
-- !impl
|
||||||
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
|
||||||
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
-- handler: function(args) -> result
|
||||||
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
function sdk.register_tool(name, def, handler)
|
||||||
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
|
print("[lua-plugin] register_tool: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- stage: "on_input" | "pre_action" | "post_action" | ...
|
||||||
|
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
|
||||||
|
function sdk.register_stage(stage, handler, scope)
|
||||||
|
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.register_api(name)
|
||||||
|
print("[lua-plugin] register_api: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
-- handler: function(args) -> result
|
||||||
|
function sdk.register_output_channel(name, caps, desc, def, handler)
|
||||||
|
print("[lua-plugin] register_output_channel: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
function sdk.register_input_channel(name, def)
|
||||||
|
print("[lua-plugin] register_input_channel: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.get_setting(key)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.set_setting(key, value)
|
||||||
|
print("[lua-plugin] set_setting: " .. tostring(key))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_text(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_interrupt: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_text_no_memory(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
|
||||||
|
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
|
||||||
|
function sdk.inject_text_opts(source, channel, text, opts)
|
||||||
|
print("[lua-plugin] inject_text_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_opts(source, channel, text, opts)
|
||||||
|
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
|
||||||
|
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
|
||||||
|
function sdk.inject_input_sync(source, channel, text)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_sync_opts(source, channel, text, opts)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
|
||||||
|
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
|
||||||
|
function sdk.set_tool_blocks(blocks)
|
||||||
|
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- blocks 每项:{ type="text", text="..." }
|
||||||
|
-- | { type="image_url", image_url={ url="...", detail="high" } }
|
||||||
|
-- | { type="audio_url", audio_url={ url="..." } }
|
||||||
|
function sdk.inject_input_media(source, channel, text, blocks)
|
||||||
|
print("[lua-plugin] inject_input_media: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
|
||||||
|
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 同 sdk.inject_input_sync:Lua 中不可用。
|
||||||
|
function sdk.inject_input_media_sync(source, channel, text, blocks)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_media(source, channel, text, blocks)
|
||||||
|
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
|
||||||
|
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
|
||||||
|
function sdk.unregister_output_channel(name) return nil, nil end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- enabled: true/false,崩溃时内核自动拉起
|
||||||
|
function sdk.set_auto_restart(enabled)
|
||||||
|
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============ graph memory ============
|
||||||
|
-- !impl
|
||||||
sdk.memory = {}
|
sdk.memory = {}
|
||||||
|
-- !impl
|
||||||
|
-- query: string, depth: number -> {entities={...}, relations={...}}
|
||||||
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||||
|
-- !impl
|
||||||
|
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
|
||||||
function sdk.memory.commit(triples) return nil end
|
function sdk.memory.commit(triples) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.memory.introspect() return {} end
|
function sdk.memory.introspect() return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.memory.merge(source, target) return 0 end
|
function sdk.memory.merge(source, target) return 0 end
|
||||||
|
-- !impl
|
||||||
|
-- criteria: {key=value}, hard: boolean
|
||||||
function sdk.memory.purge(criteria, hard) return 0 end
|
function sdk.memory.purge(criteria, hard) return 0 end
|
||||||
|
|
||||||
|
-- ============ document memory ============
|
||||||
|
-- !impl
|
||||||
sdk.doc = {}
|
sdk.doc = {}
|
||||||
|
-- !impl
|
||||||
function sdk.doc.query(text, top_k) return {} end
|
function sdk.doc.query(text, top_k) return {} end
|
||||||
|
-- !impl
|
||||||
|
-- doc: { id=, title=, content= }
|
||||||
function sdk.doc.insert(doc) return nil end
|
function sdk.doc.insert(doc) return nil end
|
||||||
|
-- !impl
|
||||||
|
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
|
||||||
|
function sdk.doc.insert_with_media(doc, attachments) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.doc.remove(id) return nil end
|
function sdk.doc.remove(id) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.doc.stats() return {} end
|
function sdk.doc.stats() return {} end
|
||||||
|
|
||||||
|
-- ============ knowledge ============
|
||||||
|
-- !impl
|
||||||
sdk.knowledge = {}
|
sdk.knowledge = {}
|
||||||
|
-- !impl
|
||||||
function sdk.knowledge.search(query, limit) return {} end
|
function sdk.knowledge.search(query, limit) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.knowledge.add(tag, content) return nil end
|
function sdk.knowledge.add(tag, content) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.knowledge.list() return {} end
|
function sdk.knowledge.list() return {} end
|
||||||
|
|
||||||
|
-- ============ text memory ============
|
||||||
|
-- !impl
|
||||||
sdk.text_memory = {}
|
sdk.text_memory = {}
|
||||||
|
-- !impl
|
||||||
|
-- evt: { timestamp=, role=, content=, channel= }
|
||||||
function sdk.text_memory.append(evt) return nil end
|
function sdk.text_memory.append(evt) return nil end
|
||||||
|
|
||||||
|
-- ============ llm ============
|
||||||
|
-- !impl
|
||||||
sdk.llm = {}
|
sdk.llm = {}
|
||||||
|
-- !impl
|
||||||
function sdk.llm.list_sources() return {} end
|
function sdk.llm.list_sources() return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.llm.set_source(name) return nil end
|
function sdk.llm.set_source(name) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.llm.current_source() return nil end
|
function sdk.llm.current_source() return nil end
|
||||||
|
|
||||||
|
-- ============ social (只读) ============
|
||||||
|
-- !impl
|
||||||
sdk.social = {}
|
sdk.social = {}
|
||||||
|
-- !impl
|
||||||
function sdk.social.get_person(name) return {} end
|
function sdk.social.get_person(name) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.social.get_network(name, depth) return {} end
|
function sdk.social.get_network(name, depth) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||||
|
-- !impl
|
||||||
function sdk.social.get_relations(name) return {} end
|
function sdk.social.get_relations(name) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.social.list_persons() return {} end
|
function sdk.social.list_persons() return {} end
|
||||||
|
|
||||||
|
-- ============ settings (作用域变体) ============
|
||||||
|
-- !impl
|
||||||
sdk.settings = {}
|
sdk.settings = {}
|
||||||
|
-- !impl
|
||||||
function sdk.settings.get_core(key) return nil end
|
function sdk.settings.get_core(key) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.set_core(key, value) return nil end
|
function sdk.settings.set_core(key, value) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.list_core(prefix) return {} end
|
function sdk.settings.list_core(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.get_plugin(plugin, key) return nil end
|
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.list(prefix) return {} end
|
function sdk.settings.list(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
|
||||||
|
-- min=, max=, step=, required=, secret= }
|
||||||
function sdk.settings.register_def(def) return nil end
|
function sdk.settings.register_def(def) return nil end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.defs(prefix) return {} end
|
function sdk.settings.defs(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.dump() return {} end
|
function sdk.settings.dump() return {} end
|
||||||
|
-- !impl
|
||||||
function sdk.settings.plugins() return {} end
|
function sdk.settings.plugins() return {} end
|
||||||
|
|
||||||
|
-- ============ events(只读订阅) ============
|
||||||
|
-- !impl
|
||||||
|
-- subscribe(event_type, handler) -> unsubscribe()
|
||||||
|
-- handler 收到 { type=, source=, timestamp=, payload= };
|
||||||
|
-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。
|
||||||
|
sdk.events = {}
|
||||||
|
function sdk.events.subscribe(event_type, handler)
|
||||||
|
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============ plugin_mgr ============
|
||||||
|
-- !impl
|
||||||
|
sdk.plugin_mgr = {}
|
||||||
|
function sdk.plugin_mgr.reload_one(name) return nil end
|
||||||
|
function sdk.plugin_mgr.list_loaded() return {} end
|
||||||
|
function sdk.plugin_mgr.is_disabled(name) return false end
|
||||||
|
|
||||||
|
-- json utils (pure Lua)
|
||||||
sdk.json = {}
|
sdk.json = {}
|
||||||
|
|
||||||
function sdk.json.encode(val)
|
function sdk.json.encode(val)
|
||||||
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
local ok, result = pcall(function()
|
||||||
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
local function _encode(v)
|
||||||
elseif type(val) == "table" then local parts, i = {}, 1
|
local t = type(v)
|
||||||
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
if t == "string" then
|
||||||
return "{" .. table.concat(parts, ",") .. "}" end
|
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
|
||||||
|
return '"' .. s .. '"'
|
||||||
|
elseif t == "number" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "boolean" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "table" then
|
||||||
|
local keys = {}
|
||||||
|
local is_array = true
|
||||||
|
local maxn = 0
|
||||||
|
for k in pairs(v) do
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
|
||||||
|
is_array = false
|
||||||
|
end
|
||||||
|
if type(k) == "number" and k > maxn then maxn = k end
|
||||||
|
end
|
||||||
|
if is_array and #keys >= maxn then
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, maxn do
|
||||||
|
parts[#parts + 1] = _encode(v[i])
|
||||||
|
end
|
||||||
|
return "[" .. table.concat(parts, ",") .. "]"
|
||||||
|
else
|
||||||
|
local parts = {}
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
|
||||||
|
end
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return _encode(val)
|
||||||
|
end)
|
||||||
|
if ok then return result end
|
||||||
return "null"
|
return "null"
|
||||||
end
|
end
|
||||||
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
|
||||||
|
function sdk.json.decode(str)
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local pos, _end = 1, #str
|
||||||
|
local function skip()
|
||||||
|
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
|
||||||
|
end
|
||||||
|
local function parse()
|
||||||
|
skip()
|
||||||
|
if pos > _end then return nil end
|
||||||
|
local c = str:sub(pos, pos)
|
||||||
|
if c == '"' then
|
||||||
|
local s = {}
|
||||||
|
pos = pos + 1
|
||||||
|
while pos <= _end do
|
||||||
|
local ch = str:sub(pos, pos)
|
||||||
|
if ch == '"' then
|
||||||
|
pos = pos + 1
|
||||||
|
return table.concat(s)
|
||||||
|
elseif ch == '\\' then
|
||||||
|
pos = pos + 1
|
||||||
|
local n = str:sub(pos, pos)
|
||||||
|
if n == '"' then s[#s+1] = '"'
|
||||||
|
elseif n == '\\' then s[#s+1] = '\\'
|
||||||
|
elseif n == '/' then s[#s+1] = '/'
|
||||||
|
elseif n == 'b' then s[#s+1] = '\b'
|
||||||
|
elseif n == 'f' then s[#s+1] = '\f'
|
||||||
|
elseif n == 'n' then s[#s+1] = '\n'
|
||||||
|
elseif n == 'r' then s[#s+1] = '\r'
|
||||||
|
elseif n == 't' then s[#s+1] = '\t'
|
||||||
|
elseif n == 'u' then
|
||||||
|
local hex = str:sub(pos+1, pos+4)
|
||||||
|
pos = pos + 4
|
||||||
|
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
|
||||||
|
end
|
||||||
|
pos = pos + 1
|
||||||
|
else
|
||||||
|
s[#s+1] = ch
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(s)
|
||||||
|
elseif c == 't' then pos = pos + 4; return true
|
||||||
|
elseif c == 'f' then pos = pos + 5; return false
|
||||||
|
elseif c == 'n' then pos = pos + 4; return nil
|
||||||
|
elseif c == '{' then
|
||||||
|
pos = pos + 1; skip()
|
||||||
|
local t = {}
|
||||||
|
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
|
||||||
|
while true do
|
||||||
|
skip(); local k = parse(); skip()
|
||||||
|
if str:sub(pos, pos) == ':' then pos = pos + 1 end
|
||||||
|
skip(); t[k] = parse(); skip()
|
||||||
|
local sep = str:sub(pos, pos)
|
||||||
|
if sep == '}' then pos = pos + 1; return t end
|
||||||
|
if sep == ',' then pos = pos + 1 end
|
||||||
|
end
|
||||||
|
elseif c == '[' then
|
||||||
|
pos = pos + 1; skip()
|
||||||
|
local t = {}
|
||||||
|
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
|
||||||
|
local idx = 1
|
||||||
|
while true do
|
||||||
|
skip(); t[idx] = parse(); idx = idx + 1; skip()
|
||||||
|
local sep = str:sub(pos, pos)
|
||||||
|
if sep == ']' then pos = pos + 1; return t end
|
||||||
|
if sep == ',' then pos = pos + 1 end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local s, e = str:find('^[-%d%.eE]+', pos)
|
||||||
|
if s then
|
||||||
|
local num = tonumber(str:sub(s, e))
|
||||||
|
pos = e + 1
|
||||||
|
return num
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return parse()
|
||||||
|
end)
|
||||||
|
if ok then return result end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- http utils
|
||||||
sdk.http = {}
|
sdk.http = {}
|
||||||
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
|
||||||
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
-- !impl
|
||||||
|
function sdk.http.get(url)
|
||||||
|
print("[lua-plugin] http.get: " .. tostring(url))
|
||||||
|
return {status=200, body='{"mock":true}', headers={}}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.http.post(url, body, content_type)
|
||||||
|
print("[lua-plugin] http.post: " .. tostring(url))
|
||||||
|
return {status=200, body='{"mock":true}', headers={}}
|
||||||
|
end
|
||||||
|
|
||||||
return sdk
|
return sdk
|
||||||
|
|||||||
43
example/memo/README.md
Normal file
43
example/memo/README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# memo · 待办与备忘录
|
||||||
|
|
||||||
|
两类条目,行为**刻意不同**:
|
||||||
|
|
||||||
|
| 类型 | 用途 | 是否主动提醒 |
|
||||||
|
|---|---|---|
|
||||||
|
| **待办**(todo) | 有截止概念、需要被催的事 | ✅ 会 |
|
||||||
|
| **备忘录**(memo) | 纯记事,供以后查阅 | ❌ 不会 |
|
||||||
|
|
||||||
|
分开的理由:把"提醒我"和"记一下"混成一类,要么备忘录天天弹、要么待办被忘掉。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `memo_todo_add` | 添加待办(会被主动提醒) |
|
||||||
|
| `memo_todo_complete` | 标记待办完成(不再提醒) |
|
||||||
|
| `memo_todo_list` | 列出未完成待办(含 ID、内容、创建时间) |
|
||||||
|
| `memo_todo_delete` | 删除待办(含已完成的) |
|
||||||
|
| `memo_memo_create` | 创建备忘录(纯记事,不提醒) |
|
||||||
|
| `memo_memo_list` | 列出全部备忘录 |
|
||||||
|
| `memo_memo_delete` | 删除备忘录 |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`p.tp`),上面按默认的 `memo_` 写法列出。
|
||||||
|
|
||||||
|
## 提醒机制
|
||||||
|
|
||||||
|
- 后台 **每 5 分钟**检查一次未完成待办数;有则通过 `InjectInterruptText` 注入一条
|
||||||
|
「注意,你还有 N 条待办未完成,请检查」。
|
||||||
|
- 注入带 **`NoMemory: true`** —— 这是定时提醒,不是记忆内容,不该进向量化。
|
||||||
|
- 通道声明为 **`NoMemory`**(`RegisterInputChannel(p.name, ChannelDef{NoMemory:true})`),
|
||||||
|
理由同上:提醒是瞬时信号。
|
||||||
|
- 另有 `StagePreAction` 钩子,在每轮动作前参与。
|
||||||
|
|
||||||
|
## 存储
|
||||||
|
|
||||||
|
条目落在数据目录的 `todos.json`,插件重启后仍在。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
30
example/music/README.md
Normal file
30
example/music/README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# music · 音乐搜索
|
||||||
|
|
||||||
|
按关键词搜歌、按 ID 查歌词(数据来自网易云音乐公开接口)。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `music_search` | 按关键词搜歌,返回歌曲列表(含歌曲 ID) |
|
||||||
|
| `music_lyrics` | 按歌曲 ID 取歌词 |
|
||||||
|
|
||||||
|
典型两段式用法:先 `music_search` 拿 ID,再 `music_lyrics` 取词。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- 请求打的是 `https://music.163.com/api/...`,并固定带上 `Referer: https://music.163.com/` ——
|
||||||
|
该接口对缺少来源头的请求会拒绝。
|
||||||
|
- 是**只读**插件:不下载音频、不写本地文件,因此没有需要清理的副作用。
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
- 依赖第三方(网易云)公开接口,其可用性与返回结构不受本插件控制;
|
||||||
|
接口变动时可能返回空列表,而不是报错。
|
||||||
|
- 仅覆盖"搜索 + 歌词",不含播放地址解析。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
40
example/ocr/README.md
Normal file
40
example/ocr/README.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# ocr · 图片文字识别
|
||||||
|
|
||||||
|
从图片里提取文字(中英文),基于 [Tesseract](https://github.com/tesseract-ocr/tesseract) OCR 引擎。
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
需要系统里装有 `tesseract` 可执行文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
apt install tesseract-ocr tesseract-ocr-chi-sim
|
||||||
|
```
|
||||||
|
|
||||||
|
中文识别需要 `chi_sim` 语言包;缺它时中文会识别成乱码而非报错。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `ocr_ocr_image` | 对图片做 OCR,返回识别文本 |
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `image_url` | 图片的 HTTP/HTTPS 地址(与 `image_data` 二选一) |
|
||||||
|
| `image_data` | 图片的 base64 数据,**不含** `data:image/...` 前缀(与 `image_url` 二选一) |
|
||||||
|
| `language` | 识别语言,默认 `chi_sim+eng`;可选 `chi_sim` / `eng` / `chi_sim+eng` |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- 传入的图先落到临时目录,OCR 完 `defer os.RemoveAll` 清掉,不残留。
|
||||||
|
- 调用参数固定 `--psm 3`(全自动页面分割),适合截图与常规排版图片;对单行小图或竖排文本效果会下降。
|
||||||
|
- **`Cleaner`**:工具返回的是 JSON(含 `text`、`language` 等字段),进记忆计算前只取 `text` 正文 —— 否则 JSON 结构本身会参与向量化。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
47
example/plugindev/README.md
Normal file
47
example/plugindev/README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# plugindev — 插件开发工具链(Agent 可调用)
|
||||||
|
|
||||||
|
把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程,
|
||||||
|
不需要人来敲命令行:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugindev_init 生成工程骨架(等价 hmapdev init <name> [--lua])
|
||||||
|
↓ 改 plugin.go
|
||||||
|
plugindev_build 构建打包(等价在该目录 hmapdev build)→ dist/*.hmap
|
||||||
|
↓
|
||||||
|
plugin_install 安装(用 path 指向刚构建出的 .hmap,overwrite=true 表示原地更新)
|
||||||
|
↓
|
||||||
|
plgreload 重载生效
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 参数 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** |
|
||||||
|
| `plugindev_init` | `name`、`lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` |
|
||||||
|
| `plugindev_build` | `dir`、`target` | 在工程目录构建打包;产物路径会在返回里给出 |
|
||||||
|
| `plugindev_sdk` | `action`、`version`、`from` | SDK 版本管理(list/current/path/latest/install/use);`from` 可指向本地 SDK 源码 |
|
||||||
|
| `plugindev_projects` | — | 列出工作区里已有工程与产物 |
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev` → `/root/go/bin/hmapdev` |
|
||||||
|
| `workspace_dir` | `<data_dir>/plugindev` | `plugindev_init` 生成工程的默认目录 |
|
||||||
|
| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 |
|
||||||
|
|
||||||
|
## 前置:装 hmapdev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <sdk-repo>/tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev .
|
||||||
|
hmapdev version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全边界(都在实现里,不只写在文档里)
|
||||||
|
|
||||||
|
- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接;
|
||||||
|
- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建),
|
||||||
|
避免把这个工具变成对任意目录跑构建;
|
||||||
|
- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB,直接回灌会撑爆模型上下文);
|
||||||
|
- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。
|
||||||
7
example/plugindev/go.mod
Normal file
7
example/plugindev/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module plugindev
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||||
11
example/plugindev/main.go
Normal file
11
example/plugindev/main.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows || !cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return NewPluginFactory(name, config)
|
||||||
|
}
|
||||||
19
example/plugindev/plg.json
Normal file
19
example/plugindev/plg.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "plugindev",
|
||||||
|
"name_zh": "插件开发工具链",
|
||||||
|
"name_en": "Plugin Dev Toolchain",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"plugindev",
|
||||||
|
"toolchain",
|
||||||
|
"developer"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
455
example/plugindev/plugin.go
Normal file
455
example/plugindev/plugin.go
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// plugindev:把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。
|
||||||
|
//
|
||||||
|
// 为什么需要它:hmapdev 是"给人和 CI 用"的命令行工具。做成插件后,Agent 能自己:
|
||||||
|
// plugindev_init(脚手架)→ plugindev_build(构建出 .hmap)→ plugin_install(安装)→ plgreload
|
||||||
|
// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。
|
||||||
|
//
|
||||||
|
// 安全边界(都在实现里落实,不只写在描述里):
|
||||||
|
// - 只有 **hmapdev 一个可执行文件**会被 exec(不接受任意命令/参数拼接);
|
||||||
|
// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json),
|
||||||
|
// 避免把一个 `hmapdev build` 变成对任意目录的操作;
|
||||||
|
// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名
|
||||||
|
// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名);
|
||||||
|
// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBuildTimeout = 10 * time.Minute
|
||||||
|
maxOutputChars = 6000
|
||||||
|
)
|
||||||
|
|
||||||
|
// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。
|
||||||
|
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
|
||||||
|
hmapdev string // 解析到的 hmapdev 可执行文件路径
|
||||||
|
workspace string // 默认工作区(生成的工程落在这里)
|
||||||
|
timeout time.Duration // 单次 hmapdev 调用的超时
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径",
|
||||||
|
Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "workspace_dir", Type: "string", DisplayName: "工程工作区",
|
||||||
|
Description: "plugindev_init 生成工程的默认目录。留空则用 <data_dir>/plugindev",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)",
|
||||||
|
Description: "单次 hmapdev 调用的超时上限",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
p.hmapdev = p.resolveHmapdev()
|
||||||
|
p.workspace = p.resolveWorkspace()
|
||||||
|
p.timeout = defaultBuildTimeout
|
||||||
|
if v, _ := s.Settings().Get("build_timeout_sec"); v != nil {
|
||||||
|
if n, ok := toInt(v); ok && n > 0 {
|
||||||
|
p.timeout = time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_status", sdk.ToolDef{
|
||||||
|
Name: "plugindev_status",
|
||||||
|
Description: "查看插件开发工具链状态:hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
}, p.handleStatus)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_init", sdk.ToolDef{
|
||||||
|
Name: "plugindev_init",
|
||||||
|
Description: "生成一个新的插件工程骨架(等价于 `hmapdev init <name> [--lua]`)。生成后在返回的目录里改 plugin.go,再用 plugindev_build 构建。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"name": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例:my_plugin",
|
||||||
|
},
|
||||||
|
"lang": map[string]interface{}{
|
||||||
|
"type": "string", "description": "go(默认)或 lua",
|
||||||
|
},
|
||||||
|
"dir": map[string]interface{}{
|
||||||
|
"type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"name"},
|
||||||
|
},
|
||||||
|
}, p.handleInit)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_build", sdk.ToolDef{
|
||||||
|
Name: "plugindev_build",
|
||||||
|
Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"dir": map[string]interface{}{
|
||||||
|
"type": "string", "description": "插件工程目录(必须含 plg.json)",
|
||||||
|
},
|
||||||
|
"target": map[string]interface{}{
|
||||||
|
"type": "string", "description": "构建目标,留空 = native(当前平台)。例:linux/amd64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"dir"},
|
||||||
|
},
|
||||||
|
}, p.handleBuild)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_sdk", sdk.ToolDef{
|
||||||
|
Name: "plugindev_sdk",
|
||||||
|
Description: "管理插件 SDK 版本(hmapdev sdk 子命令):list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录)、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string", "description": "list | current | path | latest | install | use",
|
||||||
|
},
|
||||||
|
"version": map[string]interface{}{
|
||||||
|
"type": "string", "description": "install/use 的版本号,如 v1.3.0",
|
||||||
|
},
|
||||||
|
"from": map[string]interface{}{
|
||||||
|
"type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
},
|
||||||
|
}, p.handleSDK)
|
||||||
|
|
||||||
|
s.RegisterTool("plugindev_projects", sdk.ToolDef{
|
||||||
|
Name: "plugindev_projects",
|
||||||
|
Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
}, p.handleProjects)
|
||||||
|
|
||||||
|
log.Printf("[plugindev] 就绪:hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error { return nil }
|
||||||
|
|
||||||
|
// ---------------- 工具实现 ----------------
|
||||||
|
|
||||||
|
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"hmapdev": fallback(p.hmapdev, ""),
|
||||||
|
"workspace": p.workspace,
|
||||||
|
}
|
||||||
|
if p.hmapdev == "" {
|
||||||
|
out["available"] = false
|
||||||
|
out["hint"] = "未找到 hmapdev。请安装:go build -o /usr/local/bin/hmapdev <sdk>/tools/hmapdev"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out["available"] = true
|
||||||
|
if txt, err := p.run(nil, ""); err == nil {
|
||||||
|
out["version"] = strings.TrimSpace(txt)
|
||||||
|
} else {
|
||||||
|
out["error"] = err.Error()
|
||||||
|
}
|
||||||
|
if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil {
|
||||||
|
out["sdk_current"] = strings.TrimSpace(txt)
|
||||||
|
}
|
||||||
|
if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil {
|
||||||
|
out["sdk_path"] = strings.TrimSpace(txt)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) {
|
||||||
|
name, _ := args["name"].(string)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if !namePattern.MatchString(name) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64(它会进 LLM 工具名,违规会让整条请求被上游拒绝)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
dir, _ := args["dir"].(string)
|
||||||
|
if dir == "" {
|
||||||
|
dir = p.workspace
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil
|
||||||
|
}
|
||||||
|
cmd := []string{"init", name}
|
||||||
|
if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") {
|
||||||
|
cmd = append(cmd, "--lua")
|
||||||
|
}
|
||||||
|
txt, err := p.run(cmd, dir)
|
||||||
|
res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) {
|
||||||
|
dir, _ := args["dir"].(string)
|
||||||
|
if dir == "" {
|
||||||
|
return map[string]interface{}{"error": "dir 不能为空"}, nil
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
// 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。
|
||||||
|
manifest := filepath.Join(abs, "plg.json")
|
||||||
|
if _, err := os.Stat(manifest); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("%s 不是插件工程(缺 plg.json);用 plugindev_init 先建一个", abs),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
cmd := []string{"build"}
|
||||||
|
if target, _ := args["target"].(string); strings.TrimSpace(target) != "" {
|
||||||
|
cmd = append(cmd, strings.TrimSpace(target))
|
||||||
|
}
|
||||||
|
txt, runErr := p.run(cmd, abs)
|
||||||
|
res := map[string]interface{}{"output": txt, "project_dir": abs}
|
||||||
|
if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 {
|
||||||
|
res["artifacts"] = pkgs
|
||||||
|
res["next"] = "用 plugin_install 安装本地产物(path 指向上面 artifacts 里的 .hmap),然后 plgreload"
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
res["error"] = runErr.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) {
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
action = strings.TrimSpace(action)
|
||||||
|
switch action {
|
||||||
|
case "list", "current", "path", "latest":
|
||||||
|
txt, err := p.run([]string{"sdk", action}, "")
|
||||||
|
res := map[string]interface{}{"output": txt}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
case "install":
|
||||||
|
version, _ := args["version"].(string)
|
||||||
|
from, _ := args["from"].(string)
|
||||||
|
cmd := []string{"sdk", "install"}
|
||||||
|
if strings.TrimSpace(from) != "" {
|
||||||
|
cmd = append(cmd, "--from", strings.TrimSpace(from))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(version) != "" {
|
||||||
|
cmd = append(cmd, strings.TrimSpace(version))
|
||||||
|
} else if strings.TrimSpace(from) == "" {
|
||||||
|
cmd = append(cmd, "latest")
|
||||||
|
}
|
||||||
|
txt, err := p.run(cmd, "")
|
||||||
|
res := map[string]interface{}{"output": txt}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
case "use":
|
||||||
|
version, _ := args["version"].(string)
|
||||||
|
if strings.TrimSpace(version) == "" {
|
||||||
|
return map[string]interface{}{"error": "use 需要 version"}, nil
|
||||||
|
}
|
||||||
|
txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "")
|
||||||
|
res := map[string]interface{}{"output": txt}
|
||||||
|
if err != nil {
|
||||||
|
res["error"] = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
default:
|
||||||
|
return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
|
||||||
|
entries, err := os.ReadDir(p.workspace)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil
|
||||||
|
}
|
||||||
|
var out []map[string]interface{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(p.workspace, e.Name())
|
||||||
|
projects := []string{dir}
|
||||||
|
// 有些工程会被生成到子目录里(hmapdev init 支持指定目录),这里只看一层
|
||||||
|
for _, sub := range projects {
|
||||||
|
if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := map[string]interface{}{"name": e.Name(), "dir": sub}
|
||||||
|
if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" {
|
||||||
|
item["version"] = v
|
||||||
|
}
|
||||||
|
if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 {
|
||||||
|
item["artifacts"] = pkgs
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
|
||||||
|
return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 基础设施 ----------------
|
||||||
|
|
||||||
|
// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。
|
||||||
|
func (p *Plugin) run(args []string, dir string) (string, error) {
|
||||||
|
if p.hmapdev == "" {
|
||||||
|
return "", fmt.Errorf("未找到 hmapdev 可执行文件")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, p.hmapdev, args...)
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
// 继承环境(Go 工具链需要 GOCACHE/GOPATH/PATH 等)。
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
txt := truncateOutput(string(out))
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return txt, fmt.Errorf("hmapdev %s 超时(%s)", strings.Join(args, " "), p.timeout)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return txt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。
|
||||||
|
func (p *Plugin) resolveHmapdev() string {
|
||||||
|
if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil {
|
||||||
|
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||||
|
if _, err := os.Stat(strings.TrimSpace(s)); err == nil {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("hmapdev"); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} {
|
||||||
|
if _, err := os.Stat(cand); err == nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveWorkspace:配置项 → <data_dir>/plugindev → ./plugindev。
|
||||||
|
func (p *Plugin) resolveWorkspace() string {
|
||||||
|
if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil {
|
||||||
|
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||||
|
ws := strings.TrimSpace(s)
|
||||||
|
_ = os.MkdirAll(ws, 0o755)
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil {
|
||||||
|
if dd, _ := v.(string); dd != "" {
|
||||||
|
ws := filepath.Join(dd, "plugindev")
|
||||||
|
_ = os.MkdirAll(ws, 0o755)
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws := "plugindev"
|
||||||
|
_ = os.MkdirAll(ws, 0o755)
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateOutput 截断长输出:构建日志动辄几百 KB,直接返回会灌爆模型上下文。
|
||||||
|
func truncateOutput(s string) string {
|
||||||
|
if len(s) <= maxOutputChars {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
head := s[:maxOutputChars/2]
|
||||||
|
tail := s[len(s)-maxOutputChars/2:]
|
||||||
|
return fmt.Sprintf("%s\n…(输出被截断,共 %d 字节)…\n%s", head, len(s), tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。
|
||||||
|
func listHmap(dir string) []string {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, filepath.Join(dir, e.Name()))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPlgVersion(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
i := strings.Index(s, `"version"`)
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := s[i:]
|
||||||
|
j := strings.Index(rest, ":")
|
||||||
|
if j < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest = strings.TrimSpace(rest[j+1:])
|
||||||
|
rest = strings.TrimPrefix(rest, `"`)
|
||||||
|
if k := strings.Index(rest, `"`); k > 0 {
|
||||||
|
return rest[:k]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt(v interface{}) (int, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n, true
|
||||||
|
case int64:
|
||||||
|
return int(n), true
|
||||||
|
case float64:
|
||||||
|
return int(n), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallback(s, def string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
167
example/qq/README.md
Normal file
167
example/qq/README.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# qq · QQ 消息桥接
|
||||||
|
|
||||||
|
通过 [NapCat](https://github.com/NapNeko/NapCatQQ) 把 QQ 接成 HomeAgent 的一个 IO 通道:
|
||||||
|
让 agent 收发 QQ 消息、读群/好友信息、传文件。
|
||||||
|
|
||||||
|
> ⚠️ 这是**安全敏感**插件:它让外部 QQ 用户能触达 agent 的工具。
|
||||||
|
> 本文档的「权限模型」一节请务必读完。
|
||||||
|
|
||||||
|
## 通道与钩子
|
||||||
|
|
||||||
|
| 类型 | 名称 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 出站 | `qq` | `CapText` + `CapFile` + `CapImage` + `CapAudio`;发消息/文件给 QQ |
|
||||||
|
| 入站 | `qq` | `NoMemory: true` + `Cleaner` + `RecallPolicy: None` |
|
||||||
|
|
||||||
|
四个阶段钩子(全部 `StageScopeGlobal`):
|
||||||
|
|
||||||
|
| 钩子 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `on_input` | 把本轮 QQ 身份**绑到帧上** |
|
||||||
|
| `before_toolcall` | 权限门:逐个工具判断是否放行 |
|
||||||
|
| `post_action` | 清掉被拒绝时模型已经吐出的废话 |
|
||||||
|
| `after_output` | 收尾时清理插件全局身份 |
|
||||||
|
|
||||||
|
## 工具(20 个)
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `qq_get_message` | 按 `message_id` 取消息正文、发送者、附件 |
|
||||||
|
| `qq_get_history` | 取群/私聊最近历史消息 |
|
||||||
|
| `qq_list_chats` | 会话列表(按最新消息排序,带未读数与摘要) |
|
||||||
|
| `qq_mark_read` | 把某会话未读数清零 |
|
||||||
|
| `qq_send_file` | 发文件/图片(私聊或群聊) |
|
||||||
|
| `qq_get_groups` | 群列表,可按关键词搜 |
|
||||||
|
| `qq_get_friends` | 好友列表,可按昵称/备注搜 |
|
||||||
|
| `qq_get_recent_contacts` | 最近有消息的联系人与群 |
|
||||||
|
| `qq_resolve_name` / `qq_resolve_nickname` | 名字 ↔ QQ 号互查 |
|
||||||
|
| `qq_get_group_member_info` | 群成员信息 |
|
||||||
|
| `qq_group_manage` | 群综合管理(见下) |
|
||||||
|
| `qq_friend_action` | 好友操作 |
|
||||||
|
| `qq_get_group_files` | 群文件列表 |
|
||||||
|
| `qq_download_file` / `qq_upload_group_file` / `qq_get_download_tasks` | 文件传输与任务 |
|
||||||
|
| `qq_read_document` | 读 QQ 传来的文档 |
|
||||||
|
| `qq_video_download` | 下载视频 |
|
||||||
|
| `qq_send_like` | 点赞 |
|
||||||
|
|
||||||
|
`qq_group_manage` 一个工具承载多种操作(`command` 参数):
|
||||||
|
`leave` 退群、`kick` 踢人、`ban`/`unban` 禁言解禁、`rename` 改名、`mute-all` 全员禁言、
|
||||||
|
`set-card` 设名片、`set-admin` 设管理、`set-title` 设头衔、`member-list`、`group-info`、
|
||||||
|
`msg-history`、`recall` 撤回、`pin-msg` 精华、`list-files`、`pending-requests`、`folder-create` 等。
|
||||||
|
|
||||||
|
**破坏性操作**(`leave`/`kick`/`ban`/`unban`/`rename`/`mute-all`/`set-card`/`set-admin`/
|
||||||
|
`set-title`/`recall`/`pin-msg`/`folder-create`)**必须显式传 `confirm: true`**。
|
||||||
|
|
||||||
|
## 权限模型
|
||||||
|
|
||||||
|
这是本插件最重要的部分。
|
||||||
|
|
||||||
|
### 身份分级
|
||||||
|
|
||||||
|
| 身份 | 权限 |
|
||||||
|
|---|---|
|
||||||
|
| **owner**(Bot 所有者) | 私聊或群聊均**完整放行** |
|
||||||
|
| **普通 QQ 用户** | 只放行白名单内的工具 |
|
||||||
|
|
||||||
|
### 身份必须「绑帧」,不能只存插件全局
|
||||||
|
|
||||||
|
源码注释记录了两个真实故障,这就是绑帧的原因:
|
||||||
|
|
||||||
|
1. **中断抢占后身份丢失**:中断会抢占当前轮、把现场压栈。中断轮收尾时
|
||||||
|
`after_output` 会清空插件**全局**身份;随后外层被恢复(`resumeTask` 复用同一帧、
|
||||||
|
**不重跑 `on_input`**)。若身份只存全局,恢复后的外层就是"无身份",
|
||||||
|
`before_toolcall` 在 `!auth.active` 处直接返回 —— **整个权限门失效**。
|
||||||
|
2. **运行中到达的消息改写身份**:新消息会调 `activateAuthContext` 改写全局身份,
|
||||||
|
把**正在跑的那一轮**换成另一方的身份(换高=越权,换低=误拒)。
|
||||||
|
|
||||||
|
帧上的 `Extra` 随帧一起压栈/恢复,正好是"这一轮的身份"。
|
||||||
|
|
||||||
|
### 合并取最小权限
|
||||||
|
|
||||||
|
多来源被内核合并到同一推理时,权限**取交集**而非并集:
|
||||||
|
|
||||||
|
```go
|
||||||
|
p.auth.owner = p.auth.owner && next.owner
|
||||||
|
```
|
||||||
|
|
||||||
|
防的是"非所有者请求 + 随后所有者消息"意外把前一个请求提权。
|
||||||
|
|
||||||
|
### 硬私有工具
|
||||||
|
|
||||||
|
非所有者**一律拒绝**(不看白名单),按前缀拦截:
|
||||||
|
`calendar_`、`email_`、`mail_`、`agentmail_`、`memory_`、`knowledge_`、`device_`、
|
||||||
|
`devicectl_`、`terminal_`、`shell_`、`command_`、`exec_`、`filesystem_`、`agentfs_`、
|
||||||
|
`config_`、`settings_`、`plugin_`、`plugins_`,
|
||||||
|
外加 `read_file`、`write_file`、`edit_file`、`delete_file`、`list_files`、`run_command`、
|
||||||
|
`homeagent_config`、`homeagent_restart`、`output_send__email`、`output_send__mail`。
|
||||||
|
|
||||||
|
### 参数与会话一致性校验
|
||||||
|
|
||||||
|
光看工具名不够,还要检查**参数指向的会话与当前身份一致**,否则可以拿别人的
|
||||||
|
`message_id` 去读别处内容:
|
||||||
|
|
||||||
|
- 带 `message_id` 的工具:该 ID 必须属于当前 QQ 会话(`lookupMsgRef` 校验 peer 与群/私聊类型)。
|
||||||
|
- `get_group_member_info` / `get_group_files`:`group_id` 必须是**当前群**。
|
||||||
|
|
||||||
|
### 频率与重复控制
|
||||||
|
|
||||||
|
| 键 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `max_qq_tool_calls` | 单轮工具调用上限 |
|
||||||
|
| `max_qq_output_calls` | 单轮输出调用上限 |
|
||||||
|
| `max_duplicate_qq_send` | 重复发送上限,防刷屏 |
|
||||||
|
| `batch_window_ms` / `batch_max_ms` | 消息合批窗口 |
|
||||||
|
|
||||||
|
被拒时只允许**发一次权鉴说明**,之后锁止本轮剩余工具调用
|
||||||
|
(`clearDeniedResponse` 再把模型已写出的内容清掉,避免输出里带一堆"我不能…")。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
### 连接
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `napcat_url` | — | NapCat 服务地址 |
|
||||||
|
| `listen` | — | 本插件 HTTP 监听地址 |
|
||||||
|
| `webhook_token` | — | webhook 校验令牌 |
|
||||||
|
|
||||||
|
### 身份与准入
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `owner` | 空 | Bot 所有者 QQ 列表(逗号分隔),拥有完整权限 |
|
||||||
|
| `admin` | 空 | **旧配置名**,`owner` 为空时作为所有者列表(兼容用) |
|
||||||
|
| `dm_policy` | `open` | 私聊策略:`open` / `allowlist` / `disabled` |
|
||||||
|
| `allow_from` | 空 | 私聊白名单(QQ 号,逗号分隔) |
|
||||||
|
| `group_policy` | `open` | 群聊策略:`open` / `allowlist` / `disabled` |
|
||||||
|
| `group_allow_from` | 空 | 群白名单 |
|
||||||
|
| `private_tool_allowlist` | 空 | 私聊下非所有者可用的工具 |
|
||||||
|
| `group_tool_allowlists` | 空 | 按群配置的工具白名单 |
|
||||||
|
|
||||||
|
### 文件与转发
|
||||||
|
|
||||||
|
| 键 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `files_dir` | 本地文件目录 |
|
||||||
|
| `remote_dir` | 供 NapCat 容器访问的目录(发文件前先复制到这里) |
|
||||||
|
| `agentfs_dir` | agent 文件系统目录 |
|
||||||
|
| `forward_rules` | JSON 数组,每项 `{group_id,host,port,password,template}`:匹配的群消息经 **RCON** 转发到 Minecraft;`template` 支持 `{nickname}` / `{message}` 占位 |
|
||||||
|
|
||||||
|
## 部署前提
|
||||||
|
|
||||||
|
需要**自行部署 NapCat**(本插件不含 QQ 协议实现,只是 NapCat 的客户端)。
|
||||||
|
发文件前会先把文件复制到 `remote_dir`,因为 NapCat 通常在容器里,看不到宿主任意路径。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
含权限门与绑帧的回归测试。改动权限相关代码后务必跑 `-race`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "qq",
|
"name": "qq",
|
||||||
"name_zh": "QQ消息",
|
"name_zh": "QQ消息",
|
||||||
"name_en": "qq",
|
"name_en": "qq",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -155,6 +155,41 @@ type Plugin struct {
|
|||||||
msgMu sync.Mutex
|
msgMu sync.Mutex
|
||||||
msgMap map[int64]msgRef // message_id → {peer, time}
|
msgMap map[int64]msgRef // message_id → {peer, time}
|
||||||
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
|
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
|
||||||
|
|
||||||
|
// 消息合并(debounce):同一会话、同一发送者在 batchWindow 内连续到达的消息
|
||||||
|
// 合并成一次中断。同一个人连发「在吗」「帮我看看」「报错是这个」三条,
|
||||||
|
// 逐条注入会把 Agent 唤醒三次,且前两次拿到的信息都不完整。
|
||||||
|
batchMu sync.Mutex
|
||||||
|
batches map[string]*pendingBatch
|
||||||
|
batchWindow time.Duration // 最后一条到达后再等多久(<=0 = 关闭合并,逐条投递)
|
||||||
|
batchMax time.Duration // 一批最长等多久(防持续刷屏时永远不投)
|
||||||
|
|
||||||
|
// injectHook 仅供测试:非 nil 时 injectInterrupt 走它而不是真实 SDK。
|
||||||
|
injectHook func(text, level string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingBatch 是一批待投递的消息(同一会话、同一发送者、短时间内的连续消息)。
|
||||||
|
type pendingBatch struct {
|
||||||
|
key string
|
||||||
|
isGroup bool
|
||||||
|
userID int64
|
||||||
|
groupID int64
|
||||||
|
nickname string
|
||||||
|
msgIDs []int64
|
||||||
|
single string // 单条时沿用的原文(含所有者/高危前缀),保证 n==1 行为不变
|
||||||
|
owner bool
|
||||||
|
highRisk bool
|
||||||
|
first time.Time
|
||||||
|
timer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// qqBatchKey 同一会话 + 同一发送者 = 一组。私聊按 QQ 号;群聊按 (群号, QQ 号)——
|
||||||
|
// 群里不同人各发各的,不该并成一条。
|
||||||
|
func qqBatchKey(msgType string, groupID, userID int64) string {
|
||||||
|
if msgType == "group" {
|
||||||
|
return fmt.Sprintf("g:%d:%d", groupID, userID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("p:%d", userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
type typingState struct {
|
type typingState struct {
|
||||||
@ -316,6 +351,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"})
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_window_ms", Default: "1500", Type: "int", DisplayName: "消息合并窗口(毫秒)", Description: "同一会话同一发送者的连续消息在该窗口内合并成一次中断并告知共几条;0=关闭合并(逐条投递)", Category: "qq"})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_max_ms", Default: "30000", Type: "int", DisplayName: "消息合并上限(毫秒)", Description: "一批消息最长等这么久就投递,避免对方持续刷屏时一直不唤醒 Agent", Category: "qq"})
|
||||||
|
|
||||||
settings := s.Settings()
|
settings := s.Settings()
|
||||||
|
|
||||||
@ -339,12 +376,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/")
|
p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/")
|
||||||
p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/")
|
p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/")
|
||||||
p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/")
|
p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/")
|
||||||
|
p.batchWindow = time.Duration(getSetting[int64](settings, "batch_window_ms", 1500)) * time.Millisecond
|
||||||
|
p.batchMax = time.Duration(getSetting[int64](settings, "batch_max_ms", 30000)) * time.Millisecond
|
||||||
|
if p.batchWindow < 0 {
|
||||||
|
p.batchWindow = 0
|
||||||
|
}
|
||||||
os.MkdirAll(p.remoteDir, 0755)
|
os.MkdirAll(p.remoteDir, 0755)
|
||||||
|
|
||||||
p.httpClient = &http.Client{Timeout: 30 * time.Second}
|
p.httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
// msg_id → peer 映射 + 会话状态(不缓存正文)
|
// msg_id → peer 映射 + 会话状态(不缓存正文)
|
||||||
p.msgMap = make(map[int64]msgRef)
|
p.msgMap = make(map[int64]msgRef)
|
||||||
|
p.batches = make(map[string]*pendingBatch)
|
||||||
p.chats = make(map[int64]*chatMeta)
|
p.chats = make(map[int64]*chatMeta)
|
||||||
|
|
||||||
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s)
|
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s)
|
||||||
@ -396,7 +439,9 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
|||||||
}
|
}
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
|
// qq 通道到达的是**中断通知(meta)**,不是用户正文,不据它召回;
|
||||||
|
// 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。
|
||||||
|
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone})
|
||||||
|
|
||||||
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
|
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
|
||||||
cleaner := func(output string) string {
|
cleaner := func(output string) string {
|
||||||
@ -416,6 +461,9 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
|||||||
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
|
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
|
||||||
// 长会话下持续挤占 token 预算(§13.8)。
|
// 长会话下持续挤占 token 预算(§13.8)。
|
||||||
ContextPolicy: "prune",
|
ContextPolicy: "prune",
|
||||||
|
// 正文才是真实内容:取回后用**正文**触发一次召回,
|
||||||
|
// 而不是用中断通知的 meta 文本去召回(那是无关词)。
|
||||||
|
RecallPolicy: "auto",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object", "properties": map[string]interface{}{
|
"type": "object", "properties": map[string]interface{}{
|
||||||
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
|
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
|
||||||
@ -441,6 +489,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
|||||||
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。",
|
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。",
|
||||||
NoMemory: false,
|
NoMemory: false,
|
||||||
Cleaner: cleaner,
|
Cleaner: cleaner,
|
||||||
|
// 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要,
|
||||||
|
// 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占
|
||||||
|
// L0)又据正文召回(取进来)。不声明 recall 的话就是「记忆里有、但
|
||||||
|
// 拉回历史消息时不注入」的盲区。
|
||||||
|
ContextPolicy: "prune",
|
||||||
|
RecallPolicy: "auto",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object", "properties": map[string]interface{}{
|
"type": "object", "properties": map[string]interface{}{
|
||||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"},
|
"group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"},
|
||||||
@ -697,6 +751,8 @@ func (p *Plugin) Stop() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.typingMu.Unlock()
|
p.typingMu.Unlock()
|
||||||
|
// 停机前把未到点的合并批次立刻投出去,别把对方的消息吞掉。
|
||||||
|
p.flushAllBatches()
|
||||||
if p.srv != nil {
|
if p.srv != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@ -761,6 +817,10 @@ func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallb
|
|||||||
}
|
}
|
||||||
case int64:
|
case int64:
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
|
case int:
|
||||||
|
return any(int64(val)).(T)
|
||||||
|
case int64:
|
||||||
|
return any(val).(T)
|
||||||
case float64:
|
case float64:
|
||||||
return any(int64(val)).(T)
|
return any(int64(val)).(T)
|
||||||
case string:
|
case string:
|
||||||
@ -925,6 +985,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
if !auth.active || auth.owner {
|
if !auth.active || auth.owner {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
|
||||||
|
//
|
||||||
|
// 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta
|
||||||
|
// (group_id / user_id)决定 —— handleChannelOutput 会强制要求该字段存在,
|
||||||
|
// 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门,
|
||||||
|
// 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去,
|
||||||
|
// 报「可信 QQ 会话身份不完整」。
|
||||||
|
// 「只能访问当前会话」这类限制只对**读取类**工具(get_history / mark_read /
|
||||||
|
// get_message)成立 —— 那才是真的不能跨会话读。
|
||||||
|
if name == "output_send__"+p.name {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
currentPeer := auth.userID
|
currentPeer := auth.userID
|
||||||
if auth.isGroup {
|
if auth.isGroup {
|
||||||
currentPeer = auth.groupID
|
currentPeer = auth.groupID
|
||||||
@ -967,6 +1040,40 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
|||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qqAuthExtraKey 是本轮(帧)QQ 身份挂在 StageContext.Extra 上的键。
|
||||||
|
//
|
||||||
|
// 身份必须**绑帧**,不能只存插件全局:
|
||||||
|
// - 中断会抢占当前轮并把现场压栈(scheduler 的 suspendStack),中断轮收尾时
|
||||||
|
// afterOutput 把插件全局身份清空;随后外层被恢复(resumeTask 复用同一帧、
|
||||||
|
// 不重跑 StageOnInput),若身份只存全局,恢复后的外层就是"无身份"——
|
||||||
|
// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。
|
||||||
|
// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮**
|
||||||
|
// 换成另一方的身份(换高=越权,换低=误拒)。
|
||||||
|
//
|
||||||
|
// 帧上的 Extra 随帧一起压栈/恢复,正好是"这一轮的身份"。
|
||||||
|
const qqAuthExtraKey = "qq_auth"
|
||||||
|
|
||||||
|
// authOnFrame 读取本帧绑定的身份;ok=false 表示本帧未绑定过 QQ 身份。
|
||||||
|
// 调用方需持有 ctx 的读(或写)锁。
|
||||||
|
func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) {
|
||||||
|
if ctx == nil || ctx.Extra == nil {
|
||||||
|
return qqAuthContext{}, false
|
||||||
|
}
|
||||||
|
auth, ok := ctx.Extra[qqAuthExtraKey].(qqAuthContext)
|
||||||
|
return auth, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。
|
||||||
|
func bindAuthOnFrame(ctx *sdk.StageContext, auth qqAuthContext) {
|
||||||
|
if ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Extra == nil {
|
||||||
|
ctx.Extra = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
ctx.Extra[qqAuthExtraKey] = auth
|
||||||
|
}
|
||||||
|
|
||||||
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
|
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
|
||||||
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
|
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
|
||||||
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
|
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
|
||||||
@ -1017,13 +1124,28 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
|
|||||||
p.auth.generation = next.generation
|
p.auth.generation = next.generation
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageIDFromInput(raw string) int64 {
|
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
|
||||||
match := qqMessageIDRe.FindStringSubmatch(raw)
|
|
||||||
if len(match) != 2 {
|
// messageIDsFromInput 取出一段输入里出现的全部 message_id。
|
||||||
return 0
|
//
|
||||||
|
// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理;
|
||||||
|
// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。
|
||||||
|
func messageIDsFromInput(raw string) []int64 {
|
||||||
|
matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1)
|
||||||
|
ids := make([]int64, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(match[1], ",") {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
id, _ := strconv.ParseInt(match[1], 10, 64)
|
return ids
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
||||||
@ -1031,24 +1153,27 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
|||||||
source, _ := ctx.Extra["input_source"].(string)
|
source, _ := ctx.Extra["input_source"].(string)
|
||||||
raw := ctx.RawMessage
|
raw := ctx.RawMessage
|
||||||
ctx.RUnlock()
|
ctx.RUnlock()
|
||||||
|
|
||||||
p.authMu.Lock()
|
p.authMu.Lock()
|
||||||
defer p.authMu.Unlock()
|
// 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
|
||||||
if source != p.name {
|
next := qqAuthContext{active: source == p.name}
|
||||||
p.auth = qqAuthContext{}
|
ids := messageIDsFromInput(raw)
|
||||||
p.resetTurnGuardLocked()
|
if source == p.name && len(ids) > 0 {
|
||||||
return nil
|
if auth, ok := p.authByMessageID[ids[0]]; ok {
|
||||||
}
|
next = auth
|
||||||
if messageID := messageIDFromInput(raw); messageID != 0 {
|
for _, id := range ids {
|
||||||
if auth, ok := p.authByMessageID[messageID]; ok {
|
delete(p.authByMessageID, id)
|
||||||
p.auth = auth
|
}
|
||||||
delete(p.authByMessageID, messageID)
|
|
||||||
p.resetTurnGuardLocked()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
|
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。
|
||||||
p.auth = qqAuthContext{active: true}
|
p.auth = next
|
||||||
p.resetTurnGuardLocked()
|
p.resetTurnGuardLocked()
|
||||||
|
p.authMu.Unlock()
|
||||||
|
|
||||||
|
ctx.Lock()
|
||||||
|
bindAuthOnFrame(ctx, next)
|
||||||
|
ctx.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1068,9 +1193,13 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
|
func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) {
|
||||||
|
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
|
||||||
|
auth, onFrame := authOnFrame(ctx)
|
||||||
p.authMu.RLock()
|
p.authMu.RLock()
|
||||||
auth := p.auth
|
if !onFrame {
|
||||||
|
auth = p.auth
|
||||||
|
}
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if auth.active && !auth.owner {
|
if auth.active && !auth.owner {
|
||||||
if auth.isGroup {
|
if auth.isGroup {
|
||||||
@ -1179,7 +1308,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tc := &ctx.ToolCalls[0]
|
tc := &ctx.ToolCalls[0]
|
||||||
allowed, auth := p.currentToolAllowed(tc.Name)
|
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||||
if !auth.active {
|
if !auth.active {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -1286,6 +1415,157 @@ func requiresConfirmFriendCommand(cmd string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enqueueInterrupt 把一条已通过策略/@ 检查的消息并入待投批次,并重置 debounce 计时。
|
||||||
|
//
|
||||||
|
// batchWindow<=0 时退回逐条投递(合并前行为)。
|
||||||
|
func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int64, nickname, single string, owner, highRisk bool) {
|
||||||
|
if p.sdk == nil && p.injectHook == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.batchWindow <= 0 {
|
||||||
|
p.injectInterrupt(single, p.interruptLevel(owner))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := qqBatchKey(msgType, groupID, userID)
|
||||||
|
p.batchMu.Lock()
|
||||||
|
if p.batches == nil {
|
||||||
|
p.batches = make(map[string]*pendingBatch)
|
||||||
|
}
|
||||||
|
b := p.batches[key]
|
||||||
|
if b == nil {
|
||||||
|
b = &pendingBatch{key: key, first: time.Now()}
|
||||||
|
p.batches[key] = b
|
||||||
|
}
|
||||||
|
b.isGroup = msgType == "group"
|
||||||
|
b.userID, b.groupID, b.nickname = userID, groupID, nickname
|
||||||
|
b.msgIDs = append(b.msgIDs, messageID)
|
||||||
|
b.single = single
|
||||||
|
b.owner = b.owner || owner
|
||||||
|
b.highRisk = b.highRisk || highRisk
|
||||||
|
// debounce:每来一条就推迟;但整体不超过 batchMax(否则持续刷屏会一直不投)。
|
||||||
|
delay := p.batchWindow
|
||||||
|
if p.batchMax > 0 {
|
||||||
|
if remain := p.batchMax - time.Since(b.first); remain < delay {
|
||||||
|
delay = remain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if delay < 0 {
|
||||||
|
delay = 0
|
||||||
|
}
|
||||||
|
if b.timer != nil {
|
||||||
|
b.timer.Stop()
|
||||||
|
}
|
||||||
|
b.timer = time.AfterFunc(delay, func() { p.flushBatch(key) })
|
||||||
|
p.batchMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// interruptLevel 决定一条 QQ 消息的中断级别。
|
||||||
|
//
|
||||||
|
// - Bot 所有者/管理员的消息 → **L2**(一般提醒);
|
||||||
|
// - 其他人的消息 → L1(后台,完全可等)。
|
||||||
|
//
|
||||||
|
// 为什么不能一律 L1:L1 之间可以随时互相抢占、也可以被任何更高一级打断,
|
||||||
|
// 于是「老板发的话」会被路人的闲聊挤到后面,甚至对方持续刷屏时一直排在队尾。
|
||||||
|
// 为什么也不该给 L3:L3 是时钟/终端那类"需要及时处理"的实时工作,QQ 是异步
|
||||||
|
// 消息,抬到 L3 会反过来打断真正实时的事情。
|
||||||
|
func (p *Plugin) interruptLevel(owner bool) string {
|
||||||
|
if owner {
|
||||||
|
return sdk.PriorityL2
|
||||||
|
}
|
||||||
|
return sdk.PriorityL1
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容)。
|
||||||
|
func (p *Plugin) injectInterrupt(text, level string) {
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if level == "" {
|
||||||
|
level = sdk.PriorityL1
|
||||||
|
}
|
||||||
|
if p.injectHook != nil {
|
||||||
|
p.injectHook(text, level)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.sdk == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{
|
||||||
|
NoMemory: true,
|
||||||
|
Priority: level,
|
||||||
|
// 中断文本是路由/取正文的指令,不是对话内容,不据它召回。
|
||||||
|
RecallPolicy: sdk.RecallPolicyNone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushBatch 投递一批:n==1 沿用单条原文;n>1 生成「共几条」的合并中断。
|
||||||
|
func (p *Plugin) flushBatch(key string) {
|
||||||
|
p.batchMu.Lock()
|
||||||
|
b := p.batches[key]
|
||||||
|
delete(p.batches, key)
|
||||||
|
p.batchMu.Unlock()
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := b.single
|
||||||
|
if len(b.msgIDs) > 1 {
|
||||||
|
text = p.buildBatchInterrupt(b)
|
||||||
|
}
|
||||||
|
// 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。
|
||||||
|
p.injectInterrupt(text, p.interruptLevel(b.owner))
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。
|
||||||
|
func (p *Plugin) flushAllBatches() {
|
||||||
|
p.batchMu.Lock()
|
||||||
|
keys := make([]string, 0, len(p.batches))
|
||||||
|
for k := range p.batches {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
p.batchMu.Unlock()
|
||||||
|
for _, k := range keys {
|
||||||
|
p.flushBatch(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBatchInterrupt 生成合并中断:说清「一共几条」「分别是哪些 message_id」,
|
||||||
|
// 并给出一次拿全上下文的建议(get_history),避免模型逐条 get_message。
|
||||||
|
func (p *Plugin) buildBatchInterrupt(b *pendingBatch) string {
|
||||||
|
tp := p.name + "_"
|
||||||
|
outputTool := "output_send__" + p.name
|
||||||
|
n := len(b.msgIDs)
|
||||||
|
ids := formatMsgIDs(b.msgIDs)
|
||||||
|
var s string
|
||||||
|
if b.isGroup {
|
||||||
|
s = fmt.Sprintf("来自「%s」在群里短时间内连续发来 %d 条消息(message_id=%s)。建议先用%sget_history(group_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复群聊",
|
||||||
|
b.nickname, n, ids, tp, b.groupID, n+5, tp, outputTool)
|
||||||
|
} else {
|
||||||
|
s = fmt.Sprintf("来自「%s」的私聊短时间内连续发来 %d 条消息(message_id=%s, user_id=%d)。建议先用%sget_history(user_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复对方",
|
||||||
|
b.nickname, n, ids, b.userID, tp, b.userID, n+5, tp, outputTool)
|
||||||
|
}
|
||||||
|
if b.highRisk {
|
||||||
|
s = "【⚠️ 高危信息,谨慎处理】" + s
|
||||||
|
}
|
||||||
|
if b.owner {
|
||||||
|
s = "【重要!Bot 所有者消息】" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMsgIDs 把 message_id 列表压成一行;过多时截断,避免中断文字过长。
|
||||||
|
func formatMsgIDs(ids []int64) string {
|
||||||
|
const capN = 12
|
||||||
|
parts := make([]string, 0, len(ids)+1)
|
||||||
|
for i, id := range ids {
|
||||||
|
if i >= capN {
|
||||||
|
parts = append(parts, "…")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||||
@ -1404,7 +1684,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
highRisk := false
|
||||||
if highRiskRe.MatchString(text) {
|
if highRiskRe.MatchString(text) {
|
||||||
|
highRisk = true
|
||||||
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
|
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1433,15 +1715,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
p.startTyping(evt.UserID)
|
p.startTyping(evt.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.sdk != nil {
|
// 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。
|
||||||
// NoMemory:HTTP 侧来的中断提示,不是对话内容。
|
p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk)
|
||||||
// Priority:QQ 消息是**低级别中断**——既不是时钟那样的实时工作,
|
|
||||||
// 也不是紧急工作,所以声明 L1(完全可等)。
|
|
||||||
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{
|
|
||||||
NoMemory: true,
|
|
||||||
Priority: sdk.PriorityL1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2539,7 +2815,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
|||||||
// Priority:同上,QQ 侧一律低级别中断(L1)。
|
// Priority:同上,QQ 侧一律低级别中断(L1)。
|
||||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath),
|
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath),
|
||||||
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
|
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errMsg = "下载失败,文件可能已过期"
|
errMsg = "下载失败,文件可能已过期"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
)
|
)
|
||||||
@ -202,3 +203,231 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
|
||||||
|
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
|
||||||
|
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
|
||||||
|
// (报「可信 QQ 会话身份不完整」)。
|
||||||
|
//
|
||||||
|
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
|
||||||
|
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true}
|
||||||
|
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
|
||||||
|
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
|
||||||
|
|
||||||
|
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||||
|
"payload": "带齐 meta 的主动发送",
|
||||||
|
"type": "text",
|
||||||
|
"meta": `{"user_id":2198972886}`,
|
||||||
|
})
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
|
||||||
|
if err := p.beforeToolcall(ctx2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
|
||||||
|
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 消息合并(debounce)----
|
||||||
|
|
||||||
|
// collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK)。
|
||||||
|
func collectInterrupts(p *Plugin) *[]string {
|
||||||
|
got := []string{}
|
||||||
|
p.injectHook = func(s, _ string) { got = append(got, s) }
|
||||||
|
return &got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsecutiveMessagesFromSameSenderAreBatched(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
got := collectInterrupts(p)
|
||||||
|
p.batchWindow = 20 * time.Millisecond
|
||||||
|
p.batchMax = time.Second
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
p.enqueueInterrupt("private", 10001, 0, int64(100+i), "小明", "单条", false, false)
|
||||||
|
}
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
if len(*got) != 1 {
|
||||||
|
t.Fatalf("同一发送者连发 3 条应合并成 1 次中断,实际 %d 次: %#v", len(*got), *got)
|
||||||
|
}
|
||||||
|
if !strings.Contains((*got)[0], "3 条消息") {
|
||||||
|
t.Fatalf("合并中断应说明一共几条,实际: %s", (*got)[0])
|
||||||
|
}
|
||||||
|
// 三个 message_id 都要带上,模型才能取全
|
||||||
|
for _, id := range []string{"100", "101", "102"} {
|
||||||
|
if !strings.Contains((*got)[0], id) {
|
||||||
|
t.Fatalf("合并中断漏了 message_id=%s: %s", id, (*got)[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDifferentSendersAreNotBatchedTogether(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
got := collectInterrupts(p)
|
||||||
|
p.batchWindow = 20 * time.Millisecond
|
||||||
|
p.batchMax = time.Second
|
||||||
|
|
||||||
|
p.enqueueInterrupt("private", 10001, 0, 1, "小明", "a", false, false)
|
||||||
|
p.enqueueInterrupt("private", 10002, 0, 2, "小红", "b", false, false)
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
if len(*got) != 2 {
|
||||||
|
t.Fatalf("不同发送者不该合并,应有 2 次中断,实际 %d: %#v", len(*got), *got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchWindowZeroFallsBackToPerMessage(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
got := collectInterrupts(p)
|
||||||
|
p.batchWindow = 0
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
p.enqueueInterrupt("private", 10001, 0, int64(i), "小明", "原文", false, false)
|
||||||
|
}
|
||||||
|
if len(*got) != 3 {
|
||||||
|
t.Fatalf("关闭合并时应逐条投递(3 次),实际 %d: %#v", len(*got), *got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleMessageKeepsOriginalText(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
got := collectInterrupts(p)
|
||||||
|
p.batchWindow = 20 * time.Millisecond
|
||||||
|
p.batchMax = time.Second
|
||||||
|
|
||||||
|
p.enqueueInterrupt("group", 10001, 20002, 7, "小明", "单条原文", true, false)
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
if len(*got) != 1 || (*got)[0] != "单条原文" {
|
||||||
|
t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot 所有者/管理员的消息给 L2,普通人的给 L1 —— 否则所有者的话会被路人
|
||||||
|
// 的 L1 闲聊抢占/挤到队尾。
|
||||||
|
func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
got := []string{}
|
||||||
|
p.injectHook = func(text, level string) { got = append(got, text+"|"+level) }
|
||||||
|
p.batchWindow = 20 * time.Millisecond
|
||||||
|
p.batchMax = time.Second
|
||||||
|
|
||||||
|
p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false)
|
||||||
|
p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false)
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
joined := strings.Join(got, ",")
|
||||||
|
if !strings.Contains(joined, "owner-msg|L2") {
|
||||||
|
t.Fatalf("所有者消息应为 L2,实际 %q", joined)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "other-msg|L1") {
|
||||||
|
t.Fatalf("普通人消息应为 L1,实际 %q", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后,
|
||||||
|
// 外层轮被恢复(resumeTask 复用同一帧、不重跑 onInput)时权限门不能整体失效。
|
||||||
|
func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
|
||||||
|
// 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。
|
||||||
|
inner := &sdk.StageContext{Extra: map[string]interface{}{
|
||||||
|
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
|
||||||
|
}}
|
||||||
|
if err := p.afterOutputAuthContext(inner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.auth.active {
|
||||||
|
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
|
||||||
|
frame := &sdk.StageContext{
|
||||||
|
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
|
||||||
|
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||||
|
}
|
||||||
|
if err := p.beforeToolcall(frame); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") {
|
||||||
|
t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。
|
||||||
|
func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
|
||||||
|
frame := &sdk.StageContext{
|
||||||
|
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
|
||||||
|
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||||
|
}
|
||||||
|
// 路人的群消息在所有者轮运行中到达。
|
||||||
|
p.activateAuthContext(4242, 10001, 20002, true)
|
||||||
|
if p.auth.owner {
|
||||||
|
t.Fatal("到达事件应改写全局身份(复现场景)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.beforeToolcall(frame); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if frame.Response != nil {
|
||||||
|
t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。
|
||||||
|
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.authByMessageID = map[int64]qqAuthContext{
|
||||||
|
100: {active: true, owner: true, userID: 2198972886},
|
||||||
|
101: {active: true, owner: true, userID: 2198972886},
|
||||||
|
}
|
||||||
|
ctx := &sdk.StageContext{
|
||||||
|
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
|
||||||
|
Extra: map[string]interface{}{"input_source": "qq"},
|
||||||
|
}
|
||||||
|
if err := p.onInputAuthContext(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !p.auth.owner {
|
||||||
|
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
|
||||||
|
}
|
||||||
|
if len(p.authByMessageID) != 0 {
|
||||||
|
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
|
||||||
|
}
|
||||||
|
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
|
||||||
|
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
|
||||||
|
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
|
||||||
|
p := newPermissionTestPlugin(t)
|
||||||
|
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||||
|
|
||||||
|
ctx := &sdk.StageContext{
|
||||||
|
RawMessage: "webui 里的提问",
|
||||||
|
Extra: map[string]interface{}{"input_source": "webui"},
|
||||||
|
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||||
|
}
|
||||||
|
if err := p.onInputAuthContext(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := p.beforeToolcall(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ctx.Response != nil {
|
||||||
|
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
53
example/recoverydiag/README.md
Normal file
53
example/recoverydiag/README.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# recoverydiag · 快速检查 / 崩溃取证
|
||||||
|
|
||||||
|
给 guard 与 failback 用的**确定性诊断工具集**。
|
||||||
|
|
||||||
|
设计基调(源码原话):**返回结论而非原文,确定性检出,不消耗 LLM token。**
|
||||||
|
崩溃后最忌讳的是把几万行日志塞进模型上下文让它"看看",那既慢又不可靠 ——
|
||||||
|
这里每个工具都在本地算出结论再返回。
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `recoverydiag_diag_triage` | 快速分诊:按退出码 / 信号 / 存活状态粗分类别(进程死亡 vs 配置类不可达 vs 正常) |
|
||||||
|
| `recoverydiag_diag_db` | config.db 完整性(`PRAGMA integrity_check`)+ LLM 源解析校验(`core.llm.sources.*` 必备字段),逐项 ok/fail |
|
||||||
|
| `recoverydiag_diag_log_scan` | 在日志目录的时间窗内统计已知错误签名(panic / OOM / 网络不可达 / provider 失败 / sql / 致命)出现次数,给出主导结论 |
|
||||||
|
| `recoverydiag_diag_delta` | 对比 baseline(上次 good 快照/目录)与现状,列出 created / modified / deleted 清单与摘要,判定"改了什么" |
|
||||||
|
| `recoverydiag_diag_loc` | 综合前四项结论,按**因果强度正交排序**定位根因并给出推荐恢复动作 |
|
||||||
|
|
||||||
|
## 用法顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
diag_triage → diag_db → diag_log_scan → diag_delta → diag_loc
|
||||||
|
(各自独立,可只跑需要的) (要传前四项的结论)
|
||||||
|
```
|
||||||
|
|
||||||
|
`diag_loc` 需要你把它余下的结论**作为参数传进去**(`triage` / `db` / `log` / `delta` 四个对象),
|
||||||
|
它不自己去调 —— 这样它只做归因,不重复执行。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `db_check_cmd` | `auto` | `diag_db` 用的 `sqlite3` 命令。留空=auto:可用时用 sqlite3,缺失则回退读内核 Settings |
|
||||||
|
| `recovery_kb_dir` | 空 | `diag_loc` 结论 JSON 的落盘目录。缺省 `<data_dir>/recovery_kb` |
|
||||||
|
|
||||||
|
## 不注册通道与钩子
|
||||||
|
|
||||||
|
本插件**只提供工具**,不订阅输入、不挂阶段钩子 —— 它是被 guard 或 agent 主动调用的,
|
||||||
|
不做后台干预。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`diag_test.go` 覆盖各诊断项的判定逻辑。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -1,13 +1,44 @@
|
|||||||
# rss
|
# rss · RSS/Atom 订阅监控
|
||||||
|
|
||||||
rss plugin
|
订阅 RSS/Atom 源,**有新文章时主动通知** agent(不必每轮去问)。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `rss_subscribe` | 订阅一个 RSS/Atom 源 |
|
||||||
|
| `rss_unsubscribe` | 取消订阅 |
|
||||||
|
| `rss_list` | 列出全部订阅 |
|
||||||
|
| `rss_check_now` | 立即检查所有源(不等轮询周期) |
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `poll_interval` | `30` | 默认轮询间隔(**分钟**) |
|
||||||
|
|
||||||
|
订阅时可对单个源覆盖间隔。
|
||||||
|
|
||||||
|
## 通知机制
|
||||||
|
|
||||||
|
- 后台按各自间隔轮询(默认 30 分钟)。
|
||||||
|
- 发现新条目时通过 `InjectInterruptText` 注入,格式形如
|
||||||
|
`📡 <源标题> (<URL>) — N 篇新文章:` 后跟条目。
|
||||||
|
- 注入带 **`NoMemory: true`**,通道 `rss` 也声明为 `NoMemory` ——
|
||||||
|
订阅推送是信号不是知识,不该进向量化挤掉别的记忆。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **订阅时就记下全部已有 GUID**:`handleSubscribe` 会把抓取到的历史条目
|
||||||
|
一次性标为 `seenGUIDs`,所以**订阅一个源不会把它的历史文章全部推送一遍**。
|
||||||
|
只有订阅之后新出现的条目才通知。这是避免刷屏的关键。
|
||||||
|
- **去重按「源 URL + GUID」**:不同源可能用相同 GUID,只用 GUID 会互相误判。
|
||||||
|
GUID 缺失时回退用 `link`;两者都缺则跳过该条。
|
||||||
|
- `seenGUIDs` 有清理逻辑,不会无限增长。
|
||||||
|
- 解析用 [gofeed](https://github.com/mmcdole/gofeed)(`v1.4.0`)。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
48
example/sanitizer/README.md
Normal file
48
example/sanitizer/README.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# sanitizer · 文本清洗
|
||||||
|
|
||||||
|
**不注册任何工具**,只挂三个阶段钩子,在 Agent 全链路上洗掉两类污染:
|
||||||
|
|
||||||
|
1. **坏字节**:坏 UTF-8、`U+FFFD`(替换符)、ANSI 转义序列
|
||||||
|
2. **思维泄漏**:LLM 输出里残留的工具调用标记
|
||||||
|
|
||||||
|
## 为什么需要它
|
||||||
|
|
||||||
|
坏字节会**被 LLM 复读**。一次工具返回乱码(比如源码里带 ANSI 颜色码、或二进制片段被当文本读出来),
|
||||||
|
这些字节会进上下文,之后模型每次生成都可能把它抄一遍 —— 越滚越脏。
|
||||||
|
在每个入口洗掉,比事后清理便宜得多。
|
||||||
|
|
||||||
|
思维泄漏则是另一种:模型有时把 `<tool_call>...</tool_call>` 这类内部标记直接写进正文,
|
||||||
|
用户就看到一堆不该出现的 XML。
|
||||||
|
|
||||||
|
## 挂载的三个阶段
|
||||||
|
|
||||||
|
| 阶段 | 处理对象 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `on_input` | `ctx.RawMessage` | 洗用户输入,脏字节不进后续链路 |
|
||||||
|
| `after_toolcall` | `ctx.ToolResults` | 洗工具结果,**坏字节不进 LLM 上下文** |
|
||||||
|
| `post_action` | `ctx.LLMText` | 洗模型输出:先清思维泄漏,再清乱码 |
|
||||||
|
|
||||||
|
每次有改动都打一行日志(`cleaned N bytes`),便于确认它真的在工作而不是静默失败。
|
||||||
|
|
||||||
|
## 识别哪些泄漏形态
|
||||||
|
|
||||||
|
按正则匹配多种标记写法,覆盖不同模型家族的习惯:
|
||||||
|
|
||||||
|
- `<tool_call>…</tool_call>`、`<invoke>…</invoke>`、`<tool>…</tool>`
|
||||||
|
- `<function>…</function>`
|
||||||
|
- 上述标记包在 ```xml / ```json 代码块里的形态
|
||||||
|
- 中文括号变体:`【tool_call】…【/tool_call】`
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- 依赖 **ABI v2 的 stage 写回能力**:插件对 `StageContext` 的修改会同步回内核。
|
||||||
|
在 v1 上改了不生效。
|
||||||
|
- 读写 `StageContext` 时按约定加 `ctx.Lock()`。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -buildmode=plugin -o sanitizer.so .
|
||||||
|
```
|
||||||
|
|
||||||
|
或经 `hmapdev build` 打包为 `.hmap`。
|
||||||
77
example/vanblog/README.md
Normal file
77
example/vanblog/README.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# vanblog · VanBlog 博客管理
|
||||||
|
|
||||||
|
用管理 API 操作 [VanBlog](https://vanblog.mereith.com/) 开源博客系统:
|
||||||
|
文章增删改查、分类标签、草稿发布、备份导出等。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `url` | `https://blog.jianfgit.xyz` | VanBlog 站点基地址 |
|
||||||
|
| `token` | 空 | 管理员 API Token(长期令牌,从后台「Token 管理」创建) |
|
||||||
|
| `reset_token` | 空 | 用于 `auth/restore` 重置管理员密码的**特殊** Token |
|
||||||
|
|
||||||
|
`token` 与 `reset_token` 都是 `password` 类型(界面遮蔽)。
|
||||||
|
|
||||||
|
## 工具(28 个)
|
||||||
|
|
||||||
|
### 文章
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `vanblog_list_articles` | 列文章,支持分页与搜索 |
|
||||||
|
| `vanblog_get_article` | 取单篇完整内容 |
|
||||||
|
| `vanblog_create_article` | 新建(`title` 与 `category` 必填) |
|
||||||
|
| `vanblog_update_article` | 更新(**只传要改的字段**) |
|
||||||
|
| `vanblog_delete_article` | 删除(**软删除**) |
|
||||||
|
| `vanblog_search_articles` | 按链接搜索文章 |
|
||||||
|
|
||||||
|
### 草稿
|
||||||
|
|
||||||
|
`vanblog_manage_drafts`:`list` / `get` / `create` / `update` / `delete` / **`publish`**
|
||||||
|
|
||||||
|
### 内容组织
|
||||||
|
|
||||||
|
| 工具 | 命令 |
|
||||||
|
|---|---|
|
||||||
|
| `vanblog_manage_categories` | `list` / `get` / `create` / `update` / `delete` |
|
||||||
|
| `vanblog_manage_tags` | `list` / `get` / `rename` / `delete` |
|
||||||
|
|
||||||
|
### 站点与运维
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `vanblog_manage_site` / `_settings` / `_menu` / `_social` / `_links` | 站点配置类 |
|
||||||
|
| `vanblog_manage_about` / `_pages` | 关于页与自定义页面 |
|
||||||
|
| `vanblog_manage_images` | 图床管理 |
|
||||||
|
| `vanblog_manage_rewards` | 赞赏配置 |
|
||||||
|
| `vanblog_manage_backup` | 备份 |
|
||||||
|
| `vanblog_manage_caddy` | Caddy 配置 |
|
||||||
|
| `vanblog_manage_isr` | ISR 增量静态渲染 |
|
||||||
|
| `vanblog_manage_pipelines` | 流水线 |
|
||||||
|
| `vanblog_manage_collaborators` | 协作者 |
|
||||||
|
| `vanblog_manage_tokens` | Token 管理 |
|
||||||
|
| `vanblog_get_analysis` / `_logs` / `_meta` | 统计、日志、元信息 |
|
||||||
|
| `vanblog_auth` | 认证相关(含 `restore` 重置密码) |
|
||||||
|
|
||||||
|
> 工具名前缀取自插件名(`tp`),上面按默认 `vanblog_` 列出。
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- 走的是 VanBlog 的管理 API(`/api/admin/...`),所以必须配 **admin token**,
|
||||||
|
不是前台只读接口。
|
||||||
|
- `update_article` 是**部分更新**:只传想改的字段,没传的保持不变。
|
||||||
|
(不要为了改标题而把正文一起传一遍。)
|
||||||
|
- `delete_article` 是**软删除**,内容仍在,可在后台恢复。
|
||||||
|
- 早期版本把 token 放在内核配置(`plugin.vanblog.token`)里,
|
||||||
|
现在会**自动迁移**到插件配置,迁移后清空内核侧取值。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
需要一个可访问的 VanBlog 实例,并在后台创建一个长期 Token。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev build
|
||||||
|
```
|
||||||
@ -2,7 +2,7 @@ module vikunja-plugin
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本
|
// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本
|
||||||
|
|
||||||
@ -12,4 +12,4 @@ require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||||
|
|||||||
@ -6,7 +6,12 @@
|
|||||||
"description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底",
|
"description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.bin",
|
"entry": "plugin.bin",
|
||||||
"sdk": "1.2.0",
|
"tags": [
|
||||||
"tags": ["vikunja", "todo", "task", "gtd", "productivity"],
|
"vikunja",
|
||||||
|
"todo",
|
||||||
|
"task",
|
||||||
|
"gtd",
|
||||||
|
"productivity"
|
||||||
|
],
|
||||||
"targets": "linux/amd64"
|
"targets": "linux/amd64"
|
||||||
}
|
}
|
||||||
@ -1,13 +1,33 @@
|
|||||||
# weather
|
# weather · 天气查询
|
||||||
|
|
||||||
weather plugin
|
给 agent 补上天气查询能力(基于 [wttr.in](https://wttr.in),无需 API Key)。
|
||||||
|
|
||||||
## Build
|
## 工具
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `weather_current` | 查询某城市当前天气 |
|
||||||
|
| `weather_forecast` | 查询未来几天预报 |
|
||||||
|
| `weather_set_location` | 设置默认城市 |
|
||||||
|
|
||||||
|
`weather_current` / `weather_forecast` 都接受 `location`(城市名,如 `Beijing`、`Shanghai`);
|
||||||
|
省略时用配置里的默认城市。`weather_current` 另有 `units`:`metric`(摄氏,默认)或 `imperial`(华氏)。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `default_location` | 空 | 默认城市名。留空则每次调用都必须传 `location` |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- **`NoMemory: true`**:天气是外部实时数据,对记忆计算无长期价值,跳过向量化与关键词提取(原文仍保留在对话里)。
|
||||||
|
- **`Cleaner`**:输出参与记忆计算前先过滤,只保留摘要行 —— 天气查询会反复出现,全文进记忆会挤占上下文预算,而"上周三北京多少度"通常并不需要召回。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hmapdev build
|
hmapdev build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
产出 `.hmap` 后经 Plugin Manager API 安装。
|
||||||
|
|
||||||
Upload the .hmap file through the Plugin Manager API.
|
|
||||||
|
|||||||
16
meta/meta.go
16
meta/meta.go
@ -41,16 +41,14 @@ var (
|
|||||||
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||||
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||||
//
|
//
|
||||||
// 现为 1.2.0:核心的 1.2.x 线正在发布中(release/v1.2.x 承载 1.2.0),
|
// 现为 1.4.0:1.3.0 已随核心的正式 tag `v1.3.0` 定版并发版(本仓 tag v1.3.0、
|
||||||
// 但 **SDK 不跟 beta 发版**(§七.2)——SDK 1.2.0 的定版与 tag 随核心的
|
// release/v1.3.x 承载它),该号从此归发布线所有,main 遂推进到下一个未发布中版本。
|
||||||
// **正式** tag 一起做(§七.3)。在那之前 1.2.0 仍是 SDK 尚未发布的中版本,
|
|
||||||
// 所以 main 就停在 1.2.0。
|
|
||||||
//
|
//
|
||||||
// 注意:这里与核心 main **故意不对称**。核心一旦切出 release/v1.2.x,
|
// ❗本仓**不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`,核心的 1.3.x
|
||||||
// 1.2.0 就归发布线所有,main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag,
|
// 后续 patch **不伴随 SDK 发版** —— patch 位恒为 `.0`,带非零 patch 的 SDK tag
|
||||||
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。
|
// 都是错的。(2026-09-13 曾误发 `v1.3.1`,已撤回;`v1.2.1` 是同一类历史遗留。)
|
||||||
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。)
|
//
|
||||||
Version = "1.3.0"
|
Version = "1.4.0"
|
||||||
|
|
||||||
// Commit 是构建时的 Git commit hash。
|
// Commit 是构建时的 Git commit hash。
|
||||||
Commit = "unknown"
|
Commit = "unknown"
|
||||||
|
|||||||
145
mkdocs.yml
Normal file
145
mkdocs.yml
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# HomeAgent 插件 SDK 文档站配置。
|
||||||
|
#
|
||||||
|
# 设计取舍:
|
||||||
|
# - 每页顶部都放「版本 + 编辑链接」,因为 SDK 与内核的协议版本会错配,
|
||||||
|
# 读者必须先能确认自己看的是哪一版。
|
||||||
|
# - 中文检索依赖 jieba(已装);英文走内置分词。两者都不要额外服务。
|
||||||
|
# - docs/api/*.md 是**生成物**(tools/apidoc/gensite),页首会写明,防止手改。
|
||||||
|
|
||||||
|
site_name: HomeAgent 插件 SDK
|
||||||
|
site_description: 用 Go 或 Lua 为 HomeAgent 编写插件 —— API 参考与开发指南
|
||||||
|
site_url: https://sdk.homeagent.jianfgit.xyz/
|
||||||
|
copyright: MIT 许可 · JianFeeeee
|
||||||
|
|
||||||
|
# 主题覆盖目录:只覆盖 footer.html(补备案号,见该文件里的说明)。
|
||||||
|
docs_dir: docs
|
||||||
|
site_dir: site_build
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
language: zh
|
||||||
|
# custom_dir 必须写在 theme 下(顶层会被判为未知配置)。
|
||||||
|
custom_dir: overrides
|
||||||
|
# 品牌图标:与主站 introduce 同一份 logo(曾用 Material 默认,不是我们的)。
|
||||||
|
logo: assets/logo-mark.svg
|
||||||
|
favicon: assets/logo.svg
|
||||||
|
features:
|
||||||
|
- navigation.instant
|
||||||
|
- navigation.instant.progress
|
||||||
|
- navigation.tracking
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.sections
|
||||||
|
- navigation.indexes
|
||||||
|
- navigation.top
|
||||||
|
- toc.follow
|
||||||
|
- search.suggest
|
||||||
|
- search.highlight
|
||||||
|
- search.share
|
||||||
|
- content.code.copy
|
||||||
|
- content.code.annotate
|
||||||
|
- content.action.edit
|
||||||
|
palette:
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-night
|
||||||
|
name: 切换到深色
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: slate
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-sunny
|
||||||
|
name: 切换到浅色
|
||||||
|
icon:
|
||||||
|
repo: fontawesome/brands/git-alt
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search:
|
||||||
|
lang:
|
||||||
|
- zh
|
||||||
|
- en
|
||||||
|
separator: '[\s\u200b\-]'
|
||||||
|
# 中文按词切(jieba),否则整句变一个 token,检索不到。
|
||||||
|
jieba_dict: null
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- attr_list
|
||||||
|
- def_list
|
||||||
|
- footnotes
|
||||||
|
- md_in_html
|
||||||
|
- tables
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
toc_depth: 3
|
||||||
|
- pymdownx.details
|
||||||
|
# Material 的图标语法 :material-xxx: / :octicons-xxx: 依赖这个扩展。
|
||||||
|
# 没开时它们会**原样显示为文本**(实测首页四个卡片全花了)。
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||||
|
- pymdownx.highlight:
|
||||||
|
anchor_linenums: true
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.snippets
|
||||||
|
- pymdownx.superfences
|
||||||
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
|
|
||||||
|
extra:
|
||||||
|
generator: false
|
||||||
|
social:
|
||||||
|
# 主仓已迁到 GitHub;gitcode 保留为国内镜像(源码同步)。
|
||||||
|
# 两个链接都放,是因为国内直连 gitcode 更快,而海外/权威源看 GitHub。
|
||||||
|
- icon: fontawesome/solid/code
|
||||||
|
link: https://github.com/JianFeeeee/homeagentsdk
|
||||||
|
name: SDK 源码(GitHub)
|
||||||
|
- icon: fontawesome/solid/code-branch
|
||||||
|
link: https://gitcode.com/JianFeeeee/homeagent-sdk
|
||||||
|
name: gitcode 镜像(国内)
|
||||||
|
- icon: fontawesome/solid/house
|
||||||
|
link: https://introduce.homeagent.jianfgit.xyz/
|
||||||
|
name: HomeAgent 介绍站
|
||||||
|
# 自定义检索端点在 docs/javascripts/api-search.js 里注册,
|
||||||
|
# 索引文件由 gensite 产出:docs/assets/api-index.json
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- 首页: index.md
|
||||||
|
- 快速开始:
|
||||||
|
- 环境与工具链: guide/getting-started.md
|
||||||
|
- 第一个 Go 插件: guide/first-plugin.md
|
||||||
|
- 第一个 Lua 插件: guide/first-lua-plugin.md
|
||||||
|
- API 参考:
|
||||||
|
- api/index.md
|
||||||
|
- 工具(Tools): api/tools.md
|
||||||
|
- 阶段钩子(Stages): api/stages.md
|
||||||
|
- 记忆(Memory): api/memory.md
|
||||||
|
- 输入/输出通道: api/channels.md
|
||||||
|
- 配置(Settings): api/settings.md
|
||||||
|
- 生命周期(Lifecycle): api/lifecycle.md
|
||||||
|
- 事件(Events): api/events.md
|
||||||
|
- LLM 调用: api/llm.md
|
||||||
|
- 常量与枚举: api/constants.md
|
||||||
|
- 桥接装配点: api/bridge.md
|
||||||
|
- 其他类型: api/misc.md
|
||||||
|
- 仅内置插件可用: api/builtin-only.md
|
||||||
|
- 指南:
|
||||||
|
- 能力边界(哪些 API 外部可用): guide/capability-boundary.md
|
||||||
|
- 工具并发声明(ParallelSafe/Serial): guide/parallel-tool-declaration.md
|
||||||
|
- 流式多 tool_call(适配器透传 index): guide/stream-tool-call-index.md
|
||||||
|
- 场景记忆(按场合召回): guide/scene-memory.md
|
||||||
|
- 打包与发布: guide/packaging.md
|
||||||
|
- 多平台构建: guide/multi-platform.md
|
||||||
|
- 受限 SDK 与安全: guide/security.md
|
||||||
|
- 示例插件:
|
||||||
|
- 总览: examples/index.md
|
||||||
|
- 版本与兼容: versions.md
|
||||||
|
|
||||||
|
extra_css:
|
||||||
|
- stylesheets/extra.css
|
||||||
|
|
||||||
|
extra_javascript:
|
||||||
|
- javascripts/api-search.js
|
||||||
59
overrides/partials/footer.html
Normal file
59
overrides/partials/footer.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{#-
|
||||||
|
footer.html 覆盖:在版权行下补**备案号**。
|
||||||
|
|
||||||
|
为什么要覆盖主题文件:Material 的 copyright 只渲染 config.copyright(一个字符串),
|
||||||
|
而备案号必须是**带链接的 HTML**且含两个条目(ICP + 公安),塞不进那个字段。
|
||||||
|
|
||||||
|
另外把「许可」写进 footer:本站内容与主站一致受 AGPL 约束,
|
||||||
|
读者在任何页面底部都能看到,不必翻到首页。
|
||||||
|
-#}
|
||||||
|
<footer class="md-footer">
|
||||||
|
{% if "navigation.footer" in features %}
|
||||||
|
{% if page.previous_page or page.next_page %}
|
||||||
|
{% if page.meta and page.meta.hide %}
|
||||||
|
{% set hidden = "hidden" if "footer" in page.meta.hide %}
|
||||||
|
{% endif %}
|
||||||
|
<nav class="md-footer__inner md-grid" aria-label="{{ lang.t('footer') }}" {{ hidden }}>
|
||||||
|
{% if page.previous_page %}
|
||||||
|
{% set direction = lang.t("footer.previous") %}
|
||||||
|
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}">
|
||||||
|
<div class="md-footer__button md-icon">
|
||||||
|
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
|
||||||
|
{% include ".icons/" ~ icon ~ ".svg" %}
|
||||||
|
</div>
|
||||||
|
<div class="md-footer__title">
|
||||||
|
<span class="md-footer__direction">{{ direction }}</span>
|
||||||
|
<div class="md-ellipsis">{{ page.previous_page.title }}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.next_page %}
|
||||||
|
{% set direction = lang.t("footer.next") %}
|
||||||
|
<a href="{{ page.next_page.url | url }}" class="md-footer__link md-footer__link--next" aria-label="{{ direction }}: {{ page.next_page.title | e }}">
|
||||||
|
<div class="md-footer__title">
|
||||||
|
<span class="md-footer__direction">{{ direction }}</span>
|
||||||
|
<div class="md-ellipsis">{{ page.next_page.title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md-footer__button md-icon">
|
||||||
|
{% set icon = config.theme.icon.next or "material/arrow-right" %}
|
||||||
|
{% include ".icons/" ~ icon ~ ".svg" %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="md-footer-meta md-typeset">
|
||||||
|
<div class="md-footer-meta__inner md-grid">
|
||||||
|
{% include "partials/copyright.html" %}
|
||||||
|
<div class="md-copyright ha-beian">
|
||||||
|
<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">豫ICP备2024074105号-1</a>
|
||||||
|
<span class="ha-beian-sep">·</span>
|
||||||
|
<a href="https://beian.mps.gov.cn" target="_blank" rel="noopener noreferrer">豫公网安备41070202001579号</a>
|
||||||
|
</div>
|
||||||
|
{% if config.extra.social %}
|
||||||
|
{% include "partials/social.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@ -68,13 +68,21 @@ fi
|
|||||||
# 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。
|
# 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。
|
||||||
# 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。
|
# 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。
|
||||||
# 工具链二进制名由 plugindev 改为 hmapdev;旧变量名 PLUGINDEV 仍兼容。
|
# 工具链二进制名由 plugindev 改为 hmapdev;旧变量名 PLUGINDEV 仍兼容。
|
||||||
|
#
|
||||||
|
# ★ 下面三处必须读 **HMAPDEV**(上一行刚解析出的规范名)。
|
||||||
|
# 曾经读的是 PLUGINDEV:那样只有调用方恰好传旧名时才工作,
|
||||||
|
# 而新名 HMAPDEV 只被赋给 HMAPDEV 本身、PLUGINDEV 仍是未定义,
|
||||||
|
# 在 `set -u` 下第一处判断就 "PLUGINDEV: unbound variable" 直接退出。
|
||||||
|
# 实测(2026-09-29):HMAPDEV=... → exit 1;PLUGINDEV=... → 20/20 成功。
|
||||||
|
# 发版路径传的是旧名(build.sh:92)故一直没暴露——正是「兼容」二字
|
||||||
|
# 写在注释里、却没在代码里做到的那种缺陷。
|
||||||
HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"
|
HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"
|
||||||
if [ ! -x "$PLUGINDEV" ]; then
|
if [ ! -x "$HMAPDEV" ]; then
|
||||||
echo "[examples] 先构建 hmapdev ..."
|
echo "[examples] 先构建 hmapdev ..."
|
||||||
( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || {
|
( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || {
|
||||||
echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; }
|
echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$PLUGINDEV" ]; then
|
if [ ! -x "$HMAPDEV" ]; then
|
||||||
echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2
|
echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -97,7 +105,7 @@ for dir in "$SDK_ROOT"/example/*/; do
|
|||||||
# 清掉旧产物:残留会让人(和本脚本)误判成功。
|
# 清掉旧产物:残留会让人(和本脚本)误判成功。
|
||||||
rm -rf "$dir/build" "$dir/dist"
|
rm -rf "$dir/build" "$dir/dist"
|
||||||
|
|
||||||
out=$( cd "$dir" && "$PLUGINDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 )
|
out=$( cd "$dir" && "$HMAPDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 )
|
||||||
rc=$?
|
rc=$?
|
||||||
|
|
||||||
# 判据是**退出码 + 产物存在**,两者都要。
|
# 判据是**退出码 + 产物存在**,两者都要。
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.10)
|
|
||||||
project(ha_remotedevice VERSION 0.1.0 LANGUAGES C)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# ha_remotedevice — HomeAgent 远程设备接入 C SDK
|
|
||||||
# 零外部依赖,纯 C 实现,兼容嵌入式平台。
|
|
||||||
#
|
|
||||||
# 使用方式:
|
|
||||||
# add_subdirectory(path/to/ha_remotedevice)
|
|
||||||
# target_link_libraries(my_app ha_remotedevice)
|
|
||||||
# target_include_directories(my_app PRIVATE
|
|
||||||
# ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# 选项: 构建为静态库或动态库
|
|
||||||
option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF)
|
|
||||||
|
|
||||||
# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调)
|
|
||||||
option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF)
|
|
||||||
|
|
||||||
# 选项: 日志级别
|
|
||||||
set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug")
|
|
||||||
|
|
||||||
# 源文件
|
|
||||||
set(HA_REMOTEDEVICE_SRC
|
|
||||||
src/ha_remotedevice.c
|
|
||||||
src/ha_json.c
|
|
||||||
src/ha_ws.c
|
|
||||||
)
|
|
||||||
|
|
||||||
# 头文件
|
|
||||||
set(HA_REMOTEDEVICE_INCLUDE
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
|
||||||
)
|
|
||||||
|
|
||||||
# 编译选项
|
|
||||||
if(HA_NO_ALLOC)
|
|
||||||
add_definitions(-DHA_NO_ALLOC)
|
|
||||||
endif()
|
|
||||||
add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL})
|
|
||||||
|
|
||||||
# 创建库
|
|
||||||
if(BUILD_SHARED_LIBS)
|
|
||||||
add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC})
|
|
||||||
if(WIN32)
|
|
||||||
# Windows 需要导出符号
|
|
||||||
set_target_properties(ha_remotedevice PROPERTIES
|
|
||||||
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
|
||||||
endif()
|
|
||||||
else()
|
|
||||||
add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC})
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# 包含目录
|
|
||||||
target_include_directories(ha_remotedevice
|
|
||||||
PUBLIC ${HA_REMOTEDEVICE_INCLUDE}
|
|
||||||
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
|
||||||
)
|
|
||||||
|
|
||||||
# 不链接任何外部库
|
|
||||||
target_link_libraries(ha_remotedevice PRIVATE)
|
|
||||||
|
|
||||||
# 导出包含目录供外部项目使用
|
|
||||||
set(HA_REMOTEDEVICE_INCLUDE_DIR
|
|
||||||
${HA_REMOTEDEVICE_INCLUDE}
|
|
||||||
CACHE INTERNAL "ha_remotedevice include directories")
|
|
||||||
|
|
||||||
# 安装规则
|
|
||||||
install(TARGETS ha_remotedevice
|
|
||||||
EXPORT ha_remotedevice-targets
|
|
||||||
LIBRARY DESTINATION lib
|
|
||||||
ARCHIVE DESTINATION lib
|
|
||||||
RUNTIME DESTINATION bin
|
|
||||||
INCLUDES DESTINATION include
|
|
||||||
)
|
|
||||||
|
|
||||||
install(DIRECTORY include/
|
|
||||||
DESTINATION include
|
|
||||||
)
|
|
||||||
|
|
||||||
install(EXPORT ha_remotedevice-targets
|
|
||||||
DESTINATION lib/cmake/ha_remotedevice
|
|
||||||
NAMESPACE ha_remotedevice::
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 测试(可选)
|
|
||||||
# ============================================================
|
|
||||||
option(BUILD_TESTS "Build ha_remotedevice tests" OFF)
|
|
||||||
|
|
||||||
if(BUILD_TESTS)
|
|
||||||
find_package(Threads REQUIRED)
|
|
||||||
|
|
||||||
add_executable(ha_remotedevice_test
|
|
||||||
test/test_ha_remotedevice.c
|
|
||||||
)
|
|
||||||
target_link_libraries(ha_remotedevice_test
|
|
||||||
PRIVATE ha_remotedevice Threads::Threads
|
|
||||||
)
|
|
||||||
target_include_directories(ha_remotedevice_test
|
|
||||||
PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 添加测试
|
|
||||||
add_test(NAME ha_remotedevice_test
|
|
||||||
COMMAND ha_remotedevice_test
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 编译信息
|
|
||||||
# ============================================================
|
|
||||||
message(STATUS "ha_remotedevice ${PROJECT_VERSION}")
|
|
||||||
message(STATUS " Build type: $<CONFIG>")
|
|
||||||
message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}")
|
|
||||||
message(STATUS " No alloc: ${HA_NO_ALLOC}")
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
#ifndef HA_REMOTEDEVICE_H
|
|
||||||
#define HA_REMOTEDEVICE_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ==================================================================
|
|
||||||
* ha_remotedevice — 远程设备接入 C SDK
|
|
||||||
*
|
|
||||||
* 零外部依赖,纯 C 实现,兼容嵌入式平台。
|
|
||||||
* 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。
|
|
||||||
*
|
|
||||||
* 声明式设计:
|
|
||||||
* 设备在代码中声明自己是什么(kind)和能做什么(caps),
|
|
||||||
* 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数,
|
|
||||||
* SDK 自动处理协议握手、心跳、消息路由、结果回执。
|
|
||||||
*
|
|
||||||
* 协议流程:
|
|
||||||
* TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪
|
|
||||||
* 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果
|
|
||||||
* ================================================================== */
|
|
||||||
|
|
||||||
/* ======================== 状态码 ======================== */
|
|
||||||
typedef enum {
|
|
||||||
HA_OK = 0,
|
|
||||||
HA_ERR_GENERIC = -1,
|
|
||||||
HA_ERR_NOMEM = -2,
|
|
||||||
HA_ERR_INVALID = -3,
|
|
||||||
HA_ERR_TIMEOUT = -4,
|
|
||||||
HA_ERR_DISCONNECTED = -5,
|
|
||||||
HA_ERR_PROTOCOL = -6,
|
|
||||||
HA_ERR_TRANSPORT = -7,
|
|
||||||
HA_ERR_NOT_FOUND = -8,
|
|
||||||
} ha_status_t;
|
|
||||||
|
|
||||||
/* ======================== 传输层抽象 ========================
|
|
||||||
*
|
|
||||||
* 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。
|
|
||||||
*
|
|
||||||
* connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功
|
|
||||||
* send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败
|
|
||||||
* recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败
|
|
||||||
* close(ctx) → 关闭连接
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
int (*connect)(void *ctx, const char *host, uint16_t port);
|
|
||||||
int (*send)(void *ctx, const uint8_t *data, int len);
|
|
||||||
int (*recv)(void *ctx, uint8_t *buf, int len);
|
|
||||||
void (*close)(void *ctx);
|
|
||||||
void *ctx;
|
|
||||||
} ha_transport_t;
|
|
||||||
|
|
||||||
/* ======================== 设备声明 ========================
|
|
||||||
*
|
|
||||||
* 声明式配置:设备在代码中声明自己的类型和能力。
|
|
||||||
* 这些信息通过 hello 消息发送给网关。
|
|
||||||
*
|
|
||||||
* device_id — 唯一标识,如 "esp32-cam-1"
|
|
||||||
* name — 设备显示名,如 "门口摄像头"
|
|
||||||
* kind — 设备种类,如 "camera"、"computer"、"speaker"、"light"
|
|
||||||
* caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL}
|
|
||||||
* info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}'
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
const char *device_id;
|
|
||||||
const char *name;
|
|
||||||
const char *kind;
|
|
||||||
const char **caps; /* NULL 结尾 */
|
|
||||||
const char *info_json; /* 可选,NULL 或 JSON 字符串 */
|
|
||||||
} ha_device_info_t;
|
|
||||||
|
|
||||||
/* ======================== 命令结果 ========================
|
|
||||||
*
|
|
||||||
* 命令处理函数通过填写此结构体返回数据。
|
|
||||||
* SDK 收到结果后自动发送回执(文本或二进制分块)。
|
|
||||||
*
|
|
||||||
* 使用方式:
|
|
||||||
* 1. 简单文本:设置 status=0, output="结果文本"
|
|
||||||
* 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime
|
|
||||||
* 3. 错误:设置 status=1, error="错误信息"
|
|
||||||
*
|
|
||||||
* 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。
|
|
||||||
* 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。
|
|
||||||
* 所以 handler 可以返回栈上或静态字符串。
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
int status; /* 0=ok, 非0=error */
|
|
||||||
const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */
|
|
||||||
const char *error; /* 错误信息 */
|
|
||||||
int has_binary; /* 1=通过二进制分块回传 */
|
|
||||||
const char *binary_mime; /* 二进制 MIME 类型 */
|
|
||||||
const uint8_t *binary_data; /* 二进制数据指针 */
|
|
||||||
int binary_len; /* 二进制数据长度 */
|
|
||||||
} ha_cmd_result_t;
|
|
||||||
|
|
||||||
/* ======================== 命令处理声明 ========================
|
|
||||||
*
|
|
||||||
* 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。
|
|
||||||
*
|
|
||||||
* command 值说明:
|
|
||||||
* - "shell" → 处理 shell 类型命令,args 为完整命令字符串
|
|
||||||
* - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数
|
|
||||||
* - "screensee" → 处理 homeagent-screensee 命令
|
|
||||||
* - "speakeruse" → 处理 homeagent-speakeruse 命令
|
|
||||||
* - "computeruse" → 处理 homeagent-computeruse 命令
|
|
||||||
* - "clipboardsee" → 处理 homeagent-clipboardsee 命令
|
|
||||||
* - "clipboardsue" → 处理 homeagent-clipboardsue 命令
|
|
||||||
* - "screensue" → 处理 homeagent-screensue 命令
|
|
||||||
* - "deviceinfo" → 处理设备信息查询
|
|
||||||
* - 其他自定义命令名 → 按字符串匹配分发
|
|
||||||
*
|
|
||||||
* handler 处理完毕后只需填写 result 结构体,SDK 自动回执。
|
|
||||||
*/
|
|
||||||
typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args,
|
|
||||||
ha_cmd_result_t *result, void *userdata);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char *command; /* 命令名,如 "camerasue"、"shell" */
|
|
||||||
ha_cmd_handler_t handler; /* 处理函数 */
|
|
||||||
} ha_cmd_handler_def_t;
|
|
||||||
|
|
||||||
/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。
|
|
||||||
* data 指针在回调返回后失效,如需保存请拷贝。 */
|
|
||||||
typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind,
|
|
||||||
const char *mime, const uint8_t *data,
|
|
||||||
int len, void *userdata);
|
|
||||||
|
|
||||||
/* 连接状态变化回调 */
|
|
||||||
typedef void (*ha_state_callback_t)(int connected, void *userdata);
|
|
||||||
|
|
||||||
/* ======================== 客户端配置 ========================
|
|
||||||
*
|
|
||||||
* 所有配置在 ha_client_new() 时一次性声明。
|
|
||||||
* 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。
|
|
||||||
*/
|
|
||||||
typedef struct {
|
|
||||||
ha_transport_t transport; /* 传输层实现(必须) */
|
|
||||||
ha_device_info_t device; /* 设备声明(必须) */
|
|
||||||
const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */
|
|
||||||
const char *token; /* 接入令牌(必须) */
|
|
||||||
|
|
||||||
ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */
|
|
||||||
ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */
|
|
||||||
ha_state_callback_t on_state; /* 状态变化回调(可选) */
|
|
||||||
void *userdata; /* 用户自定义数据,传给所有回调 */
|
|
||||||
|
|
||||||
int ping_interval; /* 心跳间隔秒数,0 则默认 30 */
|
|
||||||
int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */
|
|
||||||
} ha_config_t;
|
|
||||||
|
|
||||||
/* ======================== 客户端 API ======================== */
|
|
||||||
|
|
||||||
typedef struct ha_client ha_client_t;
|
|
||||||
|
|
||||||
/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */
|
|
||||||
ha_client_t *ha_client_new(const ha_config_t *config);
|
|
||||||
|
|
||||||
/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */
|
|
||||||
ha_status_t ha_client_start(ha_client_t *client);
|
|
||||||
|
|
||||||
/* 主循环处理:必须在用户的主循环中周期性调用。
|
|
||||||
* - 读取 WS 帧并分发
|
|
||||||
* - 按 handlers 表查找命令处理函数,自动回执结果
|
|
||||||
* - 处理心跳 ping/pong
|
|
||||||
* - 处理断线重连
|
|
||||||
* 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */
|
|
||||||
ha_status_t ha_client_process(ha_client_t *client);
|
|
||||||
|
|
||||||
/* ===== 主动上报(设备主动推送,非命令响应) ===== */
|
|
||||||
|
|
||||||
/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */
|
|
||||||
void ha_client_send_event(ha_client_t *client, const char *type,
|
|
||||||
const char *detail);
|
|
||||||
|
|
||||||
/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */
|
|
||||||
void ha_client_send_status(ha_client_t *client, const char *status);
|
|
||||||
|
|
||||||
/* ===== 生命周期 ===== */
|
|
||||||
|
|
||||||
/* 停止客户端,断开连接。 */
|
|
||||||
void ha_client_stop(ha_client_t *client);
|
|
||||||
|
|
||||||
/* 销毁客户端,释放所有资源。 */
|
|
||||||
void ha_client_destroy(ha_client_t *client);
|
|
||||||
|
|
||||||
/* ======================== 工具函数 ======================== */
|
|
||||||
|
|
||||||
/* 解析 homeagent-* 命令,返回能力名和参数。
|
|
||||||
* command = "camerasue 5" → cap="camerasue", args="5"
|
|
||||||
* command = "screensee" → cap="screensee", args=""
|
|
||||||
* command = "computeruse {...}" → cap="computeruse", args="..." */
|
|
||||||
void ha_cmd_parse_homeagent(const char *command, const char **cap,
|
|
||||||
const char **args);
|
|
||||||
|
|
||||||
/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。
|
|
||||||
* command = "computeruse {\"action\":\"click\",\"x\":100}"
|
|
||||||
* → action="computeruse", json_str="{\"action\":\"click\",...}" */
|
|
||||||
void ha_cmd_parse_json(const char *command, const char **action,
|
|
||||||
const char **json_str);
|
|
||||||
|
|
||||||
/* Base64 编码(用于将二进制数据编码为文本回传)。
|
|
||||||
* 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */
|
|
||||||
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len);
|
|
||||||
|
|
||||||
/* 获取版本号 */
|
|
||||||
const char *ha_version(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_REMOTEDEVICE_H */
|
|
||||||
@ -1,369 +0,0 @@
|
|||||||
#include "ha_json.h"
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <ctype.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
/* ======================== 解析器 ======================== */
|
|
||||||
|
|
||||||
/* 前向声明 */
|
|
||||||
static ha_json_node_t *parse_value(const char **pp);
|
|
||||||
|
|
||||||
/* 跳过空白 */
|
|
||||||
static const char *skip_ws(const char *p) {
|
|
||||||
while (*p && (unsigned char)*p <= ' ') p++;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */
|
|
||||||
static char *parse_string(const char **pp) {
|
|
||||||
const char *p = skip_ws(*pp);
|
|
||||||
if (*p != '"') return NULL;
|
|
||||||
p++;
|
|
||||||
int len = 0;
|
|
||||||
const char *q = p;
|
|
||||||
while (*q && *q != '"') {
|
|
||||||
if (*q == '\\') { q++; if (*q) q++; }
|
|
||||||
else q++;
|
|
||||||
len++;
|
|
||||||
}
|
|
||||||
if (*q != '"') return NULL;
|
|
||||||
char *s = (char *)malloc(len + 1);
|
|
||||||
if (!s) return NULL;
|
|
||||||
q = p;
|
|
||||||
int i = 0;
|
|
||||||
while (*q && *q != '"') {
|
|
||||||
if (*q == '\\') {
|
|
||||||
q++;
|
|
||||||
switch (*q) {
|
|
||||||
case '"': s[i++] = '"'; break;
|
|
||||||
case '\\': s[i++] = '\\'; break;
|
|
||||||
case '/': s[i++] = '/'; break;
|
|
||||||
case 'b': s[i++] = '\b'; break;
|
|
||||||
case 'f': s[i++] = '\f'; break;
|
|
||||||
case 'n': s[i++] = '\n'; break;
|
|
||||||
case 'r': s[i++] = '\r'; break;
|
|
||||||
case 't': s[i++] = '\t'; break;
|
|
||||||
case 'u': q += 4; s[i++] = '?'; continue;
|
|
||||||
default: s[i++] = *q; break;
|
|
||||||
}
|
|
||||||
q++;
|
|
||||||
} else {
|
|
||||||
s[i++] = *q++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s[i] = '\0';
|
|
||||||
*pp = q + 1;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
static ha_json_node_t *new_node(ha_json_type_t type) {
|
|
||||||
ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t));
|
|
||||||
if (n) n->type = type;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解析数字 */
|
|
||||||
static ha_json_node_t *parse_number(const char **pp) {
|
|
||||||
const char *p = *pp;
|
|
||||||
int neg = 0;
|
|
||||||
if (*p == '-') { neg = 1; p++; }
|
|
||||||
if (!isdigit((unsigned char)*p)) return NULL;
|
|
||||||
int val = 0;
|
|
||||||
while (isdigit((unsigned char)*p)) {
|
|
||||||
val = val * 10 + (*p - '0');
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; }
|
|
||||||
if (*p == 'e' || *p == 'E') {
|
|
||||||
p++;
|
|
||||||
if (*p == '+' || *p == '-') p++;
|
|
||||||
while (isdigit((unsigned char)*p)) p++;
|
|
||||||
}
|
|
||||||
*pp = p;
|
|
||||||
ha_json_node_t *n = new_node(HA_JSON_INT);
|
|
||||||
if (n) n->int_val = neg ? -val : val;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解析 true/false/null */
|
|
||||||
static ha_json_node_t *parse_keyword(const char **pp) {
|
|
||||||
const char *p = *pp;
|
|
||||||
ha_json_node_t *n = NULL;
|
|
||||||
if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) {
|
|
||||||
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1;
|
|
||||||
*pp = p + 4;
|
|
||||||
} else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) {
|
|
||||||
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0;
|
|
||||||
*pp = p + 5;
|
|
||||||
} else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) {
|
|
||||||
n = new_node(HA_JSON_NULL);
|
|
||||||
*pp = p + 4;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解析对象 */
|
|
||||||
static ha_json_node_t *parse_object(const char **pp) {
|
|
||||||
const char *p = skip_ws(*pp);
|
|
||||||
if (*p != '{') return NULL;
|
|
||||||
p++;
|
|
||||||
ha_json_node_t *obj = new_node(HA_JSON_OBJECT);
|
|
||||||
if (!obj) return NULL;
|
|
||||||
ha_json_node_t **tail = &obj->child;
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p == '}') { *pp = p + 1; return obj; }
|
|
||||||
while (*p) {
|
|
||||||
p = skip_ws(p);
|
|
||||||
char *key = parse_string(&p);
|
|
||||||
if (!key) break;
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p != ':') { free(key); break; }
|
|
||||||
p++;
|
|
||||||
ha_json_node_t *val = parse_value(&p);
|
|
||||||
if (!val) { free(key); break; }
|
|
||||||
val->key = key;
|
|
||||||
*tail = val;
|
|
||||||
tail = &val->next;
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p == ',') { p++; continue; }
|
|
||||||
if (*p == '}') break;
|
|
||||||
}
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p == '}') { *pp = p + 1; return obj; }
|
|
||||||
ha_json_free(obj);
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解析数组 */
|
|
||||||
static ha_json_node_t *parse_array(const char **pp) {
|
|
||||||
const char *p = skip_ws(*pp);
|
|
||||||
if (*p != '[') return NULL;
|
|
||||||
p++;
|
|
||||||
ha_json_node_t *arr = new_node(HA_JSON_ARRAY);
|
|
||||||
if (!arr) return NULL;
|
|
||||||
ha_json_node_t **tail = &arr->child;
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p == ']') { *pp = p + 1; return arr; }
|
|
||||||
while (*p) {
|
|
||||||
ha_json_node_t *val = parse_value(&p);
|
|
||||||
if (!val) break;
|
|
||||||
*tail = val;
|
|
||||||
tail = &val->next;
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p == ',') { p++; continue; }
|
|
||||||
if (*p == ']') break;
|
|
||||||
}
|
|
||||||
p = skip_ws(p);
|
|
||||||
if (*p == ']') { *pp = p + 1; return arr; }
|
|
||||||
ha_json_free(arr);
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 解析值(主入口) */
|
|
||||||
static ha_json_node_t *parse_value(const char **pp) {
|
|
||||||
const char *p = skip_ws(*pp);
|
|
||||||
if (*p == '{') return parse_object(pp);
|
|
||||||
if (*p == '[') return parse_array(pp);
|
|
||||||
if (*p == '"') {
|
|
||||||
char *s = parse_string(pp);
|
|
||||||
if (!s) return NULL;
|
|
||||||
ha_json_node_t *n = new_node(HA_JSON_STRING);
|
|
||||||
if (!n) { free(s); return NULL; }
|
|
||||||
n->str_val = s;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp);
|
|
||||||
return parse_keyword(pp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 公共 API ======================== */
|
|
||||||
|
|
||||||
ha_json_node_t *ha_json_parse(const char *str) {
|
|
||||||
if (!str) return NULL;
|
|
||||||
const char *p = str;
|
|
||||||
return parse_value(&p);
|
|
||||||
}
|
|
||||||
|
|
||||||
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) {
|
|
||||||
ha_json_node_t *n = ha_json_get(obj, key);
|
|
||||||
if (!n || n->type != HA_JSON_STRING) return NULL;
|
|
||||||
return n->str_val;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) {
|
|
||||||
ha_json_node_t *n = ha_json_get(obj, key);
|
|
||||||
if (!n || n->type != HA_JSON_INT) return def;
|
|
||||||
return n->int_val;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) {
|
|
||||||
if (!obj || obj->type != HA_JSON_OBJECT) return NULL;
|
|
||||||
ha_json_node_t *c = obj->child;
|
|
||||||
while (c) {
|
|
||||||
if (c->key && strcmp(c->key, key) == 0) return c;
|
|
||||||
c = c->next;
|
|
||||||
}
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_array_len(const ha_json_node_t *arr) {
|
|
||||||
if (!arr || arr->type != HA_JSON_ARRAY) return 0;
|
|
||||||
int n = 0;
|
|
||||||
ha_json_node_t *c = arr->child;
|
|
||||||
while (c) { n++; c = c->next; }
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) {
|
|
||||||
if (!arr || arr->type != HA_JSON_ARRAY) return NULL;
|
|
||||||
ha_json_node_t *c = arr->child;
|
|
||||||
int i = 0;
|
|
||||||
while (c) {
|
|
||||||
if (i == index) return c;
|
|
||||||
i++; c = c->next;
|
|
||||||
}
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_free(ha_json_node_t *root) {
|
|
||||||
if (!root) return;
|
|
||||||
ha_json_node_t *c = root->child;
|
|
||||||
while (c) {
|
|
||||||
ha_json_node_t *next = c->next;
|
|
||||||
free(c->key);
|
|
||||||
if (c->type == HA_JSON_STRING) free(c->str_val);
|
|
||||||
ha_json_free(c);
|
|
||||||
c = next;
|
|
||||||
}
|
|
||||||
free(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 构建器 ======================== */
|
|
||||||
|
|
||||||
static void json_escape(ha_json_builder_t *jb, const char *s) {
|
|
||||||
if (!s) { ha_json_builder_raw(jb, "null"); return; }
|
|
||||||
ha_json_builder_raw(jb, "\"");
|
|
||||||
for (const char *p = s; *p; p++) {
|
|
||||||
unsigned char c = (unsigned char)*p;
|
|
||||||
switch (c) {
|
|
||||||
case '"': ha_json_builder_raw(jb, "\\\""); break;
|
|
||||||
case '\\': ha_json_builder_raw(jb, "\\\\"); break;
|
|
||||||
case '\b': ha_json_builder_raw(jb, "\\b"); break;
|
|
||||||
case '\f': ha_json_builder_raw(jb, "\\f"); break;
|
|
||||||
case '\n': ha_json_builder_raw(jb, "\\n"); break;
|
|
||||||
case '\r': ha_json_builder_raw(jb, "\\r"); break;
|
|
||||||
case '\t': ha_json_builder_raw(jb, "\\t"); break;
|
|
||||||
default:
|
|
||||||
if (c < 0x20) {
|
|
||||||
char buf[8];
|
|
||||||
snprintf(buf, sizeof(buf), "\\u%04x", c);
|
|
||||||
ha_json_builder_raw(jb, buf);
|
|
||||||
} else {
|
|
||||||
char buf[2] = { (char)c, 0 };
|
|
||||||
ha_json_builder_raw(jb, buf);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ha_json_builder_raw(jb, "\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) {
|
|
||||||
jb->buf = buf;
|
|
||||||
jb->len = 0;
|
|
||||||
jb->cap = cap;
|
|
||||||
jb->depth = 0;
|
|
||||||
if (cap > 0) buf[0] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_reset(ha_json_builder_t *jb) {
|
|
||||||
jb->len = 0;
|
|
||||||
jb->depth = 0;
|
|
||||||
if (jb->cap > 0) jb->buf[0] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) {
|
|
||||||
while (*s && jb->len < jb->cap - 1) {
|
|
||||||
jb->buf[jb->len++] = *s++;
|
|
||||||
}
|
|
||||||
jb->buf[jb->len] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_comma(ha_json_builder_t *jb) {
|
|
||||||
if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) {
|
|
||||||
ha_json_builder_raw(jb, ",");
|
|
||||||
}
|
|
||||||
if (jb->depth > 0) jb->item_count[jb->depth - 1]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_begin_object(ha_json_builder_t *jb) {
|
|
||||||
ha_json_builder_comma(jb);
|
|
||||||
ha_json_builder_raw(jb, "{");
|
|
||||||
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
|
|
||||||
jb->depth++;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_end_object(ha_json_builder_t *jb) {
|
|
||||||
jb->depth--;
|
|
||||||
ha_json_builder_raw(jb, "}");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_begin_array(ha_json_builder_t *jb) {
|
|
||||||
ha_json_builder_comma(jb);
|
|
||||||
ha_json_builder_raw(jb, "[");
|
|
||||||
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
|
|
||||||
jb->depth++;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_end_array(ha_json_builder_t *jb) {
|
|
||||||
jb->depth--;
|
|
||||||
ha_json_builder_raw(jb, "]");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_key(ha_json_builder_t *jb, const char *key) {
|
|
||||||
ha_json_builder_comma(jb);
|
|
||||||
json_escape(jb, key);
|
|
||||||
ha_json_builder_raw(jb, ":");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) {
|
|
||||||
json_escape(jb, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_add_int(ha_json_builder_t *jb, int val) {
|
|
||||||
char buf[16];
|
|
||||||
snprintf(buf, sizeof(buf), "%d", val);
|
|
||||||
ha_json_builder_raw(jb, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) {
|
|
||||||
ha_json_builder_raw(jb, val ? "true" : "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_add_null(ha_json_builder_t *jb) {
|
|
||||||
ha_json_builder_raw(jb, "null");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) {
|
|
||||||
ha_json_builder_key(jb, key);
|
|
||||||
json_escape(jb, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) {
|
|
||||||
ha_json_builder_key(jb, key);
|
|
||||||
ha_json_builder_add_int(jb, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) {
|
|
||||||
ha_json_builder_key(jb, key);
|
|
||||||
ha_json_builder_add_bool(jb, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
const char *ha_json_builder_str(ha_json_builder_t *jb) {
|
|
||||||
return jb->buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_json_builder_len(ha_json_builder_t *jb) {
|
|
||||||
return jb->len;
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#ifndef HA_JSON_H
|
|
||||||
#define HA_JSON_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ======================== JSON 解析器(DOM 风格) ======================== */
|
|
||||||
typedef enum {
|
|
||||||
HA_JSON_NULL,
|
|
||||||
HA_JSON_BOOL,
|
|
||||||
HA_JSON_INT,
|
|
||||||
HA_JSON_STRING,
|
|
||||||
HA_JSON_ARRAY,
|
|
||||||
HA_JSON_OBJECT,
|
|
||||||
} ha_json_type_t;
|
|
||||||
|
|
||||||
typedef struct ha_json_node {
|
|
||||||
ha_json_type_t type;
|
|
||||||
union {
|
|
||||||
int bool_val;
|
|
||||||
int int_val;
|
|
||||||
char *str_val;
|
|
||||||
};
|
|
||||||
struct ha_json_node *next; /* linked list for array/object items */
|
|
||||||
struct ha_json_node *child; /* first child for array/object */
|
|
||||||
char *key; /* key for object members */
|
|
||||||
} ha_json_node_t;
|
|
||||||
|
|
||||||
/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */
|
|
||||||
ha_json_node_t *ha_json_parse(const char *str);
|
|
||||||
|
|
||||||
/* 从对象中按 key 获取字符串值,不存在返回 NULL */
|
|
||||||
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key);
|
|
||||||
|
|
||||||
/* 从对象中按 key 获取 int 值,不存在返回 def */
|
|
||||||
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def);
|
|
||||||
|
|
||||||
/* 从对象中按 key 获取子节点,不存在返回 NULL */
|
|
||||||
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key);
|
|
||||||
|
|
||||||
/* 获取数组长度 */
|
|
||||||
int ha_json_array_len(const ha_json_node_t *arr);
|
|
||||||
|
|
||||||
/* 获取数组第 index 个元素,越界返回 NULL */
|
|
||||||
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index);
|
|
||||||
|
|
||||||
/* 释放整个 JSON 树 */
|
|
||||||
void ha_json_free(ha_json_node_t *root);
|
|
||||||
|
|
||||||
/* ======================== JSON 构建器(直接写缓冲区) ======================== */
|
|
||||||
typedef struct {
|
|
||||||
char *buf;
|
|
||||||
int len;
|
|
||||||
int cap;
|
|
||||||
int depth;
|
|
||||||
int item_count[16]; /* 每层已添加元素数,用于逗号判断 */
|
|
||||||
} ha_json_builder_t;
|
|
||||||
|
|
||||||
/* 初始化构建器 */
|
|
||||||
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap);
|
|
||||||
|
|
||||||
/* 清空构建器 */
|
|
||||||
void ha_json_builder_reset(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
/* 基础写入 */
|
|
||||||
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s);
|
|
||||||
|
|
||||||
/* 逗号(自动判断是否需要加) */
|
|
||||||
void ha_json_builder_comma(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
/* 对象 */
|
|
||||||
void ha_json_builder_begin_object(ha_json_builder_t *jb);
|
|
||||||
void ha_json_builder_end_object(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
/* 数组 */
|
|
||||||
void ha_json_builder_begin_array(ha_json_builder_t *jb);
|
|
||||||
void ha_json_builder_end_array(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
/* 键名 */
|
|
||||||
void ha_json_builder_key(ha_json_builder_t *jb, const char *key);
|
|
||||||
|
|
||||||
/* 值 */
|
|
||||||
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val);
|
|
||||||
void ha_json_builder_add_int(ha_json_builder_t *jb, int val);
|
|
||||||
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val);
|
|
||||||
void ha_json_builder_add_null(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
/* 快捷方法:直接写 "key":"val" */
|
|
||||||
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val);
|
|
||||||
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val);
|
|
||||||
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val);
|
|
||||||
|
|
||||||
/* 获取当前构建的字符串指针 */
|
|
||||||
const char *ha_json_builder_str(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
/* 获取当前长度 */
|
|
||||||
int ha_json_builder_len(ha_json_builder_t *jb);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_JSON_H */
|
|
||||||
@ -1,628 +0,0 @@
|
|||||||
#include "ha_remotedevice.h"
|
|
||||||
#include "ha_json.h"
|
|
||||||
#include "ha_ws.h"
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
#define HA_VERSION "0.1.0"
|
|
||||||
|
|
||||||
/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */
|
|
||||||
void ha_client_send_result(ha_client_t *client, const char *req_id,
|
|
||||||
const char *status, const char *output,
|
|
||||||
const char *error);
|
|
||||||
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
|
|
||||||
const char *kind, const char *mime,
|
|
||||||
const uint8_t *data, int len);
|
|
||||||
|
|
||||||
/* ======================== 内部状态 ======================== */
|
|
||||||
typedef enum {
|
|
||||||
HA_STATE_INIT,
|
|
||||||
HA_STATE_DISCONNECTED,
|
|
||||||
HA_STATE_CONNECTING,
|
|
||||||
HA_STATE_WS_UPGRADING,
|
|
||||||
HA_STATE_HELLO_SENT,
|
|
||||||
HA_STATE_BIND_SENT,
|
|
||||||
HA_STATE_READY,
|
|
||||||
HA_STATE_STOPPING,
|
|
||||||
} ha_state_t;
|
|
||||||
|
|
||||||
/* 语音数据聚合缓冲区 */
|
|
||||||
typedef struct {
|
|
||||||
char req_id[128];
|
|
||||||
char kind[64];
|
|
||||||
char mime[64];
|
|
||||||
int total;
|
|
||||||
uint8_t *data;
|
|
||||||
int len;
|
|
||||||
int cap;
|
|
||||||
} ha_speech_accum_t;
|
|
||||||
|
|
||||||
struct ha_client {
|
|
||||||
ha_config_t config; /* 拷贝的配置 */
|
|
||||||
ha_state_t state;
|
|
||||||
int reconnect_cnt; /* 当前重连次数 */
|
|
||||||
ha_ws_t ws; /* WS 连接 */
|
|
||||||
|
|
||||||
/* JSON 构建缓冲区 */
|
|
||||||
char json_buf[4096];
|
|
||||||
ha_json_builder_t jb;
|
|
||||||
|
|
||||||
/* 语音数据聚合 */
|
|
||||||
ha_speech_accum_t speech;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ======================== 辅助函数 ======================== */
|
|
||||||
|
|
||||||
static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; }
|
|
||||||
|
|
||||||
/* Base64 编码表 */
|
|
||||||
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
|
|
||||||
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) {
|
|
||||||
int needed = ((len + 2) / 3) * 4 + 1;
|
|
||||||
if (out_len < needed) {
|
|
||||||
if (out_len > 0) out[0] = '\0';
|
|
||||||
return needed;
|
|
||||||
}
|
|
||||||
int i = 0, j = 0;
|
|
||||||
while (i < len) {
|
|
||||||
int rem = len - i;
|
|
||||||
uint8_t b0 = data[i++];
|
|
||||||
uint8_t b1 = (rem > 1) ? data[i++] : 0;
|
|
||||||
uint8_t b2 = (rem > 2) ? data[i++] : 0;
|
|
||||||
out[j++] = b64[b0 >> 2];
|
|
||||||
out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)];
|
|
||||||
out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
|
|
||||||
out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '=';
|
|
||||||
}
|
|
||||||
out[j] = '\0';
|
|
||||||
return j;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== JSON 构建辅助 ======================== */
|
|
||||||
static void json_init(ha_client_t *c) {
|
|
||||||
ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== WS 发送 JSON ======================== */
|
|
||||||
static int ws_send_json(ha_client_t *c) {
|
|
||||||
return ha_ws_send_text(&c->ws, c->json_buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 协议消息构造 ======================== */
|
|
||||||
|
|
||||||
/* 构建 hello 消息 */
|
|
||||||
static int send_hello(ha_client_t *c) {
|
|
||||||
json_init(c);
|
|
||||||
ha_json_builder_begin_object(&c->jb);
|
|
||||||
ha_json_builder_string(&c->jb, "op", "hello");
|
|
||||||
ha_json_builder_key(&c->jb, "device");
|
|
||||||
ha_json_builder_begin_object(&c->jb);
|
|
||||||
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
|
|
||||||
ha_json_builder_string(&c->jb, "name", c->config.device.name);
|
|
||||||
ha_json_builder_string(&c->jb, "kind", c->config.device.kind);
|
|
||||||
/* caps */
|
|
||||||
ha_json_builder_key(&c->jb, "caps");
|
|
||||||
ha_json_builder_begin_array(&c->jb);
|
|
||||||
if (c->config.device.caps) {
|
|
||||||
for (const char **p = c->config.device.caps; *p; p++) {
|
|
||||||
ha_json_builder_add_string(&c->jb, *p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ha_json_builder_end_array(&c->jb);
|
|
||||||
/* info 可选 */
|
|
||||||
if (c->config.device.info_json && c->config.device.info_json[0]) {
|
|
||||||
ha_json_builder_string(&c->jb, "info", c->config.device.info_json);
|
|
||||||
}
|
|
||||||
ha_json_builder_end_object(&c->jb); /* device */
|
|
||||||
ha_json_builder_end_object(&c->jb); /* root */
|
|
||||||
return ws_send_json(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 构建 bind 消息 */
|
|
||||||
static int send_bind(ha_client_t *c) {
|
|
||||||
json_init(c);
|
|
||||||
ha_json_builder_begin_object(&c->jb);
|
|
||||||
ha_json_builder_string(&c->jb, "op", "bind");
|
|
||||||
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
|
|
||||||
ha_json_builder_string(&c->jb, "token", c->config.token);
|
|
||||||
ha_json_builder_end_object(&c->jb);
|
|
||||||
return ws_send_json(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 消息处理 ======================== */
|
|
||||||
|
|
||||||
/* 在 handlers 表中查找命令处理函数 */
|
|
||||||
static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) {
|
|
||||||
if (!name || !c->config.handlers) return NULL;
|
|
||||||
for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) {
|
|
||||||
if (strcmp(h->command, name) == 0) return h;
|
|
||||||
}
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */
|
|
||||||
static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) {
|
|
||||||
const char *req_id = ha_json_get_string(msg, "req_id");
|
|
||||||
const char *command = ha_json_get_string(msg, "command");
|
|
||||||
const char *cmd_type = ha_json_get_string(msg, "cmd_type");
|
|
||||||
if (!req_id || !command) return;
|
|
||||||
if (!cmd_type) cmd_type = "homeagent";
|
|
||||||
|
|
||||||
const char *handler_name = NULL;
|
|
||||||
const char *args = command;
|
|
||||||
|
|
||||||
if (strcmp(cmd_type, "shell") == 0) {
|
|
||||||
handler_name = "shell";
|
|
||||||
/* args 保持为完整命令字符串 */
|
|
||||||
} else {
|
|
||||||
/* homeagent-* 命令:提取能力名作为 handler 名 */
|
|
||||||
const char *cap = command;
|
|
||||||
const char *p = command;
|
|
||||||
if (strncmp(p, "homeagent-", 10) == 0) p += 10;
|
|
||||||
const char *space = strchr(p, ' ');
|
|
||||||
if (space) {
|
|
||||||
args = space + 1;
|
|
||||||
/* handler_name 用静态缓冲区 */
|
|
||||||
static char name_buf[128];
|
|
||||||
int n = (int)(space - p);
|
|
||||||
if (n > 127) n = 127;
|
|
||||||
strncpy(name_buf, p, n);
|
|
||||||
name_buf[n] = '\0';
|
|
||||||
handler_name = name_buf;
|
|
||||||
} else {
|
|
||||||
handler_name = p;
|
|
||||||
args = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_cmd_handler_def_t *def = find_handler(c, handler_name);
|
|
||||||
if (!def) {
|
|
||||||
ha_client_send_result(c, req_id, "error", NULL,
|
|
||||||
"unsupported command");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 调用 handler,填写 result */
|
|
||||||
ha_cmd_result_t result;
|
|
||||||
memset(&result, 0, sizeof(result));
|
|
||||||
ha_status_t st = def->handler(req_id, args, &result, c->config.userdata);
|
|
||||||
|
|
||||||
/* 自动回执 */
|
|
||||||
if (st != HA_OK) {
|
|
||||||
ha_client_send_result(c, req_id, "error", NULL,
|
|
||||||
result.error ? result.error : "handler failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.has_binary && result.binary_data && result.binary_len > 0) {
|
|
||||||
/* 二进制分块回传 */
|
|
||||||
ha_client_send_data_chunked(c, req_id,
|
|
||||||
handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream",
|
|
||||||
result.binary_data, result.binary_len);
|
|
||||||
} else {
|
|
||||||
/* 文本回传 */
|
|
||||||
ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error",
|
|
||||||
result.output, result.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) {
|
|
||||||
const char *req_id = ha_json_get_string(msg, "req_id");
|
|
||||||
const char *kind = ha_json_get_string(msg, "kind");
|
|
||||||
const char *mime = ha_json_get_string(msg, "mime");
|
|
||||||
if (!req_id) return;
|
|
||||||
|
|
||||||
/* 释放旧的聚合数据 */
|
|
||||||
free(c->speech.data);
|
|
||||||
memset(&c->speech, 0, sizeof(c->speech));
|
|
||||||
|
|
||||||
strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1);
|
|
||||||
if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1);
|
|
||||||
if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1);
|
|
||||||
c->speech.total = ha_json_get_int(msg, "total", 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) {
|
|
||||||
const char *req_id = ha_json_get_string(msg, "req_id");
|
|
||||||
if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return;
|
|
||||||
|
|
||||||
if (c->config.on_binary && c->speech.data && c->speech.len > 0) {
|
|
||||||
c->config.on_binary(c->speech.req_id, c->speech.kind,
|
|
||||||
c->speech.mime, c->speech.data,
|
|
||||||
c->speech.len, c->config.userdata);
|
|
||||||
}
|
|
||||||
|
|
||||||
free(c->speech.data);
|
|
||||||
memset(&c->speech, 0, sizeof(c->speech));
|
|
||||||
}
|
|
||||||
|
|
||||||
static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) {
|
|
||||||
/* 解析 JSON */
|
|
||||||
char *tmp = (char *)malloc(len + 1);
|
|
||||||
if (!tmp) return;
|
|
||||||
memcpy(tmp, payload, len);
|
|
||||||
tmp[len] = '\0';
|
|
||||||
|
|
||||||
ha_json_node_t *root = ha_json_parse(tmp);
|
|
||||||
if (!root) { free(tmp); return; }
|
|
||||||
|
|
||||||
const char *op = ha_json_get_string(root, "op");
|
|
||||||
if (!op) { ha_json_free(root); free(tmp); return; }
|
|
||||||
|
|
||||||
switch (c->state) {
|
|
||||||
case HA_STATE_HELLO_SENT:
|
|
||||||
if (strcmp(op, "hello_ack") == 0) {
|
|
||||||
c->state = HA_STATE_BIND_SENT;
|
|
||||||
send_bind(c);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case HA_STATE_BIND_SENT:
|
|
||||||
if (strcmp(op, "bind_ack") == 0) {
|
|
||||||
c->state = HA_STATE_READY;
|
|
||||||
if (c->config.on_state) {
|
|
||||||
c->config.on_state(1, c->config.userdata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case HA_STATE_READY:
|
|
||||||
if (strcmp(op, "cmd") == 0) {
|
|
||||||
handle_cmd_msg(c, root);
|
|
||||||
} else if (strcmp(op, "cmd_speech_start") == 0) {
|
|
||||||
handle_speech_start(c, root);
|
|
||||||
} else if (strcmp(op, "cmd_speech_end") == 0) {
|
|
||||||
handle_speech_end(c, root);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_json_free(root);
|
|
||||||
free(tmp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 连接管理 ======================== */
|
|
||||||
|
|
||||||
static int do_connect(ha_client_t *c) {
|
|
||||||
c->state = HA_STATE_CONNECTING;
|
|
||||||
c->reconnect_cnt++;
|
|
||||||
|
|
||||||
/* 解析 server 地址 */
|
|
||||||
char host[256] = {0};
|
|
||||||
uint16_t port = 9890;
|
|
||||||
const char *p = c->config.server;
|
|
||||||
if (!p) return -1;
|
|
||||||
|
|
||||||
/* 去掉 ws:// 前缀 */
|
|
||||||
if (strncmp(p, "ws://", 5) == 0) p += 5;
|
|
||||||
else if (strncmp(p, "wss://", 6) == 0) p += 6;
|
|
||||||
|
|
||||||
/* 提取 host:port */
|
|
||||||
const char *colon = strchr(p, ':');
|
|
||||||
const char *slash = strchr(p, '/');
|
|
||||||
if (colon && (!slash || colon < slash)) {
|
|
||||||
int host_len = (int)(colon - p);
|
|
||||||
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
|
|
||||||
memcpy(host, p, host_len);
|
|
||||||
host[host_len] = '\0';
|
|
||||||
port = (uint16_t)atoi(colon + 1);
|
|
||||||
} else {
|
|
||||||
int host_len = (slash ? (int)(slash - p) : (int)strlen(p));
|
|
||||||
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
|
|
||||||
memcpy(host, p, host_len);
|
|
||||||
host[host_len] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
c->state = HA_STATE_WS_UPGRADING;
|
|
||||||
if (ha_ws_connect(&c->ws, &c->config.transport, host, port,
|
|
||||||
"/api/v1/device/ws", c->config.token) != 0) {
|
|
||||||
c->state = HA_STATE_DISCONNECTED;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 发送 hello */
|
|
||||||
c->state = HA_STATE_HELLO_SENT;
|
|
||||||
if (send_hello(c) != 0) {
|
|
||||||
ha_ws_close(&c->ws);
|
|
||||||
c->state = HA_STATE_DISCONNECTED;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 公共 API ======================== */
|
|
||||||
|
|
||||||
ha_client_t *ha_client_new(const ha_config_t *config) {
|
|
||||||
ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t));
|
|
||||||
if (!c) return NULL;
|
|
||||||
memcpy(&c->config, config, sizeof(ha_config_t));
|
|
||||||
c->state = HA_STATE_INIT;
|
|
||||||
c->reconnect_cnt = 0;
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_status_t ha_client_start(ha_client_t *client) {
|
|
||||||
if (!client) return HA_ERR_INVALID;
|
|
||||||
if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC;
|
|
||||||
|
|
||||||
/* 默认心跳间隔 30 秒 */
|
|
||||||
if (client->config.ping_interval <= 0) {
|
|
||||||
client->config.ping_interval = 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (do_connect(client) != 0) {
|
|
||||||
return HA_ERR_TRANSPORT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 等待 bind_ack(最多 5 秒) */
|
|
||||||
int wait_ms = 5000;
|
|
||||||
int step = 50;
|
|
||||||
while (wait_ms > 0 && client->state != HA_STATE_READY) {
|
|
||||||
/* 处理一帧 */
|
|
||||||
ha_status_t st = ha_client_process(client);
|
|
||||||
if (st != HA_OK && st != HA_ERR_DISCONNECTED) {
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
if (client->state == HA_STATE_READY) return HA_OK;
|
|
||||||
|
|
||||||
/* 简单延时:靠 process 中的 recv 阻塞 */
|
|
||||||
wait_ms -= step;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha_status_t ha_client_process(ha_client_t *client) {
|
|
||||||
if (!client) return HA_ERR_INVALID;
|
|
||||||
|
|
||||||
if (client->state == HA_STATE_STOPPING) {
|
|
||||||
return HA_ERR_DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 断线重连 */
|
|
||||||
if (client->state == HA_STATE_DISCONNECTED ||
|
|
||||||
client->state == HA_STATE_INIT) {
|
|
||||||
if (client->config.max_reconnect >= 0 &&
|
|
||||||
client->reconnect_cnt > client->config.max_reconnect) {
|
|
||||||
return HA_ERR_DISCONNECTED;
|
|
||||||
}
|
|
||||||
/* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */
|
|
||||||
return HA_ERR_DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client->ws.connected) {
|
|
||||||
client->state = HA_STATE_DISCONNECTED;
|
|
||||||
if (client->config.on_state) {
|
|
||||||
client->config.on_state(0, client->config.userdata);
|
|
||||||
}
|
|
||||||
return HA_ERR_DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 尝试读取一帧 */
|
|
||||||
const uint8_t *payload = NULL;
|
|
||||||
int len = 0;
|
|
||||||
int ret = ha_ws_read_frame(&client->ws, &payload, &len);
|
|
||||||
|
|
||||||
if (ret < 0) {
|
|
||||||
/* 连接断开 */
|
|
||||||
client->state = HA_STATE_DISCONNECTED;
|
|
||||||
if (client->config.on_state) {
|
|
||||||
client->config.on_state(0, client->config.userdata);
|
|
||||||
}
|
|
||||||
return HA_ERR_DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (ret) {
|
|
||||||
case WS_OPCODE_TEXT:
|
|
||||||
handle_text_message(client, payload, len);
|
|
||||||
break;
|
|
||||||
case WS_OPCODE_BINARY:
|
|
||||||
/* 二进制帧:如果处于语音聚合状态,追加数据 */
|
|
||||||
if (client->speech.req_id[0] && payload) {
|
|
||||||
int new_len = client->speech.len + len;
|
|
||||||
if (new_len > client->speech.cap) {
|
|
||||||
int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096;
|
|
||||||
while (new_cap < new_len) new_cap *= 2;
|
|
||||||
uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap);
|
|
||||||
if (!nd) break;
|
|
||||||
client->speech.data = nd;
|
|
||||||
client->speech.cap = new_cap;
|
|
||||||
}
|
|
||||||
memcpy(client->speech.data + client->speech.len, payload, len);
|
|
||||||
client->speech.len = new_len;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case WS_OPCODE_PING:
|
|
||||||
/* 回复 pong */
|
|
||||||
ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0);
|
|
||||||
break;
|
|
||||||
case WS_OPCODE_PONG:
|
|
||||||
/* 收到 pong,忽略 */
|
|
||||||
break;
|
|
||||||
case WS_OPCODE_CLOSE:
|
|
||||||
client->state = HA_STATE_DISCONNECTED;
|
|
||||||
if (client->config.on_state) {
|
|
||||||
client->config.on_state(0, client->config.userdata);
|
|
||||||
}
|
|
||||||
return HA_ERR_DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return HA_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_client_send_result(ha_client_t *client, const char *req_id,
|
|
||||||
const char *status, const char *output,
|
|
||||||
const char *error) {
|
|
||||||
if (!client || client->state != HA_STATE_READY) return;
|
|
||||||
json_init(client);
|
|
||||||
ha_json_builder_begin_object(&client->jb);
|
|
||||||
ha_json_builder_string(&client->jb, "op", "cmd_result");
|
|
||||||
ha_json_builder_string(&client->jb, "req_id", req_id);
|
|
||||||
ha_json_builder_string(&client->jb, "status", status ? status : "ok");
|
|
||||||
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
|
||||||
if (output && output[0]) {
|
|
||||||
ha_json_builder_string(&client->jb, "output", output);
|
|
||||||
}
|
|
||||||
if (error && error[0]) {
|
|
||||||
ha_json_builder_string(&client->jb, "error", error);
|
|
||||||
}
|
|
||||||
ha_json_builder_end_object(&client->jb);
|
|
||||||
ws_send_json(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
|
|
||||||
const char *kind, const char *mime,
|
|
||||||
const uint8_t *data, int len) {
|
|
||||||
if (!client || client->state != HA_STATE_READY) return;
|
|
||||||
|
|
||||||
/* cmd_data_start */
|
|
||||||
json_init(client);
|
|
||||||
ha_json_builder_begin_object(&client->jb);
|
|
||||||
ha_json_builder_string(&client->jb, "op", "cmd_data_start");
|
|
||||||
ha_json_builder_string(&client->jb, "req_id", req_id);
|
|
||||||
ha_json_builder_string(&client->jb, "kind", kind ? kind : "data");
|
|
||||||
ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream");
|
|
||||||
ha_json_builder_int(&client->jb, "total", len);
|
|
||||||
ha_json_builder_int(&client->jb, "chunk_size", 8192);
|
|
||||||
ha_json_builder_end_object(&client->jb);
|
|
||||||
ws_send_json(client);
|
|
||||||
|
|
||||||
/* 二进制帧分块发送 */
|
|
||||||
int off = 0;
|
|
||||||
while (off < len) {
|
|
||||||
int chunk = len - off;
|
|
||||||
if (chunk > 8192) chunk = 8192;
|
|
||||||
if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return;
|
|
||||||
off += chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* cmd_data_end */
|
|
||||||
json_init(client);
|
|
||||||
ha_json_builder_begin_object(&client->jb);
|
|
||||||
ha_json_builder_string(&client->jb, "op", "cmd_data_end");
|
|
||||||
ha_json_builder_string(&client->jb, "req_id", req_id);
|
|
||||||
ha_json_builder_string(&client->jb, "status", "ok");
|
|
||||||
ha_json_builder_int(&client->jb, "total", len);
|
|
||||||
ha_json_builder_end_object(&client->jb);
|
|
||||||
ws_send_json(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_client_send_event(ha_client_t *client, const char *type,
|
|
||||||
const char *detail) {
|
|
||||||
if (!client || client->state != HA_STATE_READY) return;
|
|
||||||
json_init(client);
|
|
||||||
ha_json_builder_begin_object(&client->jb);
|
|
||||||
ha_json_builder_string(&client->jb, "op", "event");
|
|
||||||
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
|
||||||
ha_json_builder_string(&client->jb, "type", type ? type : "");
|
|
||||||
if (detail && detail[0]) {
|
|
||||||
ha_json_builder_string(&client->jb, "payload", detail);
|
|
||||||
}
|
|
||||||
ha_json_builder_end_object(&client->jb);
|
|
||||||
ws_send_json(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_client_send_status(ha_client_t *client, const char *status) {
|
|
||||||
if (!client || client->state != HA_STATE_READY) return;
|
|
||||||
json_init(client);
|
|
||||||
ha_json_builder_begin_object(&client->jb);
|
|
||||||
ha_json_builder_string(&client->jb, "op", "status");
|
|
||||||
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
|
||||||
ha_json_builder_string(&client->jb, "status", status ? status : "online");
|
|
||||||
ha_json_builder_end_object(&client->jb);
|
|
||||||
ws_send_json(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_client_stop(ha_client_t *client) {
|
|
||||||
if (!client) return;
|
|
||||||
client->state = HA_STATE_STOPPING;
|
|
||||||
if (client->ws.connected) {
|
|
||||||
ha_ws_close(&client->ws);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_client_destroy(ha_client_t *client) {
|
|
||||||
if (!client) return;
|
|
||||||
ha_client_stop(client);
|
|
||||||
free(client->speech.data);
|
|
||||||
free(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 工具函数 ======================== */
|
|
||||||
|
|
||||||
void ha_cmd_parse_homeagent(const char *command, const char **cap,
|
|
||||||
const char **args) {
|
|
||||||
*cap = command;
|
|
||||||
*args = "";
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
*cap = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 去掉 homeagent- 前缀 */
|
|
||||||
const char *p = command;
|
|
||||||
if (strncmp(p, "homeagent-", 10) == 0) {
|
|
||||||
p += 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按空格分割 */
|
|
||||||
const char *space = strchr(p, ' ');
|
|
||||||
if (space) {
|
|
||||||
/* cap 指向 p 但不包含空格,需要临时拷贝 */
|
|
||||||
/* 返回指针到原始字符串,调用方用 strncpy 取出 */
|
|
||||||
*cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */
|
|
||||||
/* 实际上,最简单的方式是原地修改,但 const 不允许 */
|
|
||||||
/* 用静态缓冲区或让调用方自己处理 */
|
|
||||||
static char cap_buf[256];
|
|
||||||
int n = (int)(space - p);
|
|
||||||
if (n > 255) n = 255;
|
|
||||||
strncpy(cap_buf, p, n);
|
|
||||||
cap_buf[n] = '\0';
|
|
||||||
*cap = cap_buf;
|
|
||||||
*args = space + 1;
|
|
||||||
} else {
|
|
||||||
static char cap_buf[256];
|
|
||||||
strncpy(cap_buf, p, sizeof(cap_buf) - 1);
|
|
||||||
cap_buf[sizeof(cap_buf) - 1] = '\0';
|
|
||||||
*cap = cap_buf;
|
|
||||||
*args = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_cmd_parse_json(const char *command, const char **action,
|
|
||||||
const char **json_str) {
|
|
||||||
*action = "";
|
|
||||||
*json_str = "";
|
|
||||||
|
|
||||||
if (!command) return;
|
|
||||||
|
|
||||||
const char *p = command;
|
|
||||||
if (strncmp(p, "homeagent-", 10) == 0) {
|
|
||||||
p += 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
const char *brace = strchr(p, '{');
|
|
||||||
if (brace) {
|
|
||||||
static char act_buf[256];
|
|
||||||
int n = (int)(brace - p);
|
|
||||||
while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--;
|
|
||||||
if (n > 255) n = 255;
|
|
||||||
strncpy(act_buf, p, n);
|
|
||||||
act_buf[n] = '\0';
|
|
||||||
*action = act_buf;
|
|
||||||
*json_str = brace;
|
|
||||||
} else {
|
|
||||||
static char act_buf[256];
|
|
||||||
strncpy(act_buf, p, sizeof(act_buf) - 1);
|
|
||||||
*action = act_buf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const char *ha_version(void) {
|
|
||||||
return HA_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,325 +0,0 @@
|
|||||||
#include "ha_ws.h"
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
|
|
||||||
/* WS GUID 用于计算 Accept 值 */
|
|
||||||
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
||||||
|
|
||||||
/* ======================== Base64 编码(用于 WS key) ======================== */
|
|
||||||
static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
|
|
||||||
static void base64_encode_bin(const uint8_t *in, int in_len, char *out) {
|
|
||||||
int i = 0, j = 0;
|
|
||||||
uint8_t b[3];
|
|
||||||
while (i < in_len) {
|
|
||||||
int rem = in_len - i;
|
|
||||||
if (rem >= 3) {
|
|
||||||
b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++];
|
|
||||||
out[j++] = b64t[b[0] >> 2];
|
|
||||||
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
|
|
||||||
out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)];
|
|
||||||
out[j++] = b64t[b[2] & 0x3F];
|
|
||||||
} else if (rem == 2) {
|
|
||||||
b[0] = in[i++]; b[1] = in[i++];
|
|
||||||
out[j++] = b64t[b[0] >> 2];
|
|
||||||
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
|
|
||||||
out[j++] = b64t[(b[1] & 0x0F) << 2];
|
|
||||||
out[j++] = '=';
|
|
||||||
} else {
|
|
||||||
b[0] = in[i++];
|
|
||||||
out[j++] = b64t[b[0] >> 2];
|
|
||||||
out[j++] = b64t[(b[0] & 0x03) << 4];
|
|
||||||
out[j++] = '=';
|
|
||||||
out[j++] = '=';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[j] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 简单伪随机数生成器 */
|
|
||||||
static uint32_t ws_rand_state = 0;
|
|
||||||
static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; }
|
|
||||||
static uint32_t ws_rand(void) {
|
|
||||||
ws_rand_state = ws_rand_state * 1103515245 + 12345;
|
|
||||||
return ws_rand_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 生成 WS 握手 key */
|
|
||||||
static void ws_gen_key(char *out) {
|
|
||||||
uint8_t buf[16];
|
|
||||||
for (int i = 0; i < 16; i++) {
|
|
||||||
buf[i] = (uint8_t)(ws_rand() & 0xFF);
|
|
||||||
}
|
|
||||||
base64_encode_bin(buf, 16, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 从传输层接收指定字节数 ======================== */
|
|
||||||
static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) {
|
|
||||||
int pos = 0;
|
|
||||||
while (pos < len) {
|
|
||||||
int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos);
|
|
||||||
if (n <= 0) return -1;
|
|
||||||
pos += n;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 发送 WS 帧 ======================== */
|
|
||||||
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) {
|
|
||||||
uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */
|
|
||||||
int hdr_len = 0;
|
|
||||||
|
|
||||||
hdr[0] = 0x80 | opcode; /* FIN + opcode */
|
|
||||||
hdr_len = 2;
|
|
||||||
|
|
||||||
int ext_len = 0;
|
|
||||||
if (len < 126) {
|
|
||||||
hdr[1] = 0x80 | len; /* mask bit + length */
|
|
||||||
} else if (len < 65536) {
|
|
||||||
hdr[1] = 0x80 | 126;
|
|
||||||
hdr_len = 4;
|
|
||||||
hdr[2] = (uint8_t)(len >> 8);
|
|
||||||
hdr[3] = (uint8_t)(len & 0xFF);
|
|
||||||
ext_len = 2;
|
|
||||||
} else {
|
|
||||||
hdr[1] = 0x80 | 127;
|
|
||||||
hdr_len = 10;
|
|
||||||
uint64_t l = (uint64_t)len;
|
|
||||||
for (int i = 8; i > 0; i--) {
|
|
||||||
hdr[1 + i] = (uint8_t)(l & 0xFF);
|
|
||||||
l >>= 8;
|
|
||||||
}
|
|
||||||
ext_len = 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* mask key */
|
|
||||||
uint8_t mask_key[4];
|
|
||||||
mask_key[0] = (uint8_t)(ws_rand() & 0xFF);
|
|
||||||
mask_key[1] = (uint8_t)(ws_rand() & 0xFF);
|
|
||||||
mask_key[2] = (uint8_t)(ws_rand() & 0xFF);
|
|
||||||
mask_key[3] = (uint8_t)(ws_rand() & 0xFF);
|
|
||||||
|
|
||||||
int mask_off = 2 + ext_len;
|
|
||||||
hdr[mask_off] = mask_key[0];
|
|
||||||
hdr[mask_off + 1] = mask_key[1];
|
|
||||||
hdr[mask_off + 2] = mask_key[2];
|
|
||||||
hdr[mask_off + 3] = mask_key[3];
|
|
||||||
hdr_len = mask_off + 4;
|
|
||||||
|
|
||||||
/* 发送帧头 */
|
|
||||||
if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 发送掩码后的 payload */
|
|
||||||
if (len > 0) {
|
|
||||||
/* 如果 payload 不大,用栈缓冲区 */
|
|
||||||
uint8_t stack_buf[2048];
|
|
||||||
uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len);
|
|
||||||
if (!masked) return -1;
|
|
||||||
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
masked[i] = payload[i] ^ mask_key[i & 3];
|
|
||||||
}
|
|
||||||
|
|
||||||
int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1;
|
|
||||||
|
|
||||||
if (masked != stack_buf) free(masked);
|
|
||||||
if (ret != 0) return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================== 公共 API ======================== */
|
|
||||||
|
|
||||||
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
|
|
||||||
const char *host, uint16_t port,
|
|
||||||
const char *path, const char *token) {
|
|
||||||
memset(ws, 0, sizeof(ha_ws_t));
|
|
||||||
ws->transport = transport;
|
|
||||||
ws->connected = 0;
|
|
||||||
|
|
||||||
strncpy(ws->host, host, sizeof(ws->host) - 1);
|
|
||||||
ws->port = port;
|
|
||||||
strncpy(ws->path, path, sizeof(ws->path) - 1);
|
|
||||||
if (token) strncpy(ws->token, token, sizeof(ws->token) - 1);
|
|
||||||
|
|
||||||
/* 种子 */
|
|
||||||
ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port);
|
|
||||||
|
|
||||||
/* 1. TCP 连接 */
|
|
||||||
if (transport->connect(transport->ctx, host, port) != 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. 发送 WS 升级请求 */
|
|
||||||
char key[32];
|
|
||||||
ws_gen_key(key);
|
|
||||||
|
|
||||||
char req[1024];
|
|
||||||
int n = snprintf(req, sizeof(req),
|
|
||||||
"GET %s HTTP/1.1\r\n"
|
|
||||||
"Host: %s:%u\r\n"
|
|
||||||
"Upgrade: websocket\r\n"
|
|
||||||
"Connection: Upgrade\r\n"
|
|
||||||
"Sec-WebSocket-Key: %s\r\n"
|
|
||||||
"Sec-WebSocket-Version: 13\r\n"
|
|
||||||
"\r\n",
|
|
||||||
path, host, (unsigned)port, key);
|
|
||||||
|
|
||||||
/* 如果 token 存在,加到路径参数中 */
|
|
||||||
if (token && token[0]) {
|
|
||||||
n = snprintf(req, sizeof(req),
|
|
||||||
"GET %s?token=%s HTTP/1.1\r\n"
|
|
||||||
"Host: %s:%u\r\n"
|
|
||||||
"Upgrade: websocket\r\n"
|
|
||||||
"Connection: Upgrade\r\n"
|
|
||||||
"Sec-WebSocket-Key: %s\r\n"
|
|
||||||
"Sec-WebSocket-Version: 13\r\n"
|
|
||||||
"\r\n",
|
|
||||||
path, token, host, (unsigned)port, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transport->send(transport->ctx, (uint8_t *)req, n) != n) {
|
|
||||||
transport->close(transport->ctx);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. 读取响应头(直到 \r\n\r\n) */
|
|
||||||
char resp[1024];
|
|
||||||
int resp_len = 0;
|
|
||||||
int found = 0;
|
|
||||||
while (resp_len < (int)sizeof(resp) - 1) {
|
|
||||||
int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1);
|
|
||||||
if (n <= 0) {
|
|
||||||
transport->close(transport->ctx);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
resp_len += n;
|
|
||||||
resp[resp_len] = '\0';
|
|
||||||
if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) {
|
|
||||||
found = 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
transport->close(transport->ctx);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. 检查状态码 101 */
|
|
||||||
if (strstr(resp, " 101 ") == NULL) {
|
|
||||||
transport->close(transport->ctx);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws->connected = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_ws_send_text(ha_ws_t *ws, const char *text) {
|
|
||||||
if (!ws->connected) return -1;
|
|
||||||
return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) {
|
|
||||||
if (!ws->connected) return -1;
|
|
||||||
return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_ws_send_ping(ha_ws_t *ws) {
|
|
||||||
if (!ws->connected) return -1;
|
|
||||||
return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) {
|
|
||||||
if (!ws->connected) return -1;
|
|
||||||
|
|
||||||
*payload = NULL;
|
|
||||||
*len = 0;
|
|
||||||
|
|
||||||
/* 读取帧头:2 字节 */
|
|
||||||
uint8_t hdr[2];
|
|
||||||
if (recv_all(ws, hdr, 2) != 0) {
|
|
||||||
ws->connected = 0;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int opcode = hdr[0] & 0x0F;
|
|
||||||
int masked = (hdr[1] & 0x80) ? 1 : 0;
|
|
||||||
uint64_t frame_len = hdr[1] & 0x7F;
|
|
||||||
|
|
||||||
if (frame_len == 126) {
|
|
||||||
uint8_t ext[2];
|
|
||||||
if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; }
|
|
||||||
frame_len = ((uint64_t)ext[0] << 8) | ext[1];
|
|
||||||
} else if (frame_len == 127) {
|
|
||||||
uint8_t ext[8];
|
|
||||||
if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; }
|
|
||||||
frame_len = 0;
|
|
||||||
for (int i = 0; i < 8; i++) {
|
|
||||||
frame_len = (frame_len << 8) | ext[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 读取 mask key */
|
|
||||||
uint8_t mask_key[4] = {0, 0, 0, 0};
|
|
||||||
if (masked) {
|
|
||||||
if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 限制帧大小 */
|
|
||||||
if (frame_len > sizeof(ws->read_buf)) {
|
|
||||||
/* 帧太大,跳过 payload */
|
|
||||||
uint64_t skip = frame_len;
|
|
||||||
uint8_t tmp[256];
|
|
||||||
while (skip > 0) {
|
|
||||||
int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip;
|
|
||||||
if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; }
|
|
||||||
skip -= to_skip;
|
|
||||||
}
|
|
||||||
return -1; /* 返回错误,帧太大 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 读取 payload */
|
|
||||||
if (frame_len > 0) {
|
|
||||||
if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) {
|
|
||||||
ws->connected = 0;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
/* 如果有 mask,解掩码 */
|
|
||||||
if (masked) {
|
|
||||||
for (uint64_t i = 0; i < frame_len; i++) {
|
|
||||||
ws->read_buf[i] ^= mask_key[i & 3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*payload = ws->read_buf;
|
|
||||||
*len = (int)frame_len;
|
|
||||||
|
|
||||||
switch (opcode) {
|
|
||||||
case WS_OPCODE_CLOSE:
|
|
||||||
ws->connected = 0;
|
|
||||||
return WS_OPCODE_CLOSE;
|
|
||||||
case WS_OPCODE_PING:
|
|
||||||
return WS_OPCODE_PING;
|
|
||||||
case WS_OPCODE_PONG:
|
|
||||||
return WS_OPCODE_PONG;
|
|
||||||
case WS_OPCODE_TEXT:
|
|
||||||
case WS_OPCODE_BINARY:
|
|
||||||
return opcode;
|
|
||||||
default:
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ha_ws_close(ha_ws_t *ws) {
|
|
||||||
if (ws->connected) {
|
|
||||||
ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0);
|
|
||||||
ws->connected = 0;
|
|
||||||
}
|
|
||||||
ws->transport->close(ws->transport->ctx);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
#ifndef HA_WS_H
|
|
||||||
#define HA_WS_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
#include "../include/ha_remotedevice.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ======================== WS 帧类型 ======================== */
|
|
||||||
#define WS_OPCODE_CONTINUATION 0x0
|
|
||||||
#define WS_OPCODE_TEXT 0x1
|
|
||||||
#define WS_OPCODE_BINARY 0x2
|
|
||||||
#define WS_OPCODE_CLOSE 0x8
|
|
||||||
#define WS_OPCODE_PING 0x9
|
|
||||||
#define WS_OPCODE_PONG 0xA
|
|
||||||
|
|
||||||
/* ======================== WS 连接 ======================== */
|
|
||||||
typedef struct {
|
|
||||||
ha_transport_t *transport; /* 用户实现的传输层 */
|
|
||||||
int connected; /* 是否已连接 */
|
|
||||||
uint8_t read_buf[8192]; /* 读缓冲区 */
|
|
||||||
int read_pos; /* 缓冲区中有效数据起始位置 */
|
|
||||||
int read_len; /* 缓冲区中有效数据长度 */
|
|
||||||
char host[256]; /* 缓存目标地址 */
|
|
||||||
uint16_t port;
|
|
||||||
char path[256];
|
|
||||||
char token[256];
|
|
||||||
} ha_ws_t;
|
|
||||||
|
|
||||||
/* 创建 WS 连接。返回 0 成功,非 0 失败。 */
|
|
||||||
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
|
|
||||||
const char *host, uint16_t port,
|
|
||||||
const char *path, const char *token);
|
|
||||||
|
|
||||||
/* 发送文本帧。返回 0 成功。 */
|
|
||||||
int ha_ws_send_text(ha_ws_t *ws, const char *text);
|
|
||||||
|
|
||||||
/* 发送二进制帧。返回 0 成功。 */
|
|
||||||
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len);
|
|
||||||
|
|
||||||
/* 发送 ping。返回 0 成功。 */
|
|
||||||
int ha_ws_send_ping(ha_ws_t *ws);
|
|
||||||
|
|
||||||
/* 读取一帧。
|
|
||||||
* 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。
|
|
||||||
* payload 和 len 指向内部缓冲区,在下次调用前有效。 */
|
|
||||||
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len);
|
|
||||||
|
|
||||||
/* 发送原始 WS 帧(内部使用,用于回复 ping) */
|
|
||||||
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len);
|
|
||||||
|
|
||||||
/* 关闭 WS 连接 */
|
|
||||||
void ha_ws_close(ha_ws_t *ws);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* HA_WS_H */
|
|
||||||
File diff suppressed because it is too large
Load Diff
147
scripts/build_plugin_bundles.sh
Executable file
147
scripts/build_plugin_bundles.sh
Executable file
@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 为 SDK 仓的 example 插件批量打 .hmap 包,产出可直接随 release 发布的插件包。
|
||||||
|
#
|
||||||
|
# 背景:release 此前只发 homed/waiter 二进制与 hmapdev 工具链,**不发插件包**。
|
||||||
|
# 用户要用某个插件,得自己装 Go 1.25、拉依赖、装 hmapdev、逐个 build —— 这是
|
||||||
|
# 「开箱即用」名不副实的根源。本脚本把这一步前置到发布流程里。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./build_plugin_bundles.sh # 全部 example
|
||||||
|
# ./build_plugin_bundles.sh weather qq memo # 指定插件
|
||||||
|
# OUT=../dist/plugins ./build_plugin_bundles.sh
|
||||||
|
#
|
||||||
|
# 环境:
|
||||||
|
# HMAPDEV hmapdev 可执行文件(默认取 PATH 上的 hmapdev)
|
||||||
|
# OUT 产物目录。默认取**内核仓**的 dist/plugins(upload_assets.py 认这个位置),
|
||||||
|
# 以便直接随 release 发布;不在内核仓内时回退到 SDK 仓的 dist/plugins。
|
||||||
|
# JOBS 并行度(默认 CPU 核数)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SDK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# 默认产物落到内核仓的 dist/plugins。判定方式:从 SDK 目录向上找“含 internal/ 与
|
||||||
|
# go.mod”的目录(即内核仓根),找不到就用 SDK 仓自己的 dist/plugins。
|
||||||
|
# 为何不写死 ../../:SDK 仓在主仓里是 third_party/homeagent-sdk,但也可以被单独
|
||||||
|
# clone 出来,写死相对路径会把产物丢到仓外或 third_party/dist。
|
||||||
|
default_out() {
|
||||||
|
local d="$SDK_DIR"
|
||||||
|
for _ in 1 2 3 4; do
|
||||||
|
d="$(cd "$d/.." && pwd)"
|
||||||
|
if [ -f "$d/go.mod" ] && [ -d "$d/internal" ]; then
|
||||||
|
echo "$d/dist/plugins"; return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$SDK_DIR/dist/plugins"
|
||||||
|
}
|
||||||
|
|
||||||
|
EX_DIR="$SDK_DIR/example"
|
||||||
|
OUT="${OUT:-$(default_out)}"
|
||||||
|
HMAPDEV="${HMAPDEV:-hmapdev}"
|
||||||
|
|
||||||
|
command -v "$HMAPDEV" >/dev/null 2>&1 || {
|
||||||
|
echo "error: 找不到 hmapdev(设 HMAPDEV=/path/to/hmapdev 或用 'hmapdev sdk install' 装)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# 收集候选插件:有 plg.json 才可构建
|
||||||
|
all=()
|
||||||
|
for d in "$EX_DIR"/*/; do
|
||||||
|
n="$(basename "$d")"
|
||||||
|
[ -f "$d/plg.json" ] || continue
|
||||||
|
all+=("$n")
|
||||||
|
done
|
||||||
|
|
||||||
|
# 参数指定则取交集(并校验名字有效,避免拼错静默跳过)
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
want=("$@")
|
||||||
|
sel=()
|
||||||
|
for w in "${want[@]}"; do
|
||||||
|
found=""
|
||||||
|
for n in "${all[@]}"; do [ "$n" = "$w" ] && found=1 && break; done
|
||||||
|
[ -n "$found" ] || { echo "error: 未知插件 '$w'(可用: ${all[*]})" >&2; exit 1; }
|
||||||
|
sel+=("$w")
|
||||||
|
done
|
||||||
|
all=("${sel[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 打包 ${#all[@]} 个插件 → $OUT ==="
|
||||||
|
echo " hmapdev: $("$HMAPDEV" --version 2>/dev/null | head -1 || echo "$HMAPDEV")"
|
||||||
|
|
||||||
|
build_one() {
|
||||||
|
local name="$1"
|
||||||
|
local dir="$EX_DIR/$name"
|
||||||
|
local log="$OUT/.$name.log"
|
||||||
|
|
||||||
|
# hmapdev build 必须在插件目录内跑(它读当前目录的 plg.json)
|
||||||
|
if ! (cd "$dir" && "$HMAPDEV" build >"$log" 2>&1); then
|
||||||
|
echo " ✗ $name 构建失败(见 $log)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 产物有三种形态,不能只认 _bundle.hmap:
|
||||||
|
# 1) <name>_bundle.hmap 多平台 bundle(plg.json 里 bundle: true)
|
||||||
|
# 2) <name>_<os>_<arch>.hmap 单平台(bundle 关掉时,如 qq)
|
||||||
|
# 3) <name>_lua.hmap Lua 插件(不编译 Go,如 luademo)
|
||||||
|
#
|
||||||
|
# 注意用 if 而非 `[ -z ] && found=$(ls...)`:在 set -e 下,
|
||||||
|
# 一次 ls 无匹配就会让整个子 shell 直接退出,根本走不到后面的兜底。
|
||||||
|
local found=""
|
||||||
|
local cand
|
||||||
|
for pat in "$dir"/dist/*_bundle.hmap "$dir"/dist/*.hmap "$dir"/*_bundle.hmap; do
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
cand="$(ls -t $pat 2>/dev/null | head -1 || true)"
|
||||||
|
[ -n "$cand" ] && found="$cand"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
echo " ✗ $name 未产出 .hmap(见 $log)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp -f "$found" "$OUT/"
|
||||||
|
local sz bn
|
||||||
|
bn="$(basename "$found")"
|
||||||
|
sz="$(stat -c%s "$OUT/$bn" 2>/dev/null || stat -f%z "$OUT/$bn")"
|
||||||
|
# 标注形态:单平台/Lua 包与多平台 bundle 不同,发布时要能一眼看出
|
||||||
|
local tag=""
|
||||||
|
case "$bn" in
|
||||||
|
*_bundle.hmap) tag="bundle" ;;
|
||||||
|
*_lua.hmap) tag="lua " ;;
|
||||||
|
*) tag="单平台" ;;
|
||||||
|
esac
|
||||||
|
printf " ✓ %-16s %7.1f MB %s\n" "$name" "$(echo "$sz" | awk '{print $1/1048576}')" "$tag"
|
||||||
|
rm -f "$log"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
pids=()
|
||||||
|
for n in "${all[@]}"; do
|
||||||
|
# 有 nproc 就限并发,没有就串行
|
||||||
|
if command -v nproc >/dev/null 2>&1; then
|
||||||
|
while [ "$(jobs -rp | wc -l)" -ge "${JOBS:-$(nproc)}" ]; do wait -n 2>/dev/null || true; done
|
||||||
|
fi
|
||||||
|
( build_one "$n" ) &
|
||||||
|
pids+=($!)
|
||||||
|
done
|
||||||
|
for p in "${pids[@]}"; do wait "$p" || fail=$((fail+1)); done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 产出 ==="
|
||||||
|
ls -la "$OUT"/*.hmap 2>/dev/null | awk '{printf " %-46s %8.1f MB\n", $9, $5/1048576}' || echo " (无)"
|
||||||
|
|
||||||
|
# 汇总校验和,便于随 release 一起发布与验证
|
||||||
|
if ls "$OUT"/*.hmap >/dev/null 2>&1; then
|
||||||
|
( cd "$OUT" && sha256sum ./*.hmap > SHA256SUMS.plugins )
|
||||||
|
echo
|
||||||
|
echo "=== 校验和 → $OUT/SHA256SUMS.plugins ==="
|
||||||
|
cat "$OUT/SHA256SUMS.plugins" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fail" -gt 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "error: $fail 个插件构建失败" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
38
scripts/sync-lua-sdk.sh
Executable file
38
scripts/sync-lua-sdk.sh
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 同步 Lua SDK mock 的单一事实源到各副本。
|
||||||
|
#
|
||||||
|
# 事实源:sdk/lua/sdk.lua(本仓)
|
||||||
|
# 副本:
|
||||||
|
# - tools/hmapdev/assets/sdk.lua 工具链内嵌回退(hmapdev init --lua 无 SDK 时用)
|
||||||
|
# - example/luademo/sdk.lua 示例插件的离线测试副本
|
||||||
|
# - <core>/internal/lua/sdk/sdk.lua 内核内嵌副本(本仓被 vendored 到
|
||||||
|
# <core>/third_party/homeagent-sdk 时自动识别;独立 clone 时跳过)
|
||||||
|
#
|
||||||
|
# 为什么要有它:三份 sdk.lua 曾各自漂移,出现「mock 有、内核没有」的静默失配。
|
||||||
|
# 改 mock 只改事实源,然后跑这个脚本;内核仓另有契约测试比对。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SRC="$ROOT/sdk/lua/sdk.lua"
|
||||||
|
|
||||||
|
[ -f "$SRC" ] || { echo "error: canonical sdk.lua not found: $SRC" >&2; exit 1; }
|
||||||
|
|
||||||
|
copy() {
|
||||||
|
local dst="$1"
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
cp "$SRC" "$dst"
|
||||||
|
echo " synced -> $dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy "$ROOT/tools/hmapdev/assets/sdk.lua"
|
||||||
|
copy "$ROOT/example/luademo/sdk.lua"
|
||||||
|
|
||||||
|
# 被内核仓 vendored 时(本仓位于 <core>/third_party/homeagent-sdk)同步内核副本。
|
||||||
|
CORE_COPY="$ROOT/../../internal/lua/sdk/sdk.lua"
|
||||||
|
if [ -d "$ROOT/../../internal" ]; then
|
||||||
|
copy "$(cd "$(dirname "$CORE_COPY")" && pwd)/sdk.lua"
|
||||||
|
else
|
||||||
|
echo " note: core repo not vendored next to this checkout, skipping core copy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Lua SDK mock synced."
|
||||||
133
scripts/upload-assets.py
Executable file
133
scripts/upload-assets.py
Executable file
@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""上传 release 资产到 gitcode(两步:取签名 URL → PUT 到 OBS)。
|
||||||
|
|
||||||
|
用法: upload_assets.py <tag> <token> [file...]
|
||||||
|
不传 file 时上传 dist/release/ 下全部发布产物。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
GITCODE_REPO 目标仓库,默认 JianFeeeee/HomeAgent(SDK 仓传 JianFeeeee/homeagent-sdk)
|
||||||
|
ASSET_DIR 资产目录,默认 <repo>/dist/release
|
||||||
|
|
||||||
|
为何两步:gitcode 的 release 附件不走 API 直传,而是先向
|
||||||
|
`releases/<tag>/upload_url` 要一个 OBS 预签名 URL(带 x-obs-* 回调头),
|
||||||
|
再把文件 PUT 到那个 URL。回调头必须原样透传,否则 OBS 收下了文件但
|
||||||
|
gitcode 侧不会登记为 release 附件。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
REPO = os.environ.get("GITCODE_REPO", "JianFeeeee/HomeAgent")
|
||||||
|
API = "https://gitcode.com/api/v5/repos"
|
||||||
|
|
||||||
|
# 发布产物后缀。注意 Windows 安装器是 HomeAgent_v*_win64.exe,
|
||||||
|
# 与 bin/ 里的裸 .exe 靠 _win64.exe 后缀区分。
|
||||||
|
ARTIFACT_SUFFIXES = (
|
||||||
|
".tar.gz",
|
||||||
|
".zip",
|
||||||
|
".deb",
|
||||||
|
".rpm",
|
||||||
|
".pkg",
|
||||||
|
"_win64.exe",
|
||||||
|
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
|
||||||
|
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh)。
|
||||||
|
".hmap",
|
||||||
|
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
|
||||||
|
"SHA256SUMS.plugins",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_artifact(name: str) -> bool:
|
||||||
|
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
|
||||||
|
|
||||||
|
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
|
||||||
|
q = urllib.parse.urlencode({"file_name": filename})
|
||||||
|
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
|
||||||
|
req = urllib.request.Request(url, headers={"private-token": token})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
return data["url"], data.get("headers", {})
|
||||||
|
|
||||||
|
|
||||||
|
def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
body = f.read()
|
||||||
|
req = urllib.request.Request(url, data=body, method="PUT")
|
||||||
|
for k, v in headers.items():
|
||||||
|
req.add_header(k, v)
|
||||||
|
req.add_header("Content-Length", str(size))
|
||||||
|
try:
|
||||||
|
# 大文件(Full 变体安装包近 100MB)给足超时。
|
||||||
|
with urllib.request.urlopen(req, timeout=900) as r:
|
||||||
|
return r.status, r.read().decode("utf-8", "replace")[:300]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", "replace")[:300]
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return 0, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def project_root() -> str:
|
||||||
|
"""向上找带 go.mod 的目录作为仓库根。
|
||||||
|
|
||||||
|
为何不数 dirname:本脚本初版在 scripts/(深度 1),移到 deploy/scripts/
|
||||||
|
(深度 2)后写死的两层 dirname 就指向了 deploy/dist/release,上传直接
|
||||||
|
FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断
|
||||||
|
PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。
|
||||||
|
"""
|
||||||
|
d = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
while d != os.path.dirname(d):
|
||||||
|
if os.path.exists(os.path.join(d, "go.mod")):
|
||||||
|
return d
|
||||||
|
d = os.path.dirname(d)
|
||||||
|
# 实在找不到(脚本被单独拷出仓库)就回退到 cwd,给 ASSET_DIR 一个机会
|
||||||
|
return os.getcwd()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
tag, token = sys.argv[1], sys.argv[2]
|
||||||
|
outdir = os.environ.get("ASSET_DIR") or os.path.join(
|
||||||
|
project_root(), "dist", "release"
|
||||||
|
)
|
||||||
|
if not os.path.isdir(outdir):
|
||||||
|
print(f"error: 资产目录不存在: {outdir}")
|
||||||
|
print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/")
|
||||||
|
return 2
|
||||||
|
files = sys.argv[3:] or sorted(
|
||||||
|
f for f in os.listdir(outdir) if is_artifact(f)
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
print(f"error: {outdir} 下没有可识别的发布产物")
|
||||||
|
return 2
|
||||||
|
print(f"repo={REPO} tag={tag} dir={outdir}", flush=True)
|
||||||
|
failed = []
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(outdir, name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print(f"skip (missing): {name}", flush=True)
|
||||||
|
continue
|
||||||
|
mib = os.path.getsize(path) / 1048576
|
||||||
|
print(f"==> {name} ({mib:.1f} MiB)", flush=True)
|
||||||
|
try:
|
||||||
|
url, headers = get_upload_url(tag, token, name)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" upload_url FAILED: {e}", flush=True)
|
||||||
|
failed.append(name)
|
||||||
|
continue
|
||||||
|
status, body = put_file(url, headers, path)
|
||||||
|
ok = 200 <= status < 300
|
||||||
|
print(f" PUT -> {status} {'OK' if ok else body}", flush=True)
|
||||||
|
if not ok:
|
||||||
|
failed.append(name)
|
||||||
|
print(f"\n{'ALL OK' if not failed else f'{len(failed)} FAILED: ' + ', '.join(failed)}")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@ -9,6 +9,14 @@ type KnowledgeAPI interface {
|
|||||||
|
|
||||||
// Knowledge represents a knowledge entry.
|
// Knowledge represents a knowledge entry.
|
||||||
type Knowledge struct {
|
type Knowledge struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Content string `json:"content"`
|
// Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。
|
||||||
|
//
|
||||||
|
// 为何加这个字段:对外服务(kbtree)要做**暴露范围过滤**就必须知道
|
||||||
|
// 每条结果属于哪个分类 —— 过滤只能发生在服务端(客户端过滤等于
|
||||||
|
// 没过滤,范围外内容已经随响应发出去了)。
|
||||||
|
// 之前这里只有 Name/Content,内核明明返回了 Category 却在
|
||||||
|
// knowledge_impl.SearchIn 的拷贝里丢掉,导致外部无法按分类判定。
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|||||||
413
sdk/lua/sdk.lua
Normal file
413
sdk/lua/sdk.lua
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
-- HomeAgent Lua Plugin SDK
|
||||||
|
-- Interface contract between Lua plugins and HomeAgent kernel.
|
||||||
|
-- !impl functions are replaced by Go implementations at runtime.
|
||||||
|
-- Standalone/debug: pure Lua mock implementations are used.
|
||||||
|
-- Usage: local sdk = require("sdk")
|
||||||
|
|
||||||
|
sdk = {}
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- level: "debug" | "info" | "warn" | "error"
|
||||||
|
function sdk.log(level, msg)
|
||||||
|
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
-- handler: function(args) -> result
|
||||||
|
function sdk.register_tool(name, def, handler)
|
||||||
|
print("[lua-plugin] register_tool: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- stage: "on_input" | "pre_action" | "post_action" | ...
|
||||||
|
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
|
||||||
|
function sdk.register_stage(stage, handler, scope)
|
||||||
|
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.register_api(name)
|
||||||
|
print("[lua-plugin] register_api: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
-- handler: function(args) -> result
|
||||||
|
function sdk.register_output_channel(name, caps, desc, def, handler)
|
||||||
|
print("[lua-plugin] register_output_channel: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- def: { no_memory=true/false, cleaner=function(text)->text }
|
||||||
|
function sdk.register_input_channel(name, def)
|
||||||
|
print("[lua-plugin] register_input_channel: " .. tostring(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.get_setting(key)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.set_setting(key, value)
|
||||||
|
print("[lua-plugin] set_setting: " .. tostring(key))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_text(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_interrupt: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_text_no_memory(source, channel, text)
|
||||||
|
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
|
||||||
|
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
|
||||||
|
function sdk.inject_text_opts(source, channel, text, opts)
|
||||||
|
print("[lua-plugin] inject_text_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_opts(source, channel, text, opts)
|
||||||
|
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
|
||||||
|
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
|
||||||
|
function sdk.inject_input_sync(source, channel, text)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_sync_opts(source, channel, text, opts)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
|
||||||
|
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
|
||||||
|
function sdk.set_tool_blocks(blocks)
|
||||||
|
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- blocks 每项:{ type="text", text="..." }
|
||||||
|
-- | { type="image_url", image_url={ url="...", detail="high" } }
|
||||||
|
-- | { type="audio_url", audio_url={ url="..." } }
|
||||||
|
function sdk.inject_input_media(source, channel, text, blocks)
|
||||||
|
print("[lua-plugin] inject_input_media: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
|
||||||
|
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 同 sdk.inject_input_sync:Lua 中不可用。
|
||||||
|
function sdk.inject_input_media_sync(source, channel, text, blocks)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
|
||||||
|
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts;确需同步等待请改用 Go 插件。"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_media(source, channel, text, blocks)
|
||||||
|
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
|
||||||
|
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
|
||||||
|
function sdk.unregister_output_channel(name) return nil, nil end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
-- enabled: true/false,崩溃时内核自动拉起
|
||||||
|
function sdk.set_auto_restart(enabled)
|
||||||
|
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============ graph memory ============
|
||||||
|
-- !impl
|
||||||
|
sdk.memory = {}
|
||||||
|
-- !impl
|
||||||
|
-- query: string, depth: number -> {entities={...}, relations={...}}
|
||||||
|
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||||
|
-- !impl
|
||||||
|
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
|
||||||
|
function sdk.memory.commit(triples) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.memory.introspect() return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.memory.merge(source, target) return 0 end
|
||||||
|
-- !impl
|
||||||
|
-- criteria: {key=value}, hard: boolean
|
||||||
|
function sdk.memory.purge(criteria, hard) return 0 end
|
||||||
|
|
||||||
|
-- ============ document memory ============
|
||||||
|
-- !impl
|
||||||
|
sdk.doc = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.doc.query(text, top_k) return {} end
|
||||||
|
-- !impl
|
||||||
|
-- doc: { id=, title=, content= }
|
||||||
|
function sdk.doc.insert(doc) return nil end
|
||||||
|
-- !impl
|
||||||
|
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
|
||||||
|
function sdk.doc.insert_with_media(doc, attachments) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.doc.remove(id) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.doc.stats() return {} end
|
||||||
|
|
||||||
|
-- ============ knowledge ============
|
||||||
|
-- !impl
|
||||||
|
sdk.knowledge = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.knowledge.search(query, limit) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.knowledge.add(tag, content) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.knowledge.list() return {} end
|
||||||
|
|
||||||
|
-- ============ text memory ============
|
||||||
|
-- !impl
|
||||||
|
sdk.text_memory = {}
|
||||||
|
-- !impl
|
||||||
|
-- evt: { timestamp=, role=, content=, channel= }
|
||||||
|
function sdk.text_memory.append(evt) return nil end
|
||||||
|
|
||||||
|
-- ============ llm ============
|
||||||
|
-- !impl
|
||||||
|
sdk.llm = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.llm.list_sources() return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.llm.set_source(name) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.llm.current_source() return nil end
|
||||||
|
|
||||||
|
-- ============ social (只读) ============
|
||||||
|
-- !impl
|
||||||
|
sdk.social = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_person(name) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_network(name, depth) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.get_relations(name) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.social.list_persons() return {} end
|
||||||
|
|
||||||
|
-- ============ settings (作用域变体) ============
|
||||||
|
-- !impl
|
||||||
|
sdk.settings = {}
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.get_core(key) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.set_core(key, value) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.list_core(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.list(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
|
||||||
|
-- min=, max=, step=, required=, secret= }
|
||||||
|
function sdk.settings.register_def(def) return nil end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.defs(prefix) return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.dump() return {} end
|
||||||
|
-- !impl
|
||||||
|
function sdk.settings.plugins() return {} end
|
||||||
|
|
||||||
|
-- ============ events(只读订阅) ============
|
||||||
|
-- !impl
|
||||||
|
-- subscribe(event_type, handler) -> unsubscribe()
|
||||||
|
-- handler 收到 { type=, source=, timestamp=, payload= };
|
||||||
|
-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。
|
||||||
|
sdk.events = {}
|
||||||
|
function sdk.events.subscribe(event_type, handler)
|
||||||
|
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============ plugin_mgr ============
|
||||||
|
-- !impl
|
||||||
|
sdk.plugin_mgr = {}
|
||||||
|
function sdk.plugin_mgr.reload_one(name) return nil end
|
||||||
|
function sdk.plugin_mgr.list_loaded() return {} end
|
||||||
|
function sdk.plugin_mgr.is_disabled(name) return false end
|
||||||
|
|
||||||
|
-- json utils (pure Lua)
|
||||||
|
sdk.json = {}
|
||||||
|
|
||||||
|
function sdk.json.encode(val)
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local function _encode(v)
|
||||||
|
local t = type(v)
|
||||||
|
if t == "string" then
|
||||||
|
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
|
||||||
|
return '"' .. s .. '"'
|
||||||
|
elseif t == "number" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "boolean" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "table" then
|
||||||
|
local keys = {}
|
||||||
|
local is_array = true
|
||||||
|
local maxn = 0
|
||||||
|
for k in pairs(v) do
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
|
||||||
|
is_array = false
|
||||||
|
end
|
||||||
|
if type(k) == "number" and k > maxn then maxn = k end
|
||||||
|
end
|
||||||
|
if is_array and #keys >= maxn then
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, maxn do
|
||||||
|
parts[#parts + 1] = _encode(v[i])
|
||||||
|
end
|
||||||
|
return "[" .. table.concat(parts, ",") .. "]"
|
||||||
|
else
|
||||||
|
local parts = {}
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
|
||||||
|
end
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return _encode(val)
|
||||||
|
end)
|
||||||
|
if ok then return result end
|
||||||
|
return "null"
|
||||||
|
end
|
||||||
|
|
||||||
|
function sdk.json.decode(str)
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local pos, _end = 1, #str
|
||||||
|
local function skip()
|
||||||
|
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
|
||||||
|
end
|
||||||
|
local function parse()
|
||||||
|
skip()
|
||||||
|
if pos > _end then return nil end
|
||||||
|
local c = str:sub(pos, pos)
|
||||||
|
if c == '"' then
|
||||||
|
local s = {}
|
||||||
|
pos = pos + 1
|
||||||
|
while pos <= _end do
|
||||||
|
local ch = str:sub(pos, pos)
|
||||||
|
if ch == '"' then
|
||||||
|
pos = pos + 1
|
||||||
|
return table.concat(s)
|
||||||
|
elseif ch == '\\' then
|
||||||
|
pos = pos + 1
|
||||||
|
local n = str:sub(pos, pos)
|
||||||
|
if n == '"' then s[#s+1] = '"'
|
||||||
|
elseif n == '\\' then s[#s+1] = '\\'
|
||||||
|
elseif n == '/' then s[#s+1] = '/'
|
||||||
|
elseif n == 'b' then s[#s+1] = '\b'
|
||||||
|
elseif n == 'f' then s[#s+1] = '\f'
|
||||||
|
elseif n == 'n' then s[#s+1] = '\n'
|
||||||
|
elseif n == 'r' then s[#s+1] = '\r'
|
||||||
|
elseif n == 't' then s[#s+1] = '\t'
|
||||||
|
elseif n == 'u' then
|
||||||
|
local hex = str:sub(pos+1, pos+4)
|
||||||
|
pos = pos + 4
|
||||||
|
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
|
||||||
|
end
|
||||||
|
pos = pos + 1
|
||||||
|
else
|
||||||
|
s[#s+1] = ch
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(s)
|
||||||
|
elseif c == 't' then pos = pos + 4; return true
|
||||||
|
elseif c == 'f' then pos = pos + 5; return false
|
||||||
|
elseif c == 'n' then pos = pos + 4; return nil
|
||||||
|
elseif c == '{' then
|
||||||
|
pos = pos + 1; skip()
|
||||||
|
local t = {}
|
||||||
|
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
|
||||||
|
while true do
|
||||||
|
skip(); local k = parse(); skip()
|
||||||
|
if str:sub(pos, pos) == ':' then pos = pos + 1 end
|
||||||
|
skip(); t[k] = parse(); skip()
|
||||||
|
local sep = str:sub(pos, pos)
|
||||||
|
if sep == '}' then pos = pos + 1; return t end
|
||||||
|
if sep == ',' then pos = pos + 1 end
|
||||||
|
end
|
||||||
|
elseif c == '[' then
|
||||||
|
pos = pos + 1; skip()
|
||||||
|
local t = {}
|
||||||
|
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
|
||||||
|
local idx = 1
|
||||||
|
while true do
|
||||||
|
skip(); t[idx] = parse(); idx = idx + 1; skip()
|
||||||
|
local sep = str:sub(pos, pos)
|
||||||
|
if sep == ']' then pos = pos + 1; return t end
|
||||||
|
if sep == ',' then pos = pos + 1 end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local s, e = str:find('^[-%d%.eE]+', pos)
|
||||||
|
if s then
|
||||||
|
local num = tonumber(str:sub(s, e))
|
||||||
|
pos = e + 1
|
||||||
|
return num
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return parse()
|
||||||
|
end)
|
||||||
|
if ok then return result end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- http utils
|
||||||
|
sdk.http = {}
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.http.get(url)
|
||||||
|
print("[lua-plugin] http.get: " .. tostring(url))
|
||||||
|
return {status=200, body='{"mock":true}', headers={}}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- !impl
|
||||||
|
function sdk.http.post(url, body, content_type)
|
||||||
|
print("[lua-plugin] http.post: " .. tostring(url))
|
||||||
|
return {status=200, body='{"mock":true}', headers={}}
|
||||||
|
end
|
||||||
|
|
||||||
|
return sdk
|
||||||
154
sdk/plugin.go
154
sdk/plugin.go
@ -54,6 +54,60 @@ func ValidContextPolicy(policy string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||||
|
//
|
||||||
|
// 与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||||
|
// RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||||
|
// 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||||
|
// 默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||||
|
const (
|
||||||
|
RecallPolicyNone = "none"
|
||||||
|
RecallPolicyAuto = "auto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
|
||||||
|
func ValidRecallPolicy(policy string) bool {
|
||||||
|
switch policy {
|
||||||
|
case "", RecallPolicyNone, RecallPolicyAuto:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
|
||||||
|
//
|
||||||
|
// 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
|
||||||
|
// 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
|
||||||
|
// 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
|
||||||
|
// (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
|
||||||
|
//
|
||||||
|
// 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
|
||||||
|
// v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
|
||||||
|
// 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
|
||||||
|
// 通道突然失去场景召回;
|
||||||
|
// 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
|
||||||
|
// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
|
||||||
|
//
|
||||||
|
// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
|
||||||
|
// 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
|
||||||
|
// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
|
||||||
|
// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
|
||||||
|
// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
|
||||||
|
// 将来确实需要时的闸门。
|
||||||
|
const (
|
||||||
|
ScenePolicyAuto = "auto"
|
||||||
|
ScenePolicyNone = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。
|
||||||
|
func ValidScenePolicy(policy string) bool {
|
||||||
|
switch policy {
|
||||||
|
case "", ScenePolicyAuto, ScenePolicyNone:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||||
//
|
//
|
||||||
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||||
@ -65,6 +119,7 @@ func ValidContextPolicy(policy string) bool {
|
|||||||
//
|
//
|
||||||
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||||
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||||
|
// RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
|
||||||
//
|
//
|
||||||
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||||
//
|
//
|
||||||
@ -77,7 +132,15 @@ func ValidContextPolicy(policy string) bool {
|
|||||||
type InjectOptions struct {
|
type InjectOptions struct {
|
||||||
NoMemory bool
|
NoMemory bool
|
||||||
ContextPolicy string
|
ContextPolicy string
|
||||||
CleanerName string
|
// RecallPolicy 声明此次注入是否据其内容召回相关记忆。
|
||||||
|
// 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为);
|
||||||
|
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
|
||||||
|
RecallPolicy string
|
||||||
|
// ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。
|
||||||
|
// 空串 = 默认参与(保持既有行为);ScenePolicyNone 显式关闭,
|
||||||
|
// 适用于不产生任何场面指纹的纯内部信号(心跳、自循环、内部状态)。
|
||||||
|
ScenePolicy string
|
||||||
|
CleanerName string
|
||||||
|
|
||||||
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
|
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
|
||||||
//
|
//
|
||||||
@ -106,6 +169,8 @@ const (
|
|||||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||||
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||||
|
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
|
||||||
|
// ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
|
||||||
//
|
//
|
||||||
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||||
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||||
@ -114,6 +179,10 @@ type ChannelDef struct {
|
|||||||
NoMemory bool `json:"no_memory,omitempty"`
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
Cleaner func(string) string `json:"-"`
|
Cleaner func(string) string `json:"-"`
|
||||||
ContextPolicy string `json:"context_policy,omitempty"`
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
|
// RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。
|
||||||
|
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||||
|
// ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。
|
||||||
|
ScenePolicy string `json:"scene_policy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StageContext provides context for stage handlers.
|
// StageContext provides context for stage handlers.
|
||||||
@ -171,6 +240,41 @@ type ToolResult struct {
|
|||||||
Result interface{} `json:"result"`
|
Result interface{} `json:"result"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToolError 描述一次工具调用的失败原因。
|
||||||
|
//
|
||||||
|
// 存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试
|
||||||
|
// (实测 cmd_run 失败率 34%~48%,全部源于同一个成因:参数被截断或
|
||||||
|
// JSON 写坏,工具却只回报 "command is required" 这类与真因无关的错)。
|
||||||
|
//
|
||||||
|
// ⚠️ 零值语义:插件**不必**改用本类型。内核的失败识别同时兼容既有三种约定
|
||||||
|
// ({"error":…}、{"isError":true,…}、显式 error 返回),见 core.isToolError。
|
||||||
|
// 本类型是给**新写**的工具用的可选项,不是迁移要求。
|
||||||
|
type ToolError struct {
|
||||||
|
// Field 是出错的参数字段名(参数校验失败时填)。
|
||||||
|
Field string `json:"field,omitempty"`
|
||||||
|
// Reason 是机器可读的原因码:required / type / unauthorized / timeout / not_found。
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
// Detail 是人类可读的补充说明。
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
// Hint 是给模型的可执行指引(该改什么、不要重试什么)。
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error 实现 error,便于工具同时走 (ToolError, error) 通道。
|
||||||
|
func (e *ToolError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := e.Reason
|
||||||
|
if e.Field != "" {
|
||||||
|
s = e.Field + ": " + s
|
||||||
|
}
|
||||||
|
if e.Detail != "" {
|
||||||
|
s += " (" + e.Detail + ")"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// ToolDef describes a tool that the plugin exposes.
|
// ToolDef describes a tool that the plugin exposes.
|
||||||
type ToolDef struct {
|
type ToolDef struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -180,6 +284,33 @@ type ToolDef struct {
|
|||||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||||
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
|
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
|
||||||
|
// RecallPolicy 声明此工具输出是否触发一次记忆召回(注入)。
|
||||||
|
// ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。
|
||||||
|
// 默认 none:多数工具输出是噪声;需要「取回真实内容后据它召回」的工具(如 qq_get_message)应显式声明 auto。
|
||||||
|
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||||
|
// ParallelSafe 声明此工具**可以被并发执行**(同一批多个 tool_call 同时跑)。
|
||||||
|
//
|
||||||
|
// ⚠️ 零值 false 是刻意的:存量插件不改一行就得到**保守**行为
|
||||||
|
//(整批串行),不会因升级被意外并发。声明它是**责任**而非特权。
|
||||||
|
//
|
||||||
|
// 判据(三者皆满足才可并发):
|
||||||
|
// · handler 自身线程安全(不持有跨调用的可变状态)
|
||||||
|
// · 不与同批其它工具争抢同一资源(SQLite 写、设备、同一输出通道)
|
||||||
|
// · 执行顺序无关(顺序敏感的工具应留 false,由内核保序)
|
||||||
|
ParallelSafe bool `json:"parallel_safe,omitempty"`
|
||||||
|
// Serial 声明本工具**必须**串行 —— ParallelSafe 的反向标记。
|
||||||
|
//
|
||||||
|
// 为什么需要它:ParallelSafe 的零值 false 已经表达"安全/串行",
|
||||||
|
// 插件无法区分"我没想过"和"我确认过必须串行"。一旦工具作者需要
|
||||||
|
// 把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
|
||||||
|
// 这个区分就是必需的 —— 否则只能靠命名约定传递意图。
|
||||||
|
//
|
||||||
|
// 适用场景:读操作但有隐含顺序约束(终端 read/resize 这类共享会话
|
||||||
|
// 状态)、或写操作虽已加锁但需要串行以获得可预测的交错顺序。
|
||||||
|
//
|
||||||
|
// 判据优先级:**Serial 胜出**。显式声明"必须串行"不允许被
|
||||||
|
// ParallelSafe 或任何默认值覆盖。
|
||||||
|
Serial bool `json:"serial,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||||
@ -321,6 +452,11 @@ type PluginSDK struct {
|
|||||||
events EventSubscriber
|
events EventSubscriber
|
||||||
plgMgr PluginMgrAPI
|
plgMgr PluginMgrAPI
|
||||||
|
|
||||||
|
// proxyReg 是反代声明的注册回调(内置插件经 RegisterProxy 声明服务)。
|
||||||
|
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
|
||||||
|
// 起的 goroutine。
|
||||||
|
proxyReg ProxyRegistrar
|
||||||
|
|
||||||
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||||
//
|
//
|
||||||
// 这些字段的写方与读方天然跨 goroutine:
|
// 这些字段的写方与读方天然跨 goroutine:
|
||||||
@ -480,7 +616,15 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
|||||||
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
||||||
// 若该通道同时也是你的注入入口,两个都要登记。
|
// 若该通道同时也是你的注入入口,两个都要登记。
|
||||||
//
|
//
|
||||||
// name: channel name (e.g. "qq", "webui")
|
// name: channel name (e.g. "qq", "webui")。
|
||||||
|
//
|
||||||
|
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
|
||||||
|
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
|
||||||
|
// 而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||||
|
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
|
||||||
|
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
|
||||||
|
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
|
||||||
|
// 而不是把原始值直接当通道名。
|
||||||
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||||
// desc: description of the channel, expected meta format, and type enum
|
// desc: description of the channel, expected meta format, and type enum
|
||||||
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||||
@ -735,8 +879,12 @@ func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
// SetAutoRestart 设置插件崩溃后内核是否自动重启它。
|
||||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||||
|
//
|
||||||
|
// 重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
|
||||||
|
// 且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
|
||||||
|
// 注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
|
||||||
func (s *PluginSDK) SetAutoRestart(enabled bool) {
|
func (s *PluginSDK) SetAutoRestart(enabled bool) {
|
||||||
s.apiMu.Lock()
|
s.apiMu.Lock()
|
||||||
s.autoRestart = enabled
|
s.autoRestart = enabled
|
||||||
|
|||||||
345
sdk/proxy.go
Normal file
345
sdk/proxy.go
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。
|
||||||
|
//
|
||||||
|
// 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
|
||||||
|
// (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
|
||||||
|
// (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
|
||||||
|
// 「知道端口 + 自己配转发」,插件换端口就失效。
|
||||||
|
//
|
||||||
|
// 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HomeAgent
|
||||||
|
// 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
|
||||||
|
// 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
|
||||||
|
// 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
|
||||||
|
// 2. 可版本化:声明随插件包一起分发、可 diff、可审计;
|
||||||
|
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
|
||||||
|
// 插件、或旧 HomeAgent 读新插件都不会报错。
|
||||||
|
//
|
||||||
|
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
|
||||||
|
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
|
||||||
|
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
|
||||||
|
//
|
||||||
|
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
|
||||||
|
// capabilities 里另外开一个开关——最小权限默认生效。
|
||||||
|
//
|
||||||
|
// # 单一入口原则(强制要求)
|
||||||
|
//
|
||||||
|
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
|
||||||
|
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
|
||||||
|
//
|
||||||
|
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
|
||||||
|
//
|
||||||
|
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
|
||||||
|
// 根绝对路径(fetch('/api/x'))**天然正确**。
|
||||||
|
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
|
||||||
|
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
|
||||||
|
// —— 静默错路由,页面能开但功能全坏。
|
||||||
|
//
|
||||||
|
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
|
||||||
|
//
|
||||||
|
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
|
||||||
|
// 绝不硬编码以 / 开头的绝对路径。
|
||||||
|
//
|
||||||
|
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
|
||||||
|
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
|
||||||
|
// 时给 Path 形态,**无需插件配合改动**。
|
||||||
|
//
|
||||||
|
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
|
||||||
|
// 所有请求都必须仍然打到插件自己。
|
||||||
|
//
|
||||||
|
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
|
||||||
|
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
|
||||||
|
// 改成相对路径后两种形态同时可用。
|
||||||
|
// ProxyDef 是一个服务的**反代声明体**。
|
||||||
|
//
|
||||||
|
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
|
||||||
|
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
|
||||||
|
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
|
||||||
|
type ProxyDef struct {
|
||||||
|
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
|
||||||
|
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
|
||||||
|
// name 键填入。省略时由 HomeAgent 兜底为 "service"。
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
|
||||||
|
//
|
||||||
|
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
|
||||||
|
// (DNS label 规则)。省略时默认取插件名(下划线转连字符,因为下划线
|
||||||
|
// 不是合法 DNS label 字符)。
|
||||||
|
//
|
||||||
|
// 冲突处理:两个插件声明同一 Host 时,HomeAgent 不做「后者覆盖前者」——
|
||||||
|
// 那样会让先声明者静默消失。冲突条目被拒绝并在反代表里记录原因。
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
|
||||||
|
// Target 是上游地址,形如 "127.0.0.1:12100" 或 "http://127.0.0.1:12100"。
|
||||||
|
// 可带路径前缀(如 "127.0.0.1:3000/base"),HomeAgent 转发时保留该前缀。
|
||||||
|
//
|
||||||
|
// 端口由插件自己填它**实际监听**的地址,避免「声明与实际漂移」。
|
||||||
|
Target string `json:"target"`
|
||||||
|
|
||||||
|
// WebSocket 表示该服务需要 WebSocket 升级透传(默认 false)。
|
||||||
|
//
|
||||||
|
// 为什么必须显式声明而不是「有 Upgrade 头就转」:WS 是长连接,会占用
|
||||||
|
// 反代侧连接与 goroutine,且绕过普通请求的响应缓冲/超时逻辑。默认关闭
|
||||||
|
// 让普通 HTTP 服务的失败模式保持简单;未声明时的升级请求会被明确拒绝,
|
||||||
|
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
|
||||||
|
WebSocket bool `json:"websocket,omitempty"`
|
||||||
|
|
||||||
|
// Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
|
||||||
|
//
|
||||||
|
// 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
|
||||||
|
// 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
|
||||||
|
// CLI)走系统解析器,实测解析不到,会以「no such host」失败。
|
||||||
|
// 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
|
||||||
|
// 客户端用的。
|
||||||
|
//
|
||||||
|
// 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
|
||||||
|
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
|
||||||
|
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
|
||||||
|
//
|
||||||
|
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
|
||||||
|
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
|
||||||
|
// 否则它的前端会把请求打到门户自己身上。
|
||||||
|
//
|
||||||
|
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
|
||||||
|
// 同源,走子域天然正确)。
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
|
||||||
|
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
|
||||||
|
//
|
||||||
|
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
|
||||||
|
//
|
||||||
|
// false(别名模式):Path 就是上游真实路径的一部分。
|
||||||
|
// 请求 /api/v1/device/ws + Path="/api/v1/device"
|
||||||
|
// → 上游收到 /api/v1/device/ws(一模一样)。
|
||||||
|
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
|
||||||
|
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
|
||||||
|
//
|
||||||
|
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
|
||||||
|
// 请求 /p/myapp/api/status + Path="/p/myapp"
|
||||||
|
// → 上游收到 /api/status。
|
||||||
|
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
|
||||||
|
//
|
||||||
|
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
|
||||||
|
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
|
||||||
|
// 上游故障。所以由声明者显式写清楚。
|
||||||
|
StripPath bool `json:"strip_path,omitempty"`
|
||||||
|
|
||||||
|
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
|
||||||
|
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
|
||||||
|
//
|
||||||
|
// 为什么做成可声明项:设备网关(remotedevice)这类服务的调用方是**设备**,
|
||||||
|
// 它们不可能持有浏览器会话 cookie,而服务自身已有接入令牌(如 ws_token)。
|
||||||
|
// 强制走 HomeAgent 门户鉴权会把这类链路挡死;反过来,插件自带的 UI 若
|
||||||
|
// 声明 none,就等于把管理界面裸露给任何能访问该端口的人。
|
||||||
|
// 因此必须由插件**逐条**声明,而不是全局一刀切。
|
||||||
|
Auth string `json:"auth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyAuth 取值。空串按 ProxyAuthHomeAgent 处理(安全的默认)。
|
||||||
|
const (
|
||||||
|
// ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话
|
||||||
|
// (homeagent_session cookie),非浏览器客户端走 X-API-Key。
|
||||||
|
// 两者都没有时返回 401,而不是把请求透传给上游。
|
||||||
|
ProxyAuthHomeAgent = "homeagent"
|
||||||
|
|
||||||
|
// ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。
|
||||||
|
//
|
||||||
|
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
|
||||||
|
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
|
||||||
|
// 且该服务在网络层可达范围内对所有人开放。
|
||||||
|
ProxyAuthNone = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。
|
||||||
|
func ValidProxyAuth(auth string) bool {
|
||||||
|
switch auth {
|
||||||
|
case "", ProxyAuthHomeAgent, ProxyAuthNone:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
|
||||||
|
func EffectiveProxyAuth(auth string) string {
|
||||||
|
if auth == "" {
|
||||||
|
return ProxyAuthHomeAgent
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
|
||||||
|
//
|
||||||
|
// 独立成导出函数:插件作者在写声明时、HomeAgent 在加载时、工具链在打包时
|
||||||
|
// 都要用同一套规则判定,避免三处各写一份而互相不一致。
|
||||||
|
func ValidProxyHostLabel(label string) bool {
|
||||||
|
if label == "" || len(label) > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(label); i++ {
|
||||||
|
c := label[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeProxyHost 由插件名派生默认 Host 标签。
|
||||||
|
//
|
||||||
|
// 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
|
||||||
|
// 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
|
||||||
|
func NormalizeProxyHost(pluginName string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(pluginName))
|
||||||
|
s = strings.ReplaceAll(s, "_", "-")
|
||||||
|
// 去掉其它非法字符,保证结果是合法 label(宁可退化成保守值也不产出非法域名)
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if out == "" {
|
||||||
|
return "plugin"
|
||||||
|
}
|
||||||
|
if len(out) > 63 {
|
||||||
|
out = strings.Trim(out[:63], "-")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
|
||||||
|
//
|
||||||
|
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
|
||||||
|
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
|
||||||
|
// 拼错的 Target/Host。同一套规则两端共用。
|
||||||
|
func ValidateProxyDef(d ProxyDef) string {
|
||||||
|
if strings.TrimSpace(d.Target) == "" {
|
||||||
|
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
|
||||||
|
}
|
||||||
|
if !ValidProxyAuth(d.Auth) {
|
||||||
|
return "auth 取值非法:" + d.Auth + "(只允许 \"\" / \"homeagent\" / \"none\")"
|
||||||
|
}
|
||||||
|
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
|
||||||
|
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(d.Path); p != "" {
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
return "path 必须以 / 开头: " + d.Path
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(p, "/") {
|
||||||
|
return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
|
||||||
|
}
|
||||||
|
if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
|
||||||
|
return "path 含非法字符: " + d.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 前缀模式必须给出可剥的前缀。
|
||||||
|
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
|
||||||
|
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
|
||||||
|
if d.StripPath && strings.TrimSpace(d.Path) == "" {
|
||||||
|
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
|
||||||
|
}
|
||||||
|
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
|
||||||
|
//
|
||||||
|
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
|
||||||
|
// 用户带到别处去):
|
||||||
|
// - 带 scheme 时(http://…)允许省略端口,由反代层按 scheme 补默认值;
|
||||||
|
// - 不带 scheme 时必须给出 host:port;
|
||||||
|
// - 端口必须是数字(SplitHostPort 本身不校验数字,"host:abc" 会通过)。
|
||||||
|
scheme := ""
|
||||||
|
raw := d.Target
|
||||||
|
if i := strings.Index(raw, "://"); i >= 0 {
|
||||||
|
scheme = strings.ToLower(raw[:i])
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return "target scheme 只支持 http/https(WS 由 websocket 字段声明,不写 ws://): " + d.Target
|
||||||
|
}
|
||||||
|
raw = raw[i+3:]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(raw, '/'); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return "target 缺少主机部分: " + d.Target
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(raw)
|
||||||
|
if err != nil {
|
||||||
|
if scheme == "" {
|
||||||
|
return "target 必须给出 host:port(或带 http:// 前缀以便省略端口): " + d.Target
|
||||||
|
}
|
||||||
|
// 带 scheme 且解析失败:只剩主机名一种合法情形。
|
||||||
|
host, port = raw, ""
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return "target 缺少主机部分: " + d.Target
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
n, err := strconv.Atoi(port)
|
||||||
|
if err != nil || n < 1 || n > 65535 {
|
||||||
|
return "target 端口非法(应为 1-65535 的数字): " + d.Target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
|
||||||
|
// 插件不直接调它,用 RegisterProxy。
|
||||||
|
//
|
||||||
|
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
|
||||||
|
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
|
||||||
|
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
|
||||||
|
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
|
||||||
|
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
|
||||||
|
type ProxyRegistrar func(name string, def ProxyDef)
|
||||||
|
|
||||||
|
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
|
||||||
|
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.proxyReg = r
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
|
||||||
|
//
|
||||||
|
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
|
||||||
|
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
|
||||||
|
// def.Host / def.Path)。
|
||||||
|
//
|
||||||
|
// 用法(通常在 Start 里调用):
|
||||||
|
//
|
||||||
|
// s.RegisterProxy("ui", sdk.ProxyDef{
|
||||||
|
// Host: "myapp", Target: "127.0.0.1:12100",
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
|
||||||
|
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
|
||||||
|
// 插件作者需要看见冲突。
|
||||||
|
//
|
||||||
|
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
|
||||||
|
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.apiMu.RLock()
|
||||||
|
r := s.proxyReg
|
||||||
|
s.apiMu.RUnlock()
|
||||||
|
if r != nil {
|
||||||
|
r(name, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
sdk/proxy_test.go
Normal file
163
sdk/proxy_test.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProxyAuthDefaultsToHomeAgent(t *testing.T) {
|
||||||
|
// 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。
|
||||||
|
// 若哪天有人把默认改成 none,这条会立刻红。
|
||||||
|
if got := EffectiveProxyAuth(""); got != ProxyAuthHomeAgent {
|
||||||
|
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthHomeAgent, got)
|
||||||
|
}
|
||||||
|
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
|
||||||
|
t.Fatalf("显式 none 应保持 none,实际 %q", got)
|
||||||
|
}
|
||||||
|
for _, ok := range []string{"", ProxyAuthHomeAgent, ProxyAuthNone} {
|
||||||
|
if !ValidProxyAuth(ok) {
|
||||||
|
t.Errorf("%q 应合法", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
|
||||||
|
if ValidProxyAuth(bad) {
|
||||||
|
t.Errorf("%q 应非法", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidProxyHostLabel(t *testing.T) {
|
||||||
|
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
|
||||||
|
for _, s := range legit {
|
||||||
|
if !ValidProxyHostLabel(s) {
|
||||||
|
t.Errorf("%q 应为合法 label", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []string{
|
||||||
|
"", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b",
|
||||||
|
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
|
||||||
|
}
|
||||||
|
for _, s := range bad {
|
||||||
|
if ValidProxyHostLabel(s) {
|
||||||
|
t.Errorf("%q 应为非法 label", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 边界:恰好 63 合法,64 非法
|
||||||
|
l63 := ""
|
||||||
|
for i := 0; i < 63; i++ {
|
||||||
|
l63 += "a"
|
||||||
|
}
|
||||||
|
if !ValidProxyHostLabel(l63) {
|
||||||
|
t.Error("63 字符应为合法 label")
|
||||||
|
}
|
||||||
|
if ValidProxyHostLabel(l63 + "a") {
|
||||||
|
t.Error("64 字符应为非法 label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeProxyHost(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label
|
||||||
|
"webui": "webui",
|
||||||
|
"UPPER_Case": "upper-case",
|
||||||
|
"a__b": "a--b",
|
||||||
|
"__x__": "x",
|
||||||
|
"---": "plugin", // 全非法 → 保守回退
|
||||||
|
"": "plugin",
|
||||||
|
"a.b.c": "abc",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeProxyHost(in); got != want {
|
||||||
|
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 归一化结果必须自身合法(产物自洽)
|
||||||
|
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
|
||||||
|
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
|
||||||
|
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProxyDef(t *testing.T) {
|
||||||
|
valid := []ProxyDef{
|
||||||
|
{Target: "127.0.0.1:12100"},
|
||||||
|
{Target: "http://127.0.0.1:12100"},
|
||||||
|
{Target: "127.0.0.1:12100", Host: "huawei"},
|
||||||
|
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
|
||||||
|
{Target: "127.0.0.1:12100", Auth: ProxyAuthHomeAgent, WebSocket: true},
|
||||||
|
{Target: "127.0.0.1:3000/base", Host: "x"},
|
||||||
|
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
|
||||||
|
}
|
||||||
|
for _, d := range valid {
|
||||||
|
if msg := ValidateProxyDef(d); msg != "" {
|
||||||
|
t.Errorf("%+v 应合法,却报: %s", d, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := []ProxyDef{
|
||||||
|
{}, // 无 target
|
||||||
|
{Target: " "}, // 空白 target
|
||||||
|
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
|
||||||
|
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
|
||||||
|
{Target: "127.0.0.1:12100", Host: "-x"},
|
||||||
|
{Target: "127.0.0.1:12100", Host: "X"},
|
||||||
|
{Target: "://12100"}, // 无主机
|
||||||
|
{Target: "http:///path"}, // 无主机
|
||||||
|
{Target: "127.0.0.1:notaport"}, // 端口非数字
|
||||||
|
}
|
||||||
|
for _, d := range bad {
|
||||||
|
if msg := ValidateProxyDef(d); msg == "" {
|
||||||
|
t.Errorf("%+v 应被拒绝,却通过了", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 单一入口原则 ----
|
||||||
|
|
||||||
|
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
|
||||||
|
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
|
||||||
|
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
|
||||||
|
// 而 SDK 只能把要求写清楚并给出校验工具。
|
||||||
|
func TestSingleEntryPrincipleDocumented(t *testing.T) {
|
||||||
|
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
|
||||||
|
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
|
||||||
|
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
|
||||||
|
src, err := os.ReadFile("proxy.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"单一入口原则",
|
||||||
|
"相对路径",
|
||||||
|
"根绝对路径",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(src), want) {
|
||||||
|
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
|
||||||
|
func TestStripPathValidation(t *testing.T) {
|
||||||
|
// 合法:两种模式
|
||||||
|
for _, d := range []ProxyDef{
|
||||||
|
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
|
||||||
|
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
|
||||||
|
} {
|
||||||
|
if msg := ValidateProxyDef(d); msg != "" {
|
||||||
|
t.Errorf("应合法却被拒: %+v → %s", d, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 非法:strip_path 但没有 path(没有可剥的前缀)
|
||||||
|
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
|
||||||
|
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
|
||||||
|
}
|
||||||
|
// 非法:前缀模式挂到根会吞掉整个门户。
|
||||||
|
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
|
||||||
|
// 这里断言的是**行为**:这种声明无论如何都不能通过。
|
||||||
|
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
|
||||||
|
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
|
||||||
|
}
|
||||||
|
}
|
||||||
205
skills/homeagent-plugin-dev/SKILL.md
Normal file
205
skills/homeagent-plugin-dev/SKILL.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
---
|
||||||
|
name: homeagent-plugin-dev
|
||||||
|
description: 开发、安装、调试 HomeAgent 插件。涉及 hmapdev 工具链、SDK 版本、plugin.bin 部署、插件注册排查时使用。触发词:HomeAgent 插件、hmapdev、plugin.bin、写插件、装插件、插件不生效、SDK 版本、RegisterTool、plugin_spawn。
|
||||||
|
---
|
||||||
|
|
||||||
|
# HomeAgent 插件开发与安装
|
||||||
|
|
||||||
|
> 本机(192.168.2.60)HomeAgent 的插件工具链手册。**所有命令都实测过**,
|
||||||
|
> 不是从文档抄的。改了本机环境后请回来更正。
|
||||||
|
|
||||||
|
## 0. 先搞清三件事,别猜
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev version # 工具链版本 + SDK 模块 + 构建用 Go
|
||||||
|
hmapdev sdk current # 当前活跃 SDK 版本
|
||||||
|
hmapdev sdk list # 本机已装的所有 SDK 版本
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心仓在** `/home/program/TrueAgent`(Go module `gitcode.com/JianFeeeee/HomeAgent`)。
|
||||||
|
**SDK 仓在** `third_party/homeagent-sdk`(独立 git 仓,独立版本号)。
|
||||||
|
|
||||||
|
## 1. 工具链:hmapdev
|
||||||
|
|
||||||
|
已装在 `/usr/local/bin/hmapdev`。这是插件开发的**唯一入口**。
|
||||||
|
|
||||||
|
| 命令 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `hmapdev init <name>` | 生成 Go 插件脚手架 |
|
||||||
|
| `hmapdev init <name> --lua` | 生成 Lua 插件 |
|
||||||
|
| `hmapdev init <name> --type remotedevice` | 生成 C 语言远程设备适配器 |
|
||||||
|
| `hmapdev build` | 编译打包(`--outdir` / `--target os/arch`) |
|
||||||
|
| `hmapdev debug [dir]` | 解释执行 / 调试插件源码 |
|
||||||
|
| `hmapdev clean` | 清理 build/dist |
|
||||||
|
| `hmapdev sdk list/install/use/path/current/latest` | SDK 版本管理 |
|
||||||
|
|
||||||
|
⚠ **`hmapdev build` / `debug` 不支持 `--help`**:它们**不是**打印帮助,而是直接
|
||||||
|
去读当前目录的 `plg.json`,于是你会看到
|
||||||
|
|
||||||
|
error: read plg.json: open plg.json: no such file or directory
|
||||||
|
|
||||||
|
这**不是故障**,只是没有 `--help`。要看 build 的可用 flag 用 `hmapdev --help`
|
||||||
|
(那里列出了 `--outdir` / `--target` / `--lua` / `--type`)。
|
||||||
|
|
||||||
|
## 2. ★ SDK 版本:最常见的坑
|
||||||
|
|
||||||
|
**插件编译时会校验 SDK 能力**,用**文档里的旧接口**生成的工程会直接构建失败,
|
||||||
|
报错形如「某能力需要更高版本 SDK」,并给出两条出路:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hmapdev sdk install <version> # 1) 装对应版本
|
||||||
|
hmapdev sdk install --from <本地源码目录> # 2) 直接用本地 SDK 源码
|
||||||
|
```
|
||||||
|
|
||||||
|
本机现状:`hmapdev sdk current` = **v1.4.0**,与核心仓 `internal/meta.Version` 一致。
|
||||||
|
|
||||||
|
⇒ **动手前先 `hmapdev sdk current`**,别照着旧文档写。
|
||||||
|
⇒ 核心仓有**新能力但 SDK 未跟上**时(本机发生过),用 `--from` 指向本地源码:
|
||||||
|
`hmapdev sdk install --from /home/program/TrueAgent/third_party/homeagent-sdk`
|
||||||
|
|
||||||
|
## 3. 开发流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /home/newqqagent/plugindev && cd /home/newqqagent/plugindev
|
||||||
|
hmapdev init myplugin
|
||||||
|
cd myplugin
|
||||||
|
# 编辑源码:注册工具用 s.RegisterTool(name, sdk.ToolDef{...}, handler)
|
||||||
|
hmapdev build # 产出 dist/myplugin_bundle.hmap
|
||||||
|
hmapdev debug . # 不想装就能先跑一遍
|
||||||
|
```
|
||||||
|
|
||||||
|
### ★ 产物形态(实测,别猜)
|
||||||
|
|
||||||
|
`hmapdev build` 产出的是 **`dist/<name>_bundle.hmap`**,它是 **ZIP**
|
||||||
|
(魔数 `PK`,用 `unzip` 而不是 `tar` 解),内含多平台二进制:
|
||||||
|
|
||||||
|
plugin.json
|
||||||
|
plugin.bin.linux.amd64
|
||||||
|
plugin.bin.darwin.amd64
|
||||||
|
README.md
|
||||||
|
|
||||||
|
而**生产上** `/home/newqqagent/plugins/<name>/plugin.bin` 是**解包后的单个二进制**
|
||||||
|
(**不是** zip)。⇒ 部署时要用对应平台的 `plugin.bin.<os>.<arch>`,
|
||||||
|
不要把 `.hmap` 直接丢进去。
|
||||||
|
|
||||||
|
### 注册工具的形状(照 example 写,别自创)
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.RegisterTool(tp+"mytool", sdk.ToolDef{
|
||||||
|
Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位**
|
||||||
|
Description: "……",
|
||||||
|
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||||
|
Handler: h.mytool,
|
||||||
|
NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法
|
||||||
|
}, s)
|
||||||
|
```
|
||||||
|
|
||||||
|
**并发声明**(同轮多个 tool_call 时):
|
||||||
|
|
||||||
|
- `ParallelSafe: true` —— 可并发(**三个条件都满足才可声明**:handler 线程安全 /
|
||||||
|
不与同批工具争抢同一资源 / 执行顺序无关)
|
||||||
|
- `Serial: true` —— 必须串行,**优先级高于 `ParallelSafe`**
|
||||||
|
- **默认(都不写)= 整批串行**,是保守设计不是遗漏
|
||||||
|
|
||||||
|
## 3.5 `plg.json` —— 插件工程标志
|
||||||
|
|
||||||
|
`hmapdev build` 找的就是它。缺失时报
|
||||||
|
`error: read plg.json: open plg.json: no such file or directory`。
|
||||||
|
|
||||||
|
以 `example/qq/plg.json` 为准:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "qq", // 插件名(英文,装到 plugins/<name>/ 用它)
|
||||||
|
"name_zh": "QQ消息", // 展示名
|
||||||
|
"version": "1.4.1",
|
||||||
|
"description": "……",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so", // 入口(build 会替换为实际产物)
|
||||||
|
"tags": ["qq", "messaging"],
|
||||||
|
"targets": "linux/amd64", // 目标平台
|
||||||
|
"outdir": "dist" // 产物目录
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 安装到生产
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 从 bundle 里取出本机平台的二进制
|
||||||
|
cd myplugin && unzip -o dist/myplugin_bundle.hmap 'plugin.bin.linux.amd64'
|
||||||
|
# ② 放进插件目录(<name> 要与 plg.json 的 name 一致)
|
||||||
|
sudo mkdir -p /home/newqqagent/plugins/myplugin
|
||||||
|
sudo cp plugin.json /home/newqqagent/plugins/myplugin/
|
||||||
|
sudo cp plugin.bin.linux.amd64 /home/newqqagent/plugins/myplugin/plugin.bin
|
||||||
|
sudo chmod +x /home/newqqagent/plugins/myplugin/plugin.bin
|
||||||
|
# ③ 重启并确认工具真的注册了
|
||||||
|
sudo systemctl restart homeagent.service
|
||||||
|
journalctl -u homeagent.service --since "-2 min" | grep "registering tool: <你的工具名>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`plugin.json` 是**安装标志**(生产目录里没有它,插件不会被识别)。
|
||||||
|
|
||||||
|
★ **内置 vs 独立二进制**(目录存在 ≠ 有 plugin.bin):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for p in webui qq cmd seq; do
|
||||||
|
printf "%-8s " $p
|
||||||
|
ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \
|
||||||
|
&& echo "独立(要单独构建部署)" || echo "内置(随 homed 部署)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
实测:`webui`/`cmd`/`seq` 内置,`qq` 独立。
|
||||||
|
|
||||||
|
## 5. 排查:插件装了却不生效
|
||||||
|
|
||||||
|
按这个顺序查,**别跳步**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 内置还是独立?内置的改了源码必须重编 homed,不是重启就行
|
||||||
|
ls /home/newqqagent/plugins/<name>/plugin.bin
|
||||||
|
|
||||||
|
# 2) 工具注册了吗
|
||||||
|
journalctl -u homeagent.service --since "-5 min" | grep "registering tool:" | grep -i <name>
|
||||||
|
|
||||||
|
# 3) 插件加载了吗
|
||||||
|
journalctl -u homeagent.service --since "-5 min" | grep -E "loaded: <name>"
|
||||||
|
|
||||||
|
# 4) 启动时有没有报错
|
||||||
|
journalctl -u homeagent.service --since "-5 min" | grep -iE "<name>.*(error|panic|failed)"
|
||||||
|
```
|
||||||
|
|
||||||
|
★ **改了内置插件的源码 ⇒ 必须重新构建并部署 homed**,重启服务不生效。
|
||||||
|
内嵌资源走 `//go:embed`,是编译进二进制的。
|
||||||
|
|
||||||
|
★ 判断线上跑的是哪次构建,看 commit 字段(**不要用 `strings`** ——
|
||||||
|
`//go:embed` 的资源在旧二进制里也可能出现新内容):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';")
|
||||||
|
curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"'
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ 必须带 `X-API-Key`:无认证时返回 **200 + 登录页 HTML**,只看状态码会误判。
|
||||||
|
|
||||||
|
## 6. 参考资料在哪(别凭记忆写 API)
|
||||||
|
|
||||||
|
| 内容 | 路径 |
|
||||||
|
| --- | --- |
|
||||||
|
| SDK 源码 | `third_party/homeagent-sdk/sdk/` |
|
||||||
|
| **可运行的示例插件** | `third_party/homeagent-sdk/example/`(a2a / acp / qq / bili …) |
|
||||||
|
| 文档站 | `https://sdk.homeagent.jianfgit.xyz`(本地源 `docs/`,构建 `tools/apidoc/build.sh`) |
|
||||||
|
| 能力边界(哪些 API 外部可用) | `docs/guide/capability-boundary.md` |
|
||||||
|
| 并发声明 | `docs/guide/parallel-tool-declaration.md` |
|
||||||
|
| Lua 适配器写法 | `docs/guide/first-lua-plugin.md` |
|
||||||
|
| 部署手册 | 核心仓 `docs/zh/deploy-runbook.md` |
|
||||||
|
|
||||||
|
★ **写插件前先翻 `example/`**:那里的代码是**编译通过**的,
|
||||||
|
比文档更可靠。文档与示例冲突时以示例为准。
|
||||||
|
|
||||||
|
## 7. 边界(别越界)
|
||||||
|
|
||||||
|
- **不要手改生成的文档**:`docs/api/*.md` 由 `tools/apidoc/gensite` 生成,
|
||||||
|
首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。
|
||||||
|
- **不要在插件里硬编码 token / 密钥**:走 `Settings()` 或宿主注入。
|
||||||
|
- **不要为了让插件生效去改 `homed` 的源码** —— 先确认它是不是内置插件。
|
||||||
247
tools/annotate_parallel/main.go
Normal file
247
tools/annotate_parallel/main.go
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
// 命令 annotate_parallel 按 SDK 声明风格为工具加并发安全声明。
|
||||||
|
//
|
||||||
|
// 风格要求(照 SDK 的 NoMemory 走,不自创):
|
||||||
|
//
|
||||||
|
// · 声明项是**结构体字段**(ParallelSafe / Serial),不是注释标记;
|
||||||
|
// · 插在 Parameters 之后、handler 之前 —— 即字面量的**末尾**,
|
||||||
|
// 与 SDK 里 NoMemory/ContextPolicy/RecallPolicy 的位置一致;
|
||||||
|
// · Name 保持在首位,不打散 gofmt 对齐。
|
||||||
|
//
|
||||||
|
// 为什么用括号深度定位插入点:之前用正则找"最后一个顶层字段",
|
||||||
|
// 会被嵌套 map 里的同形文本骗到,结果把声明插到 Parameters 中间,
|
||||||
|
// 甚至把文件改坏(823 处重排)。深度计数是唯一可靠的。
|
||||||
|
//
|
||||||
|
// 用法:annotate_parallel <file> <tool:kind:note> ...
|
||||||
|
//
|
||||||
|
// kind: parallel | serial
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "用法: annotate_parallel <file> <tool:kind:note>...")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
path := os.Args[1]
|
||||||
|
lines := readLines(path)
|
||||||
|
// 从后往前改,避免行号漂移
|
||||||
|
type job struct {
|
||||||
|
tool, kind, note string
|
||||||
|
}
|
||||||
|
var jobs []job
|
||||||
|
for _, arg := range os.Args[2:] {
|
||||||
|
p := strings.SplitN(arg, ":", 3)
|
||||||
|
if len(p) != 3 {
|
||||||
|
fmt.Fprintf(os.Stderr, "参数格式错: %q\n", arg)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
jobs = append(jobs, job{p[0], p[1], p[2]})
|
||||||
|
}
|
||||||
|
// 反序处理(同一文件里多个工具,位置互不影响,但保守起见从后往前)
|
||||||
|
for i := len(jobs) - 1; i >= 0; i-- {
|
||||||
|
j := jobs[i]
|
||||||
|
at, err := findInsertPoint(lines, j.tool)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 幂等:块内已有并发声明就跳过。
|
||||||
|
//
|
||||||
|
// 不加这条时,重跑会在已标注的工具上**再插一份** —— 而
|
||||||
|
// duplicate field name 是编译期错误,跨文件批量跑时定位成本很高。
|
||||||
|
if blockHasDecl(lines, j.tool) {
|
||||||
|
fmt.Printf(" %s 已有声明,跳过\n", j.tool)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
field := "ParallelSafe: true,"
|
||||||
|
if j.kind == "serial" {
|
||||||
|
field = "Serial: true,"
|
||||||
|
}
|
||||||
|
ins := []string{"\t\t// " + j.note, "\t\t" + field}
|
||||||
|
out := append([]string{}, lines[:at]...)
|
||||||
|
out = append(out, ins...)
|
||||||
|
out = append(out, lines[at:]...)
|
||||||
|
lines = out
|
||||||
|
fmt.Printf(" %s @line %d (%s)\n", j.tool, at+1, j.kind)
|
||||||
|
}
|
||||||
|
writeLines(path, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。
|
||||||
|
func findInsertPoint(lines []string, tool string) (int, error) {
|
||||||
|
// 匹配两种注册形式:
|
||||||
|
// RegisterTool("get_article", ...) 字面量
|
||||||
|
// RegisterTool(tp+"get_article", ...) 变量前缀 + 字面量
|
||||||
|
// 只认字面量会漏掉后者 —— example 里绝大多数是变量前缀形式。
|
||||||
|
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
|
||||||
|
alt := fmt.Sprintf(`+"%s"`, tool)
|
||||||
|
start := -1
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.Contains(l, head) {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
// ★ 必须 RegisterTool( 与字面量在**同一行**。
|
||||||
|
//
|
||||||
|
// 我第一版只找含 `+"name"` 的行,命中了函数体里的散落字面量
|
||||||
|
// (vanblog 的 handleAuth 里满是 "restore"/"update" 这类 case 分支),
|
||||||
|
// 起点错到函数体中间,深度追踪再也回不到 2 ⇒ 插入点落在 1300+ 行,
|
||||||
|
// 把文件改坏。
|
||||||
|
//
|
||||||
|
// 症状离原因很远:报错说"expected 1 expression",指向的是一处
|
||||||
|
// 看起来完全正常的 case 分支。
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(") {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool)
|
||||||
|
}
|
||||||
|
// 从 RegisterTool( 开始做括号深度追踪,找到 ToolDef 字面量的闭合 "}," 行
|
||||||
|
depth := 0
|
||||||
|
started := false
|
||||||
|
enteredAt := -1
|
||||||
|
inStr := false
|
||||||
|
esc := false
|
||||||
|
for i := start; i < len(lines); i++ {
|
||||||
|
// peak = 本行内的峰值深度。
|
||||||
|
//
|
||||||
|
// 每行重置:它表示"这一行曾深入到多深",不是全程最大值 ——
|
||||||
|
// 全程最大值一旦到过 3 就永远是 3,"曾进入 Parameters"判据随之失效。
|
||||||
|
peak := depth
|
||||||
|
for _, ch := range lines[i] {
|
||||||
|
if esc {
|
||||||
|
esc = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '\\' && inStr {
|
||||||
|
esc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
inStr = !inStr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inStr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case '(', '{', '[':
|
||||||
|
depth++
|
||||||
|
started = true
|
||||||
|
if depth > peak {
|
||||||
|
peak = depth
|
||||||
|
}
|
||||||
|
case ')', '}', ']':
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 插入点 = Parameters 字段的**闭合之后**。
|
||||||
|
//
|
||||||
|
// 精确定位法:Parameters 起始处的深度是 3(RegisterTool( → ToolDef{ →
|
||||||
|
// Parameters{);它的闭合就是深度**首次从 3 回到 2** 的那一行。
|
||||||
|
//
|
||||||
|
// ⚠️ 我第一版没有这样做,而是"找 required 行的下一行,没有就返回字面量
|
||||||
|
// 闭合行"。对于没有 required 的工具(如 config_list_plugins),
|
||||||
|
// 后者落在 properties{} 内部 —— 生成的代码是
|
||||||
|
// "properties": map[string]interface{}{},
|
||||||
|
// ParallelSafe: true, ← 跑到 map 里去了
|
||||||
|
// 编译报 undefined: ParallelSafe,症状离原因很远。
|
||||||
|
// 插入点 = Parameters 字段闭合的**下一行**。
|
||||||
|
//
|
||||||
|
// 深度:RegisterTool( =1, ToolDef{ =2, Parameters{ =3。
|
||||||
|
//
|
||||||
|
// ★ 两个坑都是"同一行内深度进出平衡"造成的:
|
||||||
|
//
|
||||||
|
// 1. 空 properties:`"properties": map[string]interface{}{},`
|
||||||
|
// 深度 3→2 在**同一行**完成。所以不能用"曾触及 3"作门控,
|
||||||
|
// 必须记住**行号**:进入 3 的那行之后,首个回到 2 的行才是闭合行。
|
||||||
|
//
|
||||||
|
// 2. Name:/Description: 本来就在深度 2,早于 Parameters。
|
||||||
|
// 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前,
|
||||||
|
// 编译报 "expected 1 expression"。
|
||||||
|
// 判定"进入过 Parameters"要按**行内峰值深度**,不能只看行末净深度。
|
||||||
|
//
|
||||||
|
// get_meta 的 Parameters 全在一行:
|
||||||
|
// Parameters: map[string]interface{}{"type":"object","properties":map[string]interface{}{}},
|
||||||
|
// 这行净深度变化是 0(进去又出来)—— 只看净深就永远察觉不到曾进入
|
||||||
|
// 深度 3 ⇒ 追踪一路跑到 1305 行才"收敛",插入点落在某个 case 分支
|
||||||
|
// 中间,文件改坏。症状离原因很远:报错指向一处看起来完全正常的
|
||||||
|
// switch case。
|
||||||
|
if started && peak >= 3 {
|
||||||
|
enteredAt = i
|
||||||
|
}
|
||||||
|
// 闭合判定:进入过 Parameters(enteredAt)之后,深度回到 2 的那一行
|
||||||
|
// **就是** Parameters 的闭合行;插入点取它的**下一行**。
|
||||||
|
//
|
||||||
|
// ★ 不能要求 i > enteredAt:Parameters 写在单行时(get_meta 就是)
|
||||||
|
// enteredAt 与闭合行是**同一行**,加上这个条件会跳到再下一行,
|
||||||
|
// 插到 log.Printf 之前 —— 不报错,但声明落在了字面量外面。
|
||||||
|
if enteredAt >= 0 && i >= enteredAt && depth <= 2 {
|
||||||
|
return i + 1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("括号深度追踪未收敛")
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockHasDecl 报告该工具的字面量里是否已有并发声明。
|
||||||
|
func blockHasDecl(lines []string, tool string) bool {
|
||||||
|
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
|
||||||
|
alt := fmt.Sprintf(`+"%s"`, tool)
|
||||||
|
start := -1
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.Contains(l, head) || (strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(")) {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 从注册行往后找 30 行(工具定义不会更长)
|
||||||
|
for i := start; i < len(lines) && i <= start+30; i++ {
|
||||||
|
if strings.Contains(lines[i], "ParallelSafe:") || strings.Contains(lines[i], "Serial:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLines(p string) []string {
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []string
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
out = append(out, sc.Text())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLines(p string, lines []string) {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, l := range lines {
|
||||||
|
sb.WriteString(l)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(sb.String()), 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tools/apidoc/README.md
Normal file
86
tools/apidoc/README.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# 插件 SDK 文档站
|
||||||
|
|
||||||
|
用 MkDocs Material 构建的 SDK 文档站。**API 参考不是手写的** ——
|
||||||
|
它从 `sdk/*.go` 的源码注释生成,因为手抄必然与代码漂移。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdocs.yml 站点配置(导航、主题、中文检索)
|
||||||
|
docs/
|
||||||
|
├── index.md ┐
|
||||||
|
├── versions.md │
|
||||||
|
├── guide/*.md ├─ 手写:指南、边界说明、版本
|
||||||
|
├── api/index.md │
|
||||||
|
├── javascripts/ │
|
||||||
|
│ └── api-search.js │ 自建 API 检索(按名称/描述/签名)
|
||||||
|
├── stylesheets/extra.css ┘
|
||||||
|
├── api/*.md ┐ 生成物 —— 勿手改
|
||||||
|
├── examples/index.md │ (build 时覆盖)
|
||||||
|
└── assets/api-index.json ┘
|
||||||
|
tools/apidoc/ 生成器(本仓 Go 代码,零外部依赖)
|
||||||
|
├── extract.go 从源码提取符号、注释、分层
|
||||||
|
├── tiers.go 应用能力分层(public / builtin / bridge)
|
||||||
|
├── tiers.json **能力边界的事实源**(每条附源码依据)
|
||||||
|
├── gensite/main.go 渲染 Markdown + 检索索引
|
||||||
|
├── gensite/usages.go 从 example/ 抽取真实调用点
|
||||||
|
└── build.sh 一键生成 + 构建
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/apidoc/build.sh # 生成 + 构建到 site_build/
|
||||||
|
tools/apidoc/build.sh serve # 本地预览(http://127.0.0.1:8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:Go 1.21+、`mkdocs-material`(`pip install mkdocs-material`)、
|
||||||
|
`jieba`(中文检索分词,`pip install jieba`)。
|
||||||
|
|
||||||
|
## 两条设计原则
|
||||||
|
|
||||||
|
**① API 参考从源码生成。** 签名、说明、示例全部来自 `sdk/*.go` 的文档注释。
|
||||||
|
发现文档不对时,**改的是源码注释**,然后重新生成。生成页首行有「勿手改」标记。
|
||||||
|
|
||||||
|
**② 能力边界是可核对的事实,不是印象。** 哪些 API 外部插件拿不到,
|
||||||
|
逐条记在 `tools/apidoc/tiers.json`,每条都写清**可复核的依据**
|
||||||
|
(文件:行号、或 `grep` 结论)。判断标准是:
|
||||||
|
|
||||||
|
| 依据 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `tools/hmapdev/templates/proc_main.go.tmpl` 的 `base.Set*` 调用 | 外部插件运行时**实际注入**哪些能力 |
|
||||||
|
| `internal/sdk` | 内置插件用的完整接口(对照出外部缺什么) |
|
||||||
|
| `internal/plugin/proc/protocol.go` | 外部插件**能发哪些 RPC** |
|
||||||
|
|
||||||
|
文档站上每条「仅内置」告警都带这个依据,读者可自行核对。
|
||||||
|
|
||||||
|
### 为什么这个边界值得单独维护
|
||||||
|
|
||||||
|
写这个站时,实测发现文档与源码有**三处不符**(现已在站内更正):
|
||||||
|
|
||||||
|
1. `PluginMgr()` 曾被写成「仅内置可用」——实际桥接**显式注入**了它。
|
||||||
|
真正的区别是方法数:公开面 3 个,内部面 9 个(两个包里同名不同接口)。
|
||||||
|
2. `Events()` 曾被当作可用的事件订阅入口——实际桥接**不注入** subscriber,
|
||||||
|
外部插件拿到的恒为 nil(`SetEventSubscriber` 全仓无调用点)。
|
||||||
|
外部插件的事件订阅实际由生成的运行时走 `events.subscribe` RPC 完成。
|
||||||
|
3. `UnregisterOutputChannel` 易被当成「可用但会报错」——实际返回 nil,
|
||||||
|
**静默无效**(桥不注入 unregister),不报错也不注销。
|
||||||
|
|
||||||
|
## 检索
|
||||||
|
|
||||||
|
站内有两套检索,互补:
|
||||||
|
|
||||||
|
- **MkDocs 内置搜索**(右上角):全文检索,中文走 jieba 分词。
|
||||||
|
- **自建 API 检索**(首页与 API 参考页的输入框):读 `assets/api-index.json`,
|
||||||
|
专门解决「**按描述找 API**」——搜「注册工具」能找到 `RegisterTool`,
|
||||||
|
搜「崩溃」能找到 `SetAutoRestart`,并可区分公开/仅内置。
|
||||||
|
|
||||||
|
自建检索支持四类查询:名称、描述(中英文)、`限定符.方法`
|
||||||
|
(如 `memory.recall`)、签名片段(如 `(string) error`)。
|
||||||
|
|
||||||
|
## 维护提示
|
||||||
|
|
||||||
|
- **改了 `sdk/*.go` 的注释或签名** → 重跑 `build.sh`,改动自动进文档。
|
||||||
|
- **改了能力边界** → 改 `tiers.json`,不要直接改生成的 `.md`。
|
||||||
|
- **新增示例插件** → 自动出现在「示例插件」页的用法表里(扫 `example/`)。
|
||||||
|
- `site_build/` 是构建产物,已 gitignore,不要提交。
|
||||||
62
tools/apidoc/build.sh
Executable file
62
tools/apidoc/build.sh
Executable file
@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 生成并构建插件 SDK 文档站。
|
||||||
|
#
|
||||||
|
# 四步:
|
||||||
|
# 1. apidoc —— 从 sdk/*.go 提取公开 API 面(签名/注释/分层)→ JSON
|
||||||
|
# 2. gensite —— 把 JSON 渲染成 docs/api/*.md + 检索索引 + llms.txt
|
||||||
|
# 3. mkdocs —— 构建静态站
|
||||||
|
# 4. copy_agent —— 把 Markdown 源搬进站点产物(mkdocs 只渲染 .md,不复制)
|
||||||
|
#
|
||||||
|
# 为什么要脚本而不是手敲:API 参考是**生成物**,必须与源码同步,
|
||||||
|
# 否则文档会悄悄过时(这是文档站最常见的死法)。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# tools/apidoc/build.sh # 生成 + 构建
|
||||||
|
# tools/apidoc/build.sh serve # 生成 + 本地预览(热重载)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
TMP_API="${TMPDIR:-/tmp}/homeagent-sdk-api.json"
|
||||||
|
SITE=site_build
|
||||||
|
|
||||||
|
# copy_agent_files 把 docs/ 下的 Markdown 原样复制进站点产物。
|
||||||
|
#
|
||||||
|
# 为什么必须复制:mkdocs 只把 .md **渲染**成 HTML,不会把它们放进产物目录。
|
||||||
|
# 但 agent 需要 Markdown 原文(省 token、不含主题样板),所以 llms.txt 里
|
||||||
|
# 指的 /api/tools.md 必须真实可访问。llms.txt 与 llms-full.txt 由 gensite 生成。
|
||||||
|
copy_agent_files() {
|
||||||
|
local n=0 rel dir
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
rel="${f#docs/}"
|
||||||
|
[ "${rel##*/}" = "README.md" ] && continue
|
||||||
|
dir="$(dirname "$rel")"
|
||||||
|
[ "$dir" != "." ] && mkdir -p "$SITE/$dir"
|
||||||
|
cp "$f" "$SITE/$rel"
|
||||||
|
n=$((n + 1))
|
||||||
|
done < <(find docs -name '*.md' -print0)
|
||||||
|
echo " 复制 $n 个 Markdown 到 $SITE/(供 agent 直读)"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== 1/4 提取 API 面 ==="
|
||||||
|
go run ./tools/apidoc -pkgdir ./sdk -out "$TMP_API"
|
||||||
|
|
||||||
|
echo "=== 2/4 渲染文档页、检索索引与 agent 入口 ==="
|
||||||
|
go run ./tools/apidoc/gensite -api "$TMP_API" -out ./docs -examples ./example
|
||||||
|
|
||||||
|
echo "=== 3/4 构建静态站 ==="
|
||||||
|
if [ "${1:-}" = "serve" ]; then
|
||||||
|
# 预览模式也要能取到 .md(agent 入口),故先构建一次再起服务。
|
||||||
|
mkdocs build --strict >/dev/null
|
||||||
|
copy_agent_files
|
||||||
|
exec mkdocs serve
|
||||||
|
fi
|
||||||
|
mkdocs build --strict
|
||||||
|
|
||||||
|
echo "=== 4/4 供 agent 直读的 Markdown ==="
|
||||||
|
copy_agent_files
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "完成。产物在 $SITE/,本地预览:tools/apidoc/build.sh serve"
|
||||||
|
echo "agent 入口:$SITE/llms.txt(目录)、$SITE/llms-full.txt(全文)"
|
||||||
427
tools/apidoc/extract.go
Normal file
427
tools/apidoc/extract.go
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
// Command apidoc 从 SDK 源码提取公开 API 面,输出 JSON 供文档站生成使用。
|
||||||
|
//
|
||||||
|
// 设计约束:
|
||||||
|
// - **只用标准库**(go/ast、go/parser、go/token)——不需要网络、不依赖
|
||||||
|
// golang.org/x/tools,clone 下来就能跑。
|
||||||
|
// - **只读源码**,不做 import 解析:它按文件解析 `sdk/*.go`,因此不必处于
|
||||||
|
// 任何 Go module 内,也不会把依赖带进 SDK 主 module。
|
||||||
|
// - 输出是文档站生成的**唯一事实源**:文档里的签名、注释、示例代码块
|
||||||
|
// 全部来自这里,不手抄,避免文档与源码漂移。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/doc"
|
||||||
|
"go/parser"
|
||||||
|
"go/printer"
|
||||||
|
"go/token"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Symbol 是一条 API 记录。
|
||||||
|
type Symbol struct {
|
||||||
|
Kind string `json:"kind"` // func / method / type / const / var
|
||||||
|
Recv string `json:"recv"` // 方法接收者(仅 method)
|
||||||
|
Name string `json:"name"` // 符号名
|
||||||
|
Signature string `json:"signature"` // 一行签名
|
||||||
|
Doc string `json:"doc"` // 文档注释(原文,含 markdown)
|
||||||
|
DocBrief string `json:"doc_brief"` // 首行摘要
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Group string `json:"group"` // 归属分组(由 groupFor 决定)
|
||||||
|
Exported bool `json:"exported"`
|
||||||
|
Examples []string `json:"examples"` // 注释里的 ```go 代码块
|
||||||
|
// Deprecated/Since 由注释里的标记提取,供文档打标。
|
||||||
|
Deprecated bool `json:"deprecated"`
|
||||||
|
// BuiltinOnly 标记「仅内核内置插件可用」——由 tiers.json 注入。
|
||||||
|
BuiltinOnly bool `json:"builtin_only"`
|
||||||
|
// Tier 是可见级别(public / builtin / bridge)——由 tiers.json 注入。
|
||||||
|
// 用显式字段而非从 TierReason 里找关键字判断:中文说明里「桥接」两字
|
||||||
|
// 在公开条目的理由里也会出现(“桥接模板注入…外部插件可用”),
|
||||||
|
// 靠 strings.Contains 判断会把公开 API 误标成装配点。
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
TierReason string `json:"tier_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface 是一个接口类型及其方法。
|
||||||
|
type Interface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Doc string `json:"doc"`
|
||||||
|
Methods []Symbol `json:"methods"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package 是提取结果。
|
||||||
|
type Package struct {
|
||||||
|
ImportPath string `json:"import_path"`
|
||||||
|
Doc string `json:"doc"`
|
||||||
|
Symbols []Symbol `json:"symbols"`
|
||||||
|
Interfaces []Interface `json:"interfaces"`
|
||||||
|
// ConstGroups 保留源码里 const(...) 的分组结构。
|
||||||
|
ConstGroups []ConstGroup `json:"const_groups"`
|
||||||
|
SDKVersion string `json:"sdk_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConstGroup struct {
|
||||||
|
Doc string `json:"doc"`
|
||||||
|
Consts []Symbol `json:"consts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
codeBlockRe = regexp.MustCompile("(?s)```(?:go|bash|json|)\n(.*?)```")
|
||||||
|
deprecatedRe = regexp.MustCompile(`(?i)\b(deprecated|已废弃|已弃用|即将移除)\b`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pkgdir := flag.String("pkgdir", "./sdk", "要解析的包目录")
|
||||||
|
out := flag.String("out", "-", "输出 JSON 路径,- 表示 stdout")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
pkgs, err := parser.ParseDir(fset, *pkgdir, func(fi os.FileInfo) bool {
|
||||||
|
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||||
|
}, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("解析 %s 失败: %v", *pkgdir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Package
|
||||||
|
for name, pkg := range pkgs {
|
||||||
|
result.ImportPath = name
|
||||||
|
// doc.New 会归并同名符号、抽取示例,并给出包级文档。
|
||||||
|
d := doc.New(pkg, name, doc.AllDecls)
|
||||||
|
result.Doc = strings.TrimSpace(d.Doc)
|
||||||
|
|
||||||
|
for _, f := range d.Funcs {
|
||||||
|
result.Symbols = append(result.Symbols, makeFunc(fset, f, *pkgdir))
|
||||||
|
}
|
||||||
|
for _, t := range d.Types {
|
||||||
|
result.Symbols = append(result.Symbols, makeType(fset, t, *pkgdir))
|
||||||
|
for _, m := range t.Methods {
|
||||||
|
result.Symbols = append(result.Symbols, makeMethod(fset, m, t.Name, *pkgdir))
|
||||||
|
}
|
||||||
|
if iface, ok := t.Decl.Specs[0].(*ast.TypeSpec).Type.(*ast.InterfaceType); ok {
|
||||||
|
result.Interfaces = append(result.Interfaces,
|
||||||
|
makeInterface(t, iface, fset, *pkgdir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// const/var 用 Value 承载,按源码 const 块分组保留。
|
||||||
|
for _, v := range d.Consts {
|
||||||
|
result.Symbols = append(result.Symbols, makeValue(fset, v, "const", *pkgdir)...)
|
||||||
|
}
|
||||||
|
for _, v := range d.Vars {
|
||||||
|
result.Symbols = append(result.Symbols, makeValue(fset, v, "var", *pkgdir)...)
|
||||||
|
}
|
||||||
|
result.ConstGroups = groupConsts(fset, pkg, *pkgdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result.Symbols, func(i, j int) bool {
|
||||||
|
if result.Symbols[i].Group != result.Symbols[j].Group {
|
||||||
|
return result.Symbols[i].Group < result.Symbols[j].Group
|
||||||
|
}
|
||||||
|
return result.Symbols[i].Name < result.Symbols[j].Name
|
||||||
|
})
|
||||||
|
for i := range result.Interfaces {
|
||||||
|
sort.Slice(result.Interfaces[i].Methods, func(a, b int) bool {
|
||||||
|
return result.Interfaces[i].Methods[a].Name < result.Interfaces[i].Methods[b].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applyTiers(&result); err != nil {
|
||||||
|
log.Fatalf("应用能力分层失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if *out == "-" {
|
||||||
|
os.Stdout.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, append(data, '\n'), 0o644); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "提取 %d 个符号 → %s\n", len(result.Symbols), *out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneLine(s string) string {
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func brief(doc string) string {
|
||||||
|
for _, line := range strings.Split(doc, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" && !strings.HasPrefix(line, "//") {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func examplesOf(doc string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, m := range codeBlockRe.FindAllStringSubmatch(doc, -1) {
|
||||||
|
out = append(out, strings.TrimRight(m[1], "\n"))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(s *Symbol) Symbol {
|
||||||
|
s.Doc = strings.TrimSpace(s.Doc)
|
||||||
|
s.DocBrief = brief(s.Doc)
|
||||||
|
s.Examples = examplesOf(s.Doc)
|
||||||
|
s.Deprecated = deprecatedRe.MatchString(s.DocBrief)
|
||||||
|
s.Group = groupFor(s)
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeFunc(fset *token.FileSet, f *doc.Func, pkgdir string) Symbol {
|
||||||
|
pos := fset.Position(f.Decl.Pos())
|
||||||
|
return finish(&Symbol{
|
||||||
|
Kind: "func",
|
||||||
|
Name: f.Name,
|
||||||
|
Signature: sigOf(fset, f.Decl),
|
||||||
|
Doc: f.Doc,
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: ast.IsExported(f.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeMethod(fset *token.FileSet, f *doc.Func, recv, pkgdir string) Symbol {
|
||||||
|
pos := fset.Position(f.Decl.Pos())
|
||||||
|
return finish(&Symbol{
|
||||||
|
Kind: "method",
|
||||||
|
Recv: recv,
|
||||||
|
Name: f.Name,
|
||||||
|
Signature: sigOf(fset, f.Decl),
|
||||||
|
Doc: f.Doc,
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: ast.IsExported(f.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeType(fset *token.FileSet, t *doc.Type, pkgdir string) Symbol {
|
||||||
|
pos := fset.Position(t.Decl.Pos())
|
||||||
|
return finish(&Symbol{
|
||||||
|
Kind: "type",
|
||||||
|
Name: t.Name,
|
||||||
|
Signature: "type " + t.Name + " " + typeShape(fset, t),
|
||||||
|
Doc: t.Doc,
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: ast.IsExported(t.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeValue(fset *token.FileSet, v *doc.Value, kind, pkgdir string) []Symbol {
|
||||||
|
// 一个 const/var 声明里可能有多组名字(如 CapText/CapFile/... 同块),
|
||||||
|
// 拆成多条——把名字用逗号拼成一条在文档里很难读,检索也搜不到。
|
||||||
|
var out []Symbol
|
||||||
|
for _, spec := range v.Decl.Specs {
|
||||||
|
vs, ok := spec.(*ast.ValueSpec)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
docText := strings.TrimSpace(vs.Doc.Text())
|
||||||
|
if docText == "" {
|
||||||
|
docText = strings.TrimSpace(v.Doc)
|
||||||
|
}
|
||||||
|
for _, n := range vs.Names {
|
||||||
|
if !ast.IsExported(n.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos := fset.Position(n.Pos())
|
||||||
|
out = append(out, finish(&Symbol{
|
||||||
|
Kind: kind,
|
||||||
|
Name: n.Name,
|
||||||
|
Signature: kind + " " + n.Name,
|
||||||
|
Doc: docText,
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeInterface 从接口类型的 AST 直接读方法。
|
||||||
|
//
|
||||||
|
// 注意:不能用 doc.Type.Methods —— 那个字段只收集**具名类型的方法声明**
|
||||||
|
// (即 `func (x T) M()`),不含接口内嵌的方法列表。接口成员只能在 AST 的
|
||||||
|
// InterfaceType.Methods 里拿到。
|
||||||
|
func makeInterface(t *doc.Type, iface *ast.InterfaceType, fset *token.FileSet, pkgdir string) Interface {
|
||||||
|
it := Interface{Name: t.Name, Doc: strings.TrimSpace(t.Doc)}
|
||||||
|
for _, field := range iface.Methods.List {
|
||||||
|
if len(field.Names) == 0 {
|
||||||
|
// 内嵌接口(如 interface { io.Closer }):记为一条说明性条目。
|
||||||
|
pos := fset.Position(field.Pos())
|
||||||
|
embedded := oneLine(exprString(field.Type))
|
||||||
|
it.Methods = append(it.Methods, finish(&Symbol{
|
||||||
|
Kind: "embedded",
|
||||||
|
Recv: t.Name,
|
||||||
|
Name: embedded,
|
||||||
|
Signature: embedded,
|
||||||
|
Doc: strings.TrimSpace(field.Doc.Text()),
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: true,
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ft, ok := field.Type.(*ast.FuncType)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos := fset.Position(field.Pos())
|
||||||
|
sig := oneLine(exprString(ft))
|
||||||
|
for _, n := range field.Names {
|
||||||
|
if !ast.IsExported(n.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := name(n.Name) + strings.TrimPrefix(sig, "func")
|
||||||
|
it.Methods = append(it.Methods, finish(&Symbol{
|
||||||
|
Kind: "method",
|
||||||
|
Recv: t.Name,
|
||||||
|
Name: n.Name,
|
||||||
|
Signature: full,
|
||||||
|
Doc: strings.TrimSpace(field.Doc.Text()),
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = iface
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// exprString 用 go/printer 把 AST 节点还原成源码文本。
|
||||||
|
func exprString(n ast.Node) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := printer.Fprint(&buf, token.NewFileSet(), n); err != nil {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func name(s string) string { return s }
|
||||||
|
|
||||||
|
func sigOf(fset *token.FileSet, fn *ast.FuncDecl) string {
|
||||||
|
if fn.Type == nil {
|
||||||
|
return fn.Name.Name
|
||||||
|
}
|
||||||
|
// 用源码原文截取签名,保证与源码逐字一致(不重新格式化)。
|
||||||
|
start := fset.Position(fn.Pos()).Offset
|
||||||
|
end := fset.Position(fn.Type.End()).Offset
|
||||||
|
src, err := os.ReadFile(fset.Position(fn.Pos()).Filename)
|
||||||
|
if err == nil && start < end && end <= len(src) {
|
||||||
|
return oneLine(string(src[start:end]))
|
||||||
|
}
|
||||||
|
return fn.Name.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func typeShape(fset *token.FileSet, t *doc.Type) string {
|
||||||
|
spec, ok := t.Decl.Specs[0].(*ast.TypeSpec)
|
||||||
|
if !ok {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
start := fset.Position(spec.Type.Pos()).Offset
|
||||||
|
end := fset.Position(spec.Type.End()).Offset
|
||||||
|
src, err := os.ReadFile(fset.Position(spec.Type.Pos()).Filename)
|
||||||
|
if err != nil || start >= end || end > len(src) {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
raw := src[start:end]
|
||||||
|
// 结构体只保留第一行 + 字段数提示,完整字段在 API 页单独展开。
|
||||||
|
if len(raw) > 160 {
|
||||||
|
return oneLine(string(raw[:160])) + " …"
|
||||||
|
}
|
||||||
|
return oneLine(string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rel(base, p string) string {
|
||||||
|
if r, err := filepath.Rel(base, p); err == nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupFor 把符号归到文档站的章节。规则集中在这里,避免散落。
|
||||||
|
func groupFor(s *Symbol) string {
|
||||||
|
switch {
|
||||||
|
case s.Recv == "PluginSDK" || strings.HasPrefix(s.Name, "PluginSDK"):
|
||||||
|
return "plugin-sdk"
|
||||||
|
case s.Recv == "StageContext":
|
||||||
|
return "stages"
|
||||||
|
case s.Kind == "const" || s.Kind == "var":
|
||||||
|
return "constants"
|
||||||
|
case s.Recv == "" && s.Kind == "func":
|
||||||
|
return "functions"
|
||||||
|
case s.Kind == "type":
|
||||||
|
return "types"
|
||||||
|
case s.Recv != "":
|
||||||
|
return "interfaces"
|
||||||
|
}
|
||||||
|
return "misc"
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupConsts(fset *token.FileSet, pkg *ast.Package, pkgdir string) []ConstGroup {
|
||||||
|
var groups []ConstGroup
|
||||||
|
for _, f := range pkg.Files {
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
gd, ok := decl.(*ast.GenDecl)
|
||||||
|
if !ok || gd.Tok != token.CONST {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g := ConstGroup{}
|
||||||
|
if gd.Doc != nil {
|
||||||
|
g.Doc = strings.TrimSpace(gd.Doc.Text())
|
||||||
|
}
|
||||||
|
for _, spec := range gd.Specs {
|
||||||
|
vs, ok := spec.(*ast.ValueSpec)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, n := range vs.Names {
|
||||||
|
if ast.IsExported(n.Name) {
|
||||||
|
names = append(names, n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos := fset.Position(vs.Pos())
|
||||||
|
g.Consts = append(g.Consts, Symbol{
|
||||||
|
Kind: "const",
|
||||||
|
Name: strings.Join(names, ", "),
|
||||||
|
Doc: strings.TrimSpace(vs.Doc.Text()),
|
||||||
|
DocBrief: brief(strings.TrimSpace(vs.Doc.Text())),
|
||||||
|
File: rel(pkgdir, pos.Filename),
|
||||||
|
Line: pos.Line,
|
||||||
|
Exported: true,
|
||||||
|
Group: "constants",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(g.Consts) > 0 {
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
170
tools/apidoc/gensite/agent.go
Normal file
170
tools/apidoc/gensite/agent.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 给 agent 用的入口。
|
||||||
|
//
|
||||||
|
// 为什么需要:文档站是给**人**看的(HTML + 主题 + JS 搜索),但越来越多读者是
|
||||||
|
// agent —— 它们要的是「一次拿到结构化事实」,而不是渲染后的页面。让 agent 去
|
||||||
|
// 爬 HTML 既浪费 token(主题样板占大头)又容易漏内容。
|
||||||
|
//
|
||||||
|
// 因此额外产出三样东西:
|
||||||
|
//
|
||||||
|
// /llms.txt 站点的**目录**:每个页面一行,带 URL 与一句话说明
|
||||||
|
// /llms-full.txt 全部文档**正文**(Markdown)拼成一份,可一次读完
|
||||||
|
// /<page>.md 每个页面的 Markdown 原文(含生成的 API 页)
|
||||||
|
// /<page>.json 机器可读版(API 页有结构化符号)
|
||||||
|
//
|
||||||
|
// 约定沿用 llms.txt 社区规范(Jeremy Howard 提出):llms.txt 是给「读目录」
|
||||||
|
// 用的精简索引,llms-full.txt 是给「一次读全」用的大文件。
|
||||||
|
const llmsHeader = `# HomeAgent 插件 SDK
|
||||||
|
|
||||||
|
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
|
||||||
|
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
|
||||||
|
>
|
||||||
|
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
|
||||||
|
>
|
||||||
|
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
|
||||||
|
> 不含 HTML 样板;也可以直接取 %s 一次读完全部文档。
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
// writeAgentEntrypoints 产出 llms.txt 与 llms-full.txt,并把每个页面同时写成
|
||||||
|
// `.md`(Markdown 原文)。返回写出的页面数。
|
||||||
|
//
|
||||||
|
// 注意:mkdocs 只会把 `.md` 渲染成 HTML,不会把它们复制到站点产物里。
|
||||||
|
// 所以这里除了写 docs/,构建后还要把 Markdown 副本搬进 site_build/
|
||||||
|
// (见 build.sh 的 copy_agent_files)。
|
||||||
|
func writeAgentEntrypoints(docsDir, siteDir string) (int, error) {
|
||||||
|
type entry struct {
|
||||||
|
rel string // 相对 docs/ 的路径,如 api/tools.md
|
||||||
|
title string
|
||||||
|
desc string
|
||||||
|
}
|
||||||
|
var entries []entry
|
||||||
|
|
||||||
|
err := filepath.Walk(docsDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Base(path) == "README.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(docsDir, path)
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
title, desc := firstHeadingAndDesc(string(body))
|
||||||
|
entries = append(entries, entry{rel: rel, title: title, desc: desc})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:首页最前,其余按路径。
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
if entries[i].rel == "index.md" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if entries[j].rel == "index.md" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return entries[i].rel < entries[j].rel
|
||||||
|
})
|
||||||
|
|
||||||
|
base := "https://sdk.homeagent.jianfgit.xyz"
|
||||||
|
var idx strings.Builder
|
||||||
|
fmt.Fprintf(&idx, llmsHeader, base+"/llms-full.txt")
|
||||||
|
for _, e := range entries {
|
||||||
|
// URL 就是 .md 的落地路径(构建后把 docs/**/*.md 复制进站点产物)。
|
||||||
|
// 不要把 index.md 改成 index/ —— 那样指向的是 HTML 页而不是 Markdown 源。
|
||||||
|
mdURL := base + "/" + e.rel
|
||||||
|
fmt.Fprintf(&idx, "- [%s](%s)", e.title, mdURL)
|
||||||
|
if e.desc != "" {
|
||||||
|
fmt.Fprintf(&idx, ": %s", e.desc)
|
||||||
|
}
|
||||||
|
idx.WriteString("\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(docsDir, "llms.txt"), []byte(idx.String()), 0o644); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var full strings.Builder
|
||||||
|
fmt.Fprintf(&full, "# HomeAgent 插件 SDK — 完整文档\n\n")
|
||||||
|
full.WriteString("(本文件由 tools/apidoc/gensite 从 docs/ 汇总生成,供 agent 一次读取。)\n\n")
|
||||||
|
full.WriteString("---\n\n")
|
||||||
|
for _, e := range entries {
|
||||||
|
body, err := os.ReadFile(filepath.Join(docsDir, e.rel))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&full, "\n\n## <%s>\n\n", e.rel)
|
||||||
|
full.Write(body)
|
||||||
|
full.WriteString("\n")
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(docsDir, "llms-full.txt")
|
||||||
|
if err := os.WriteFile(fullPath, []byte(full.String()), 0o644); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把 llms.txt / llms-full.txt 也复制进站点产物(mkdocs 不搬运非 md 页面)。
|
||||||
|
// 各页面的 .md 副本由 build.sh 统一复制——那时 docs/ 已经定稿。
|
||||||
|
if siteDir != "" {
|
||||||
|
if err := os.MkdirAll(siteDir, 0o755); err == nil {
|
||||||
|
_ = copyFile(filepath.Join(docsDir, "llms.txt"), filepath.Join(siteDir, "llms.txt"))
|
||||||
|
_ = copyFile(fullPath, filepath.Join(siteDir, "llms-full.txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(entries), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstHeadingAndDesc 取首个 `# 标题` 与紧随其后的第一段(作一句话说明)。
|
||||||
|
func firstHeadingAndDesc(body string) (title, desc string) {
|
||||||
|
lines := strings.Split(body, "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if strings.HasPrefix(l, "# ") && title == "" {
|
||||||
|
title = strings.TrimSpace(strings.TrimPrefix(l, "# "))
|
||||||
|
// 往下找第一段非空、非标题、非注释、非命令的文本。
|
||||||
|
for j := i + 1; j < len(lines); j++ {
|
||||||
|
t := strings.TrimSpace(lines[j])
|
||||||
|
if t == "" || strings.HasPrefix(t, "#") ||
|
||||||
|
strings.HasPrefix(t, "<!--") || strings.HasPrefix(t, "```") ||
|
||||||
|
strings.HasPrefix(t, "!!!") || strings.HasPrefix(t, "|") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 去掉行内标记,截断到一句话。
|
||||||
|
d := strings.NewReplacer("**", "", "`", "", "\\", "").Replace(t)
|
||||||
|
if idx := strings.IndexAny(d, "。."); idx > 0 {
|
||||||
|
d = d[:idx]
|
||||||
|
}
|
||||||
|
return title, d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dst, data, 0o644)
|
||||||
|
}
|
||||||
94
tools/apidoc/gensite/keywords.go
Normal file
94
tools/apidoc/gensite/keywords.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 中文检索关键词。
|
||||||
|
//
|
||||||
|
// 问题:SDK 里 100 个有摘要的符号中 **66 个是英文注释**
|
||||||
|
// (`RegisterTool registers a tool that the LLM can call.`),
|
||||||
|
// 于是搜「注册工具」找不到 RegisterTool —— 而受众主要是中文。
|
||||||
|
//
|
||||||
|
// 解法不是翻译源码注释(那会让代码与文档脱节),而是给检索索引补一层
|
||||||
|
// **人工标注的功能词**:不影响页面展示,只让中文能搜到。
|
||||||
|
//
|
||||||
|
// 词表在 tools/apidoc/keywords.json。规则用「前缀 → 词」批量覆盖
|
||||||
|
// (Register* 全带「注册」),少数重点符号再逐条补充。
|
||||||
|
type keywordTable struct {
|
||||||
|
Rules []keywordRule `json:"rules"`
|
||||||
|
Symbols map[string][]string `json:"symbols"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type keywordRule struct {
|
||||||
|
// Prefix 匹配符号名前缀;Contains 匹配名字里是否含该子串。二选一。
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
Contains string `json:"contains,omitempty"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadKeywords 读词表。找不到就返回空表(不报错中断)——
|
||||||
|
// 检索关键词是**增强**,缺失时退化为原行为,不该让构建失败。
|
||||||
|
func loadKeywords() (*keywordTable, error) {
|
||||||
|
path := keywordPath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return &keywordTable{}, err
|
||||||
|
}
|
||||||
|
var t keywordTable
|
||||||
|
if err := json.Unmarshal(data, &t); err != nil {
|
||||||
|
return &keywordTable{}, fmt.Errorf("解析 %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup 返回某符号的中文检索词(可能为空)。
|
||||||
|
func (t *keywordTable) lookup(name, docBrief string) []string {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
add := func(ws []string) {
|
||||||
|
for _, w := range ws {
|
||||||
|
w = strings.TrimSpace(w)
|
||||||
|
if w == "" || seen[w] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[w] = true
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range t.Rules {
|
||||||
|
if r.Prefix != "" && strings.HasPrefix(name, r.Prefix) {
|
||||||
|
add(r.Words)
|
||||||
|
}
|
||||||
|
if r.Contains != "" && strings.Contains(name, r.Contains) {
|
||||||
|
add(r.Words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(t.Symbols[name])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// keywordPath 找 keywords.json:可执行文件旁 → 源码目录 → 工作目录。
|
||||||
|
func keywordPath() string {
|
||||||
|
candidates := []string{}
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "keywords.json"))
|
||||||
|
}
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join("tools", "apidoc", "keywords.json"),
|
||||||
|
"keywords.json",
|
||||||
|
)
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user