mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-06 23:53:54 +00:00
docs: 插件 SDK 文档站(API 参考从源码生成 + 自建检索)
为插件作者建一个文档站,重点是**能按描述搜到 API**,以及**明确能力边界**。
## 为什么 API 参考要生成而不是手写
公开 API 面有 115 个符号、11 个接口。手抄必然与代码漂移——这是文档站最常见的
死法(本仓 README 里已经有过几处「文档说一套、代码是另一套」)。
所以 `tools/apidoc` 直接从 `sdk/*.go` 提取签名、文档注释与代码块示例,渲染成
`docs/api/*.md`。发现文档不对时改的是**源码注释**,不是生成物。生成页首行带
「勿手改」标记,防止有人改了下次构建白改。
- `extract.go`:go/ast + go/doc 提取(只用标准库,离线可跑,不引入依赖)
- `gensite/`:渲染 Markdown + 检索索引
- `gensite/usages.go`:从 `example/` 21 个示例插件里反查**真实调用点**,
贴在每个 API 下(源码注释里几乎没有可运行示例,但示例插件都是能编译跑的真代码)
## 能力边界:写这个站时查出的三处文档错误
这是本次最有价值的部分。原以为「公开 SDK 里有的 API 外部插件都能用」,
实测对照桥接模板后发现三处不符,站内已更正:
1. **`PluginMgr()` 被写成「仅内置可用」——错的。** 桥接模板第 692 行显式
`base.SetPluginMgrAPI(procPluginMgr{})`,公开 `PluginMgrAPI` 注释也写「外部插件可调用」。
真正的区别是**方法数**:公开面 3 个(ReloadOne/ListLoadedPlugins/IsPluginDisabled),
内部面 9 个。容易混淆是因为两个包里有同名但不同的接口。
2. **`Events()` 外部插件恒为 nil。** `SetEventSubscriber` 全仓只有定义、无调用点,
故 subscriber 从未被注入。外部插件的事件订阅实际由生成的运行时走
`events.subscribe` RPC 完成——旧文档把它当成可用入口,会让人写出必然失效的代码。
3. **`UnregisterOutputChannel` 是静默无效,不是报错。** 桥接只注入 registrar、
不注入 unregistrar,于是 `regOutputUnreg == nil`,函数命中 else 分支**直接返回 nil**
(sdk/plugin.go:539-549)——不报错、通道也没注销。
每条裁定的依据写进 `tools/apidoc/tiers.json`(文件:行号 或 grep 结论),
站上以告警框呈现,读者可自行核对。判断依据三源:桥接模板的 `base.Set*` 注入点、
`internal/sdk` 完整面、`internal/plugin/proc/protocol.go` 的 RPC 表。
## 检索(用户的核心诉求)
两套互补:
- **MkDocs 内置搜索**:全文,中文走 jieba 分词。
- **自建 API 检索**(`docs/javascripts/api-search.js` + `assets/api-index.json`):
支持四类查询——按名称、**按功能描述**(「注册工具」→ RegisterTool、
「崩溃」→ SetAutoRestart)、按 `限定符.方法`(`memory.recall` → MemoryAPI.Recall)、
按签名片段(`(string) error`)。并标出「仅内置」,避免外部插件作者踩空。
自建的理由:Material 内置搜索按整页文本索引,搜 `InjectText` 会列出所有提到它的
页面,但分不清哪条是它的定义;而且它要等 mkdocs build 才更新。
## 文档结构
- `docs/guide/`:快速开始、Go/Lua 首个插件、能力边界、打包发布、多平台、受限 SDK 与安全
- `docs/api/`:10 个按「你想做什么」划分的章节(工具/阶段/记忆/通道/配置/生命周期/
事件/LLM/常量/桥接)+ 仅内置汇总页
- `docs/versions.md`:SDK 版本语义(跟随内核中版本、patch 恒为 .0)、
1.0.0 是唯一破坏性变更、RPC 协议版本
## 验证
- `mkdocs build --strict` 零告警
- 23 个页面的全部站内链接与锚点可达(自动校验)
- 1440 / 768 / 390px 三视口:无横向溢出、无控制台错误
- 四种检索模式实测有结果且跳转锚点正确
- 构建产物 `site_build/` 已 gitignore
用法:`tools/apidoc/build.sh`(生成+构建)、`tools/apidoc/build.sh serve`(预览)。
This commit is contained in:
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,不要提交。
|
||||
34
tools/apidoc/build.sh
Executable file
34
tools/apidoc/build.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 生成并构建插件 SDK 文档站。
|
||||
#
|
||||
# 两步:
|
||||
# 1. apidoc —— 从 sdk/*.go 提取公开 API 面(签名/注释/分层)→ JSON
|
||||
# 2. gensite —— 把 JSON 渲染成 docs/api/*.md + 检索索引
|
||||
# 然后 mkdocs 构建静态站。
|
||||
#
|
||||
# 为什么要脚本而不是手敲: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"
|
||||
|
||||
echo "=== 1/3 提取 API 面 ==="
|
||||
go run ./tools/apidoc -pkgdir ./sdk -out "$TMP_API"
|
||||
|
||||
echo "=== 2/3 渲染文档页与检索索引 ==="
|
||||
go run ./tools/apidoc/gensite -api "$TMP_API" -out ./docs -examples ./example
|
||||
|
||||
echo "=== 3/3 构建静态站 ==="
|
||||
if [ "${1:-}" = "serve" ]; then
|
||||
exec mkdocs serve
|
||||
fi
|
||||
mkdocs build --strict
|
||||
echo
|
||||
echo "完成。产物在 site_build/,本地预览:tools/apidoc/build.sh serve"
|
||||
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
|
||||
}
|
||||
596
tools/apidoc/gensite/main.go
Normal file
596
tools/apidoc/gensite/main.go
Normal file
@ -0,0 +1,596 @@
|
||||
// Command gensite 把 apidoc 提取出的 api.json 渲染成文档站的 Markdown 页面,
|
||||
// 并额外产出一份供浏览器即时检索的索引。
|
||||
//
|
||||
// 为什么要「生成」而不是手写:API 面有 100+ 个符号,手抄必然与源码漂移。
|
||||
// 这里的每个签名、每段说明都直接来自源码注释,因此文档只在「人写的指南」
|
||||
// 部分才需要人工维护。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json # 先提取
|
||||
// go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs # 再渲染
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 与 extract.go 的 Package 结构对应(两个 main 包不共享代码,故重复声明)。
|
||||
type Symbol struct {
|
||||
Kind string `json:"kind"`
|
||||
Recv string `json:"recv"`
|
||||
Name string `json:"name"`
|
||||
Signature string `json:"signature"`
|
||||
Doc string `json:"doc"`
|
||||
DocBrief string `json:"doc_brief"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Group string `json:"group"`
|
||||
Exported bool `json:"exported"`
|
||||
Examples []string `json:"examples"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
BuiltinOnly bool `json:"builtin_only"`
|
||||
Tier string `json:"tier"`
|
||||
TierReason string `json:"tier_reason"`
|
||||
}
|
||||
|
||||
type Interface struct {
|
||||
Name string `json:"name"`
|
||||
Doc string `json:"doc"`
|
||||
Methods []Symbol `json:"methods"`
|
||||
}
|
||||
|
||||
type ConstGroup struct {
|
||||
Doc string `json:"doc"`
|
||||
Consts []Symbol `json:"consts"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
ImportPath string `json:"import_path"`
|
||||
Doc string `json:"doc"`
|
||||
Symbols []Symbol `json:"symbols"`
|
||||
Interfaces []Interface `json:"interfaces"`
|
||||
ConstGroups []ConstGroup `json:"const_groups"`
|
||||
}
|
||||
|
||||
// siteSection 是 API 参考的一个页面。
|
||||
type siteSection struct {
|
||||
File string // 输出文件名(不含 .md)
|
||||
Title string // 页面标题
|
||||
Desc string // 页面导语
|
||||
Match func(Symbol) bool
|
||||
}
|
||||
|
||||
// 章节划分:按「插件作者想做什么」组织,而不是按 Go 的符号类别。
|
||||
// 这是文档好不好用的关键——作者是来找「怎么注册工具」的,不是来找 type 的。
|
||||
var sections = []siteSection{
|
||||
{
|
||||
File: "tools", Title: "工具(Tools)",
|
||||
Desc: "注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。",
|
||||
Match: func(s Symbol) bool {
|
||||
return s.Name == "RegisterTool" || s.Name == "ToolDef" || s.Name == "ToolHandler" || s.Name == "ToolCall" || s.Name == "ToolResult" || s.Name == "ContentBlock" || s.Name == "ToolCleaner"
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "stages", Title: "阶段钩子(Stages)",
|
||||
Desc: "在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。",
|
||||
Match: func(s Symbol) bool {
|
||||
return s.Group == "stages" || s.Name == "Stage" || s.Name == "StageHandler" || s.Name == "StageScope" || s.Name == "RegisterStage" || strings.HasPrefix(s.Name, "Stage")
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "memory", Title: "记忆(Memory)",
|
||||
Desc: "三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。",
|
||||
Match: func(s Symbol) bool {
|
||||
switch s.Name {
|
||||
case "MemoryAPI", "TextMemoryAPI", "DocMemoryAPI", "KnowledgeAPI", "Entity", "Relation", "Triple", "Doc", "TextEvent", "MediaAttachment", "Knowledge", "Memory", "TextMemory", "DocMemory", "DocQuery", "SocialAPI", "PersonProfile", "SocialRelation", "Social":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(s.Recv, "Memory") || strings.HasPrefix(s.Recv, "Doc") || strings.HasPrefix(s.Recv, "TextMemory") || strings.HasPrefix(s.Recv, "Knowledge")
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "channels", Title: "输入 / 输出通道",
|
||||
Desc: "通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。",
|
||||
Match: func(s Symbol) bool {
|
||||
switch s.Name {
|
||||
case "RegisterInputChannel", "RegisterOutputChannel", "ChannelDef", "CapText", "CapFile", "CapImage", "CapAudio", "CapStructured", "IOInjector", "InjectText", "InjectTextNoMemory", "InjectTextOpts", "InjectInterruptText", "InjectInterruptTextOpts", "InjectInputSync", "InjectInputSyncOpts", "InjectInputMedia", "InjectInputMediaSync", "InjectInputMediaOpts", "InjectInputMediaSyncOpts", "InjectInterruptMedia", "InjectInterruptMediaOpts", "InjectOptions", "ContextPolicyNone", "ContextPolicyPrune", "RecallPolicyNone", "RecallPolicyAuto", "SetToolBlocks", "ValidContextPolicy", "ValidRecallPolicy", "PriorityL1", "PriorityL2", "PriorityL3", "PriorityL4":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "settings", Title: "配置(Settings)",
|
||||
Desc: "声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。",
|
||||
Match: func(s Symbol) bool { return s.Name == "SettingsAPI" || s.Name == "ConfigDef" || s.Name == "Settings" },
|
||||
},
|
||||
{
|
||||
File: "lifecycle", Title: "生命周期(Lifecycle)",
|
||||
Desc: "插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。",
|
||||
Match: func(s Symbol) bool {
|
||||
switch s.Name {
|
||||
case "Plugin", "RegisterStopHandler", "RunStopHandlers", "RegisterOnRemoveHandler", "RunOnRemoveHandlers", "SetAutoRestart", "AutoRestart", "PluginName", "RegisterPluginAPI", "PluginMgr", "PluginMgrAPI", "Settings":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "events", Title: "事件(Events)",
|
||||
Desc: "订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。",
|
||||
Match: func(s Symbol) bool {
|
||||
return s.Name == "EventSubscriber" || s.Name == "Event" || s.Name == "EventType" || s.Name == "EventHandler" || s.Name == "Events" || strings.HasPrefix(s.Name, "Event")
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "llm", Title: "LLM 调用",
|
||||
Desc: "让插件自己调用模型(而不是只等模型来调你)。",
|
||||
Match: func(s Symbol) bool { return s.Name == "LLMAPI" || s.Name == "LLM" },
|
||||
},
|
||||
{
|
||||
File: "bridge", Title: "桥接装配点(Bridge)",
|
||||
Desc: "以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。",
|
||||
Match: func(s Symbol) bool {
|
||||
// 只匹配真正的「装配注入点」:Set*API 系列,以及三个 Register/Injector 注入点。
|
||||
// 注意不能用「Set 开头」一刀切 —— SetAutoRestart / SetToolBlocks 是
|
||||
// 插件业务代码会调的公开方法,不属于装配面,分别归入 lifecycle / channels。
|
||||
if s.Recv == "PluginSDK" && strings.HasPrefix(s.Name, "Set") && strings.HasSuffix(s.Name, "API") {
|
||||
return true
|
||||
}
|
||||
switch s.Name {
|
||||
case "APIRegistrar", "ToolRegistrar", "InputChannelRegistrar", "OutputChannelRegistrar", "OutputChannelUnregistrar",
|
||||
"SetIOInjector", "SetInputChannelRegistrar", "SetOutputChannelRegistrar", "SetOutputChannelUnregistrar", "SetEventSubscriber":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
File: "misc", Title: "其他类型",
|
||||
Desc: "剩余的类型与方法:`PluginSDK` 本体的访问器、`StageContext` 的并发控制,以及多模态辅助类型。没有归入上面任何一个主题,但可能仍会用到。",
|
||||
Match: func(s Symbol) bool { return true },
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
apiPath := flag.String("api", "/tmp/api.json", "apidoc 提取的 JSON")
|
||||
outDir := flag.String("out", "./docs", "文档站目录")
|
||||
exDir := flag.String("examples", "./example", "示例插件目录(用于抽取真实用法)")
|
||||
flag.Parse()
|
||||
|
||||
raw, err := os.ReadFile(*apiPath)
|
||||
if err != nil {
|
||||
log.Fatalf("读取 %s: %v", *apiPath, err)
|
||||
}
|
||||
var pkg Package
|
||||
if err := json.Unmarshal(raw, &pkg); err != nil {
|
||||
log.Fatalf("解析 %s: %v", *apiPath, err)
|
||||
}
|
||||
|
||||
apiDir := filepath.Join(*outDir, "api")
|
||||
if err := os.MkdirAll(apiDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 先收集所有待渲染的符号名,再扫 example/ 取真实用法。
|
||||
want := map[string]bool{}
|
||||
for _, s := range pkg.Symbols {
|
||||
if s.Exported {
|
||||
want[s.Name] = true
|
||||
}
|
||||
}
|
||||
for _, it := range pkg.Interfaces {
|
||||
want[it.Name] = true
|
||||
}
|
||||
usages := scanUsages(*exDir, want)
|
||||
|
||||
used := map[string]bool{}
|
||||
var index []indexEntry
|
||||
|
||||
for _, sec := range sections {
|
||||
var picked []Symbol
|
||||
for _, s := range pkg.Symbols {
|
||||
if !s.Exported || !sec.Match(s) || used[key(s)] {
|
||||
continue
|
||||
}
|
||||
picked = append(picked, s)
|
||||
used[key(s)] = true
|
||||
}
|
||||
// 该章节涉及的接口(其方法单独列在接口下,避免重复)。
|
||||
var ifaces []Interface
|
||||
for _, it := range pkg.Interfaces {
|
||||
if sec.Match(Symbol{Kind: "type", Name: it.Name}) {
|
||||
ifaces = append(ifaces, it)
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 && len(ifaces) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Slice(picked, func(i, j int) bool { return picked[i].Name < picked[j].Name })
|
||||
md := renderSection(sec, picked, ifaces, usages)
|
||||
if err := writeFile(filepath.Join(apiDir, sec.File+".md"), md); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, s := range picked {
|
||||
index = append(index, indexEntry{
|
||||
N: s.Name, S: s.Signature, D: s.DocBrief,
|
||||
K: s.Kind, R: s.Recv, P: sec.File,
|
||||
B: s.BuiltinOnly, F: s.File, L: s.Line,
|
||||
})
|
||||
}
|
||||
// 接口本身与其方法也要进索引。此前只加了顶层符号,导致
|
||||
// `MemoryAPI.Recall` 这类接口方法搜不到(只能靠页面浏览)。
|
||||
for _, it := range ifaces {
|
||||
index = append(index, indexEntry{
|
||||
N: it.Name, S: "type " + it.Name + " interface",
|
||||
D: briefOf(it.Doc), K: "interface", P: sec.File,
|
||||
})
|
||||
for _, m := range it.Methods {
|
||||
index = append(index, indexEntry{
|
||||
N: m.Name, S: m.Signature, D: m.DocBrief,
|
||||
K: "method", R: it.Name, P: sec.File,
|
||||
B: m.BuiltinOnly, F: m.File, L: m.Line,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 常量单独成页(它们是取值枚举,不是「怎么做」)。
|
||||
if md := renderConstants(pkg.ConstGroups); md != "" {
|
||||
if err := writeFile(filepath.Join(apiDir, "constants.md"), md); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, g := range pkg.ConstGroups {
|
||||
for _, c := range g.Consts {
|
||||
index = append(index, indexEntry{
|
||||
N: c.Name, S: "const " + c.Name, D: c.DocBrief,
|
||||
K: "const", P: "constants", F: c.File, L: c.Line,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 仅内置 API 汇总页——这是「真实的私密边界」的显式落点。
|
||||
if md := renderBuiltinOnly(pkg); md != "" {
|
||||
if err := writeFile(filepath.Join(apiDir, "builtin-only.md"), md); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端即时检索索引。
|
||||
//
|
||||
// 除了「按名称」与「按签名」,还带 examples/ 里的真实调用点,
|
||||
// 因为用户的核心诉求是「按描述搜到 API」——描述文本与用法片段
|
||||
// 一起进索引,搜「发消息」能找到 InjectText,搜「注册工具」能找到 RegisterTool。
|
||||
//
|
||||
// 去重:同一符号可能同时作为「顶层方法」与「接口方法」被扫到
|
||||
// (如 PluginSDK 方法与接口方法共享名字)。按 接收者.名称 去掉重复,
|
||||
// 否则搜索结果里同一 API 会出现两次。
|
||||
index = dedupeIndex(index)
|
||||
sort.Slice(index, func(i, j int) bool { return index[i].N < index[j].N })
|
||||
if err := writeJSON(filepath.Join(*outDir, "assets", "api-index.json"), index); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 示例插件总览页(真实调用点也在这里汇总)。
|
||||
if err := writeFile(filepath.Join(*outDir, "examples", "index.md"), renderExamples(usages)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
sort.Slice(pkg.Symbols, func(i, j int) bool { return pkg.Symbols[i].Name < pkg.Symbols[j].Name })
|
||||
fmt.Fprintf(os.Stderr, "生成 %d 个章节 + 检索索引 %d 条 → %s\n",
|
||||
len(sections), len(index), apiDir)
|
||||
}
|
||||
|
||||
type indexEntry struct {
|
||||
N string `json:"n"` // 名称
|
||||
S string `json:"s"` // 签名
|
||||
D string `json:"d"` // 描述摘要
|
||||
K string `json:"k"` // 类别
|
||||
R string `json:"r"` // 接收者
|
||||
P string `json:"p"` // 所属页面
|
||||
B bool `json:"b"` // 仅内置
|
||||
F string `json:"f"` // 源文件
|
||||
L int `json:"l"` // 行号
|
||||
}
|
||||
|
||||
func key(s Symbol) string {
|
||||
if s.Recv != "" {
|
||||
return s.Recv + "." + s.Name
|
||||
}
|
||||
return s.Kind + "." + s.Name
|
||||
}
|
||||
|
||||
func briefOf(doc string) string {
|
||||
for _, line := range strings.Split(doc, "\n") {
|
||||
if t := strings.TrimSpace(line); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// genBanner 是生成页的首页标记。
|
||||
//
|
||||
// 必须有:docs/api/*.md 会被提交进仓,而它们下次构建就被覆盖。
|
||||
// 没有这行提示,别人手改一处再发现改动消失,会以为是自己弄错了。
|
||||
const genBanner = "<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->\n\n"
|
||||
|
||||
// renderSection 渲染一个 API 章节。
|
||||
func renderSection(sec siteSection, syms []Symbol, ifaces []Interface, usages map[string][]Usage) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
fmt.Fprintf(&b, "# %s\n\n%s\n\n", sec.Title, sec.Desc)
|
||||
|
||||
// 接口优先展示(它们是「能力清单」),再列独立符号。
|
||||
for _, it := range ifaces {
|
||||
fmt.Fprintf(&b, "## `%s`\n\n", it.Name)
|
||||
if it.Doc != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", it.Doc)
|
||||
}
|
||||
if len(it.Methods) > 0 {
|
||||
b.WriteString("| 方法 | 说明 |\n|---|---|\n")
|
||||
for _, m := range it.Methods {
|
||||
// 锚点必须与标题逐字对应:标题是 `接口.方法`,slug 会把点号丢掉。
|
||||
fmt.Fprintf(&b, "| [`%s`](#%s) | %s |\n",
|
||||
m.Name, anchor(it.Name+"."+m.Name), escapePipe(m.DocBrief))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, m := range it.Methods {
|
||||
b.WriteString(renderSymbol(m, usages[m.Name]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range syms {
|
||||
b.WriteString(renderSymbol(s, usages[s.Name]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderSymbol 渲染单个符号。签名放进代码块便于复制,说明保留原文 markdown。
|
||||
func renderSymbol(s Symbol, uses []Usage) string {
|
||||
var b strings.Builder
|
||||
|
||||
name := s.Name
|
||||
if s.Recv != "" {
|
||||
name = s.Recv + "." + s.Name
|
||||
}
|
||||
fmt.Fprintf(&b, "### `%s`\n\n", name)
|
||||
|
||||
if s.BuiltinOnly {
|
||||
b.WriteString("!!! warning \"仅内核内置插件可用\"\n")
|
||||
if s.TierReason != "" {
|
||||
b.WriteString(indent(s.TierReason, " ") + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
} else if s.Tier == "bridge" {
|
||||
b.WriteString("!!! info \"桥接装配点\"\n")
|
||||
b.WriteString(indent(s.TierReason, " ") + "\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("```go\n")
|
||||
b.WriteString(strings.TrimSpace(s.Signature))
|
||||
b.WriteString("\n```\n\n")
|
||||
|
||||
if s.Doc != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", strings.TrimSpace(s.Doc))
|
||||
}
|
||||
if s.Deprecated {
|
||||
b.WriteString("!!! danger \"已废弃\"\n 不要在新代码里使用。\n\n")
|
||||
}
|
||||
for _, ex := range s.Examples {
|
||||
b.WriteString("**示例**\n\n```go\n" + ex + "\n```\n\n")
|
||||
}
|
||||
if len(uses) > 0 {
|
||||
b.WriteString("**示例插件里的真实用法**\n\n")
|
||||
b.WriteString("| 插件 | 位置 | 代码 |\n|---|---|---|\n")
|
||||
for _, u := range uses {
|
||||
fmt.Fprintf(&b, "| [`%s`](../examples/index.md#%s) | `%s:%d` | `%s` |\n",
|
||||
u.Plugin, u.Plugin, u.File, u.Line, escapePipe(u.Code))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if s.File != "" {
|
||||
fmt.Fprintf(&b, "<small>`%s:%d`</small>\n\n", s.File, s.Line)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderConstants(groups []ConstGroup) string {
|
||||
if len(groups) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
b.WriteString("# 常量与枚举\n\n")
|
||||
b.WriteString("SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。\n\n")
|
||||
for _, g := range groups {
|
||||
title := "相关取值"
|
||||
if len(g.Consts) > 0 {
|
||||
title = g.Consts[0].Name
|
||||
if len(g.Consts) > 1 {
|
||||
title += " 等"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "## %s\n\n", title)
|
||||
if g.Doc != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", strings.TrimSpace(g.Doc))
|
||||
}
|
||||
b.WriteString("| 名称 | 说明 |\n|---|---|\n")
|
||||
for _, c := range g.Consts {
|
||||
fmt.Fprintf(&b, "| `%s` | %s |\n", c.Name, escapePipe(c.DocBrief))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderBuiltinOnly 汇总仅内置符号——把「真正的私密边界」集中在一页,
|
||||
// 外部插件作者一眼能看出哪些不属于自己。
|
||||
func renderBuiltinOnly(pkg Package) string {
|
||||
var hits []Symbol
|
||||
var ifaceHits []struct {
|
||||
iface string
|
||||
m Symbol
|
||||
}
|
||||
for _, s := range pkg.Symbols {
|
||||
if s.BuiltinOnly {
|
||||
hits = append(hits, s)
|
||||
}
|
||||
}
|
||||
for _, it := range pkg.Interfaces {
|
||||
for _, m := range it.Methods {
|
||||
if m.BuiltinOnly {
|
||||
ifaceHits = append(ifaceHits, struct {
|
||||
iface string
|
||||
m Symbol
|
||||
}{it.Name, m})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(hits) == 0 && len(ifaceHits) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
b.WriteString("# 仅内置插件可用的 API\n\n")
|
||||
b.WriteString("这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上" +
|
||||
"不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。" +
|
||||
"列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。\n\n")
|
||||
b.WriteString("判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。\n\n")
|
||||
for _, s := range hits {
|
||||
b.WriteString(renderSymbol(s, nil))
|
||||
}
|
||||
for _, h := range ifaceHits {
|
||||
fmt.Fprintf(&b, "### `%s.%s`\n\n!!! warning \"仅内核内置插件可用\"\n", h.iface, h.m.Name)
|
||||
if h.m.TierReason != "" {
|
||||
b.WriteString(indent(h.m.TierReason, " ") + "\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "\n```go\n%s\n```\n\n", strings.TrimSpace(h.m.Signature))
|
||||
if h.m.Doc != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", strings.TrimSpace(h.m.Doc))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderExamples 汇总示例插件,并把每个插件用到的 API 列出。
|
||||
// 这是「想找一个能跑的参考实现」的入口。
|
||||
func renderExamples(usages map[string][]Usage) string {
|
||||
byPlugin := map[string]map[string]bool{}
|
||||
for api, list := range usages {
|
||||
for _, u := range list {
|
||||
if byPlugin[u.Plugin] == nil {
|
||||
byPlugin[u.Plugin] = map[string]bool{}
|
||||
}
|
||||
byPlugin[u.Plugin][api] = true
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(byPlugin))
|
||||
for n := range byPlugin {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
b.WriteString("# 示例插件\n\n")
|
||||
b.WriteString("SDK 仓 `example/` 下有多个**真实可编译**的示例插件," +
|
||||
"覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。\n\n")
|
||||
b.WriteString("每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。\n\n")
|
||||
for _, n := range names {
|
||||
apis := make([]string, 0, len(byPlugin[n]))
|
||||
for a := range byPlugin[n] {
|
||||
apis = append(apis, a)
|
||||
}
|
||||
sort.Strings(apis)
|
||||
fmt.Fprintf(&b, "## `%s`\n\n", n)
|
||||
fmt.Fprintf(&b, "用到的 API:%s\n\n", codeList(apis))
|
||||
}
|
||||
if len(names) == 0 {
|
||||
b.WriteString("(未找到示例插件调用点)\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// dedupeIndex 按「接收者.名称」去重。无接收者的用「类别.名称」。
|
||||
// 同名但不同接收者(如 MemoryAPI.Recall 与 IOInjector.InjectText)都保留。
|
||||
func dedupeIndex(in []indexEntry) []indexEntry {
|
||||
seen := map[string]bool{}
|
||||
out := in[:0]
|
||||
for _, e := range in {
|
||||
k := e.R + "." + e.N
|
||||
if e.R == "" {
|
||||
k = e.K + "." + e.N
|
||||
}
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func codeList(items []string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, s := range items {
|
||||
parts = append(parts, "`"+s+"`")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// anchor 复现 python-markdown 的 toc slugify:小写,去掉非字母数字与下划线
|
||||
// **以外**的字符(点号、反引号、括号都在此列),空格与下划线**保留为原形**。
|
||||
//
|
||||
// 实测确认:`### \`SettingsAPI.DataDir\“ → id="settingsapidatadir";
|
||||
// `## \`ai_image\“ → id="ai_image"(下划线保留,不转连字符)。
|
||||
// 所以调用方必须传**完整标题文本**(如 "SettingsAPI.DataDir"),不是裸方法名。
|
||||
func anchor(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
b.WriteRune(r)
|
||||
case r == ' ':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapePipe(s string) string { return strings.ReplaceAll(s, "|", "\\|") }
|
||||
|
||||
func indent(s, pad string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i := range lines {
|
||||
if strings.TrimSpace(lines[i]) != "" {
|
||||
lines[i] = pad + lines[i]
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(path, string(append(data, '\n')))
|
||||
}
|
||||
118
tools/apidoc/gensite/usages.go
Normal file
118
tools/apidoc/gensite/usages.go
Normal file
@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Usage 是一条真实用法:某个示例插件在某行用了这个 API。
|
||||
//
|
||||
// 为什么要有它:API 参考最容易变成「签名罗列」——读者看得到参数,
|
||||
// 却不知道该怎么用。SDK 的源码注释里几乎没有可运行示例(实测只有 7 行缩进
|
||||
// 代码块),但 example/ 下有 21 个**真实可编译**的示例插件。把它们里的调用点
|
||||
// 反查到每个 API 上,读者就能直接跳到能跑的代码。
|
||||
type Usage struct {
|
||||
Plugin string `json:"plugin"` // 示例名(example 下的目录名)
|
||||
File string `json:"file"` // 相对 SDK 仓根的路径
|
||||
Line int `json:"line"`
|
||||
Code string `json:"code"` // 该行原文(裁剪首尾空白)
|
||||
}
|
||||
|
||||
var callRe = regexp.MustCompile(`\.([A-Z][A-Za-z0-9_]*)\s*\(`)
|
||||
|
||||
// scanUsages 遍历 example/ 下所有 .go 文件,找出每个 API 的真实调用点。
|
||||
//
|
||||
// names 是要找的符号名集合(越小越快)。只扫 example/,不扫 tools/——
|
||||
// 工具链自己也会调 SDK,那属于内部实现,不是「用法示例」。
|
||||
func scanUsages(exDir string, names map[string]bool) map[string][]Usage {
|
||||
out := map[string][]Usage{}
|
||||
_ = filepath.Walk(exDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(filepath.Dir(exDir), path)
|
||||
plugin := pluginName(path, exDir)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
text := sc.Text()
|
||||
trimmed := strings.TrimSpace(text)
|
||||
// 跳过注释行:注释里提到 API 名不算「用法」。
|
||||
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
|
||||
continue
|
||||
}
|
||||
for _, m := range callRe.FindAllStringSubmatch(text, -1) {
|
||||
name := m[1]
|
||||
if !names[name] {
|
||||
continue
|
||||
}
|
||||
out[name] = append(out[name], Usage{
|
||||
Plugin: plugin, File: rel, Line: line, Code: truncate(trimmed, 110),
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for k := range out {
|
||||
sort.Slice(out[k], func(i, j int) bool {
|
||||
if out[k][i].Plugin != out[k][j].Plugin {
|
||||
return out[k][i].Plugin < out[k][j].Plugin
|
||||
}
|
||||
return out[k][i].Line < out[k][j].Line
|
||||
})
|
||||
// 每个 API 最多留 4 条,避免页面被用法淹没;优先保留不同插件。
|
||||
out[k] = dedupeByPlugin(out[k], 4)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pluginName 从路径里取示例名:example/<plugin>/....go → <plugin>。
|
||||
func pluginName(path, exDir string) string {
|
||||
rel, err := filepath.Rel(exDir, path)
|
||||
if err != nil {
|
||||
return "?"
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
func dedupeByPlugin(in []Usage, limit int) []Usage {
|
||||
seen := map[string]int{}
|
||||
var out []Usage
|
||||
for _, u := range in {
|
||||
if seen[u.Plugin] >= 1 {
|
||||
continue
|
||||
}
|
||||
seen[u.Plugin]++
|
||||
out = append(out, u)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
149
tools/apidoc/tiers.go
Normal file
149
tools/apidoc/tiers.go
Normal file
@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Tier 是符号的可见级别。
|
||||
const (
|
||||
TierPublic = "public" // 外部(第三方)插件可用
|
||||
TierBuiltin = "builtin" // 仅内核内置插件可用
|
||||
TierBridge = "bridge" // 桥接运行时注入的装配点(插件业务代码不调)
|
||||
)
|
||||
|
||||
// tierTable 对应 tools/apidoc/tiers.json 的结构。
|
||||
type tierTable struct {
|
||||
Public struct {
|
||||
Source string `json:"_source"`
|
||||
Methods []string `json:"methods"`
|
||||
} `json:"public"`
|
||||
Bridge struct {
|
||||
Doc string `json:"_doc"`
|
||||
Source string `json:"_source"`
|
||||
Methods []string `json:"methods"`
|
||||
} `json:"bridge"`
|
||||
TierOverrides map[string]json.RawMessage `json:"tier_overrides"`
|
||||
Interfaces map[string]json.RawMessage `json:"interfaces"`
|
||||
}
|
||||
|
||||
// applyTiers 把能力分层写回符号。分层表与源码一样是事实源:
|
||||
// 找不到分层表就报错,不静默降级——否则文档会悄悄丢掉「仅内置」标记。
|
||||
func applyTiers(p *Package) error {
|
||||
path := tierPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 %s: %w", path, err)
|
||||
}
|
||||
var t tierTable
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return fmt.Errorf("解析 %s: %w", path, err)
|
||||
}
|
||||
|
||||
pub := map[string]bool{}
|
||||
for _, m := range t.Public.Methods {
|
||||
pub[m] = true
|
||||
}
|
||||
brg := map[string]bool{}
|
||||
for _, m := range t.Bridge.Methods {
|
||||
brg[m] = true
|
||||
}
|
||||
|
||||
// tier_overrides 里既有 "_doc" 这类说明键(值是数组),也有真正的裁定
|
||||
// (值是对象)。逐一解码,跳过 _ 开头的说明键。
|
||||
overrides := map[string]struct {
|
||||
Tier string `json:"tier"`
|
||||
Reason string `json:"reason"`
|
||||
}{}
|
||||
for name, raw := range t.TierOverrides {
|
||||
if len(name) > 0 && name[0] == '_' {
|
||||
continue
|
||||
}
|
||||
var v struct {
|
||||
Tier string `json:"tier"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return fmt.Errorf("tier_overrides[%s]: %w", name, err)
|
||||
}
|
||||
overrides[name] = v
|
||||
}
|
||||
|
||||
// 接口级裁定先落到接口本身;其方法继承接口的 tier。
|
||||
ifaceTier := map[string]string{}
|
||||
for name, raw := range t.Interfaces {
|
||||
if len(name) > 0 && name[0] == '_' {
|
||||
continue
|
||||
}
|
||||
var v struct {
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return fmt.Errorf("interfaces[%s]: %w", name, err)
|
||||
}
|
||||
ifaceTier[name] = v.Tier
|
||||
}
|
||||
|
||||
tierOf := func(s Symbol) (string, string) {
|
||||
// 1) 逐符号覆盖优先(它带 reason,最有信息量)。
|
||||
if o, ok := overrides[s.Name]; ok {
|
||||
return o.Tier, o.Reason
|
||||
}
|
||||
// 2) 接口方法:跟随接口的 tier。
|
||||
if s.Recv != "" {
|
||||
if tier, ok := ifaceTier[s.Recv]; ok {
|
||||
return tier, fmt.Sprintf("接口 %s 的层级裁定", s.Recv)
|
||||
}
|
||||
}
|
||||
// 3) 桥接注入点。
|
||||
if brg[s.Name] {
|
||||
return TierBridge, "桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)"
|
||||
}
|
||||
// 4) 显式公开名单。
|
||||
if pub[s.Name] {
|
||||
return TierPublic, "桥接模板注入或 proc* 实现,外部插件可用"
|
||||
}
|
||||
// 5) 公开包里的方法默认公开;内部专有符号不会出现在本包里。
|
||||
if s.Recv == "PluginSDK" || s.Recv == "StageContext" {
|
||||
return TierPublic, "公开 SDK 的方法,未列入 bridge/内置清单"
|
||||
}
|
||||
return TierPublic, ""
|
||||
}
|
||||
|
||||
for i := range p.Symbols {
|
||||
tier, reason := tierOf(p.Symbols[i])
|
||||
p.Symbols[i].Tier = tier
|
||||
p.Symbols[i].BuiltinOnly = tier == TierBuiltin
|
||||
p.Symbols[i].TierReason = reason
|
||||
}
|
||||
for i := range p.Interfaces {
|
||||
if tier, ok := ifaceTier[p.Interfaces[i].Name]; ok {
|
||||
for j := range p.Interfaces[i].Methods {
|
||||
p.Interfaces[i].Methods[j].Tier = tier
|
||||
p.Interfaces[i].Methods[j].BuiltinOnly = tier == TierBuiltin
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tierPath 找 tiers.json:先看可执行文件旁,再看源码目录,最后看工作目录。
|
||||
// 这样 `go run ./tools/apidoc` 与编译后的二进制都能找到它。
|
||||
func tierPath() string {
|
||||
candidates := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "tiers.json"))
|
||||
}
|
||||
candidates = append(candidates,
|
||||
filepath.Join("tools", "apidoc", "tiers.json"),
|
||||
"tiers.json",
|
||||
)
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
157
tools/apidoc/tiers.json
Normal file
157
tools/apidoc/tiers.json
Normal file
@ -0,0 +1,157 @@
|
||||
{
|
||||
"_doc": [
|
||||
"能力分层表:决定文档站上每个 API 的可见级别。",
|
||||
"",
|
||||
"tier 取值:",
|
||||
" public —— 外部(第三方)插件可用。这是文档站的主体。",
|
||||
" builtin —— 仅内核内置插件可用。外部插件调用会失败或拿到 nil。",
|
||||
" bridge —— 由桥接运行时注入的装配点(SetXxxAPI),插件业务代码不该调;",
|
||||
" 但它是公开 SDK 的一部分,故列出并标注用途。",
|
||||
"",
|
||||
"**每条判断都必须能追溯到源码**,依据记在 reason 里。不要凭文档注释推测——",
|
||||
"实测发现文档与源码有三处不符(见下 tier_overrides 的注释)。"
|
||||
],
|
||||
|
||||
"public": {
|
||||
"_source": "hmapdev 桥接模板 tools/hmapdev/templates/proc_main.go.tmpl 的 buildPluginSDK():凡被 base.Set* 注入或在 procIO/procMemory/... 上实现的,外部插件都能真调到。",
|
||||
"methods": [
|
||||
"PluginName",
|
||||
"Settings",
|
||||
"Memory", "TextMemory", "DocMemory", "Knowledge", "LLM", "Social",
|
||||
"PluginMgr",
|
||||
"RegisterTool", "RegisterStage", "RegisterPluginAPI",
|
||||
"RegisterOutputChannel", "RegisterInputChannel",
|
||||
"InjectText", "InjectTextNoMemory", "InjectTextOpts",
|
||||
"InjectInterruptText", "InjectInterruptTextOpts",
|
||||
"InjectInputSync", "InjectInputSyncOpts",
|
||||
"InjectInputMedia", "InjectInputMediaSync", "InjectInputMediaOpts",
|
||||
"InjectInputMediaSyncOpts", "InjectInterruptMedia", "InjectInterruptMediaOpts",
|
||||
"SetToolBlocks",
|
||||
"SetAutoRestart", "AutoRestart",
|
||||
"RegisterStopHandler", "RunStopHandlers",
|
||||
"RegisterOnRemoveHandler", "RunOnRemoveHandlers"
|
||||
]
|
||||
},
|
||||
|
||||
"bridge": {
|
||||
"_doc": "桥接注入点:公开 SDK 的装配接口,外部插件的**业务代码不调用**它们,由 hmapdev 生成的运行时调用。文档里单列一节说明,不与业务 API 混排。",
|
||||
"_source": "proc_main.go.tmpl:685-705 逐个 base.Set* 调用。",
|
||||
"methods": [
|
||||
"SetIOInjector", "SetMemoryAPI", "SetTextMemoryAPI", "SetDocMemoryAPI",
|
||||
"SetKnowledgeAPI", "SetLLMAPI", "SetSocialAPI", "SetPluginMgrAPI",
|
||||
"SetInputChannelRegistrar",
|
||||
"SetOutputChannelRegistrar", "SetOutputChannelUnregistrar",
|
||||
"SetEventSubscriber"
|
||||
]
|
||||
},
|
||||
|
||||
"tier_overrides": {
|
||||
"_doc": [
|
||||
"逐符号的边界裁定。键是符号名,值是 {tier, reason}。",
|
||||
"reason 必须写出**可复核的依据**(文件:行 或 grep 结论),不接受「大概」。"
|
||||
],
|
||||
|
||||
"Events": {
|
||||
"tier": "builtin",
|
||||
"reason": "实测全仓 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 里没有说清的一处。"
|
||||
},
|
||||
|
||||
"SetEventSubscriber": {
|
||||
"tier": "builtin",
|
||||
"reason": "同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。"
|
||||
},
|
||||
|
||||
"UnregisterOutputChannel": {
|
||||
"tier": "builtin",
|
||||
"reason": "外部插件的桥接模板只注入 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,真正生效。外部插件要让通道下线,只能重载插件。"
|
||||
},
|
||||
|
||||
"SetOutputChannelUnregistrar": {
|
||||
"tier": "builtin",
|
||||
"reason": "同上,桥接模板不注入。"
|
||||
},
|
||||
|
||||
"PluginMgr": {
|
||||
"tier": "public",
|
||||
"reason": "proc_main.go.tmpl:692 显式注入 base.SetPluginMgrAPI(procPluginMgr{}),且 sdk/plugin.go:282 注释写明「外部插件可调用」。注意返回的 PluginMgrAPI 只有 3 个方法(ReloadOne / ListLoadedPlugins / IsPluginDisabled),与 internal/sdk 的完整 PluginManager(含 ReloadPlugins / DisablePlugin / RemovePlugin / ListDisabledPlugins / IsBuiltinPlugin 等)**不是同一个接口**——同名不同包,文档必须分清。PLUGIN_DEV.md:786 写「PluginMgr() 仅内置插件可用」是**错的**,已在本站更正。"
|
||||
},
|
||||
|
||||
"SetToolBlocks": {
|
||||
"tier": "public",
|
||||
"reason": "procIO 实现了它(proc_main.go.tmpl:749),模板在 base.SetIOInjector(procIO{}) 中注入。"
|
||||
},
|
||||
|
||||
"RunStopHandlers": {
|
||||
"tier": "public",
|
||||
"reason": "由生成的运行时在收到 plugin.stop 时调用(proc_main.go.tmpl:1274、1659),插件注册的 stop handler 由此触发;插件业务代码也可直接调。"
|
||||
},
|
||||
|
||||
"RunOnRemoveHandlers": {
|
||||
"tier": "public",
|
||||
"reason": "与 RunStopHandlers 同源;onRemove 语义见 sdk/plugin.go:824。"
|
||||
},
|
||||
|
||||
"PriorityL4": {
|
||||
"tier": "builtin",
|
||||
"reason": "sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。"
|
||||
},
|
||||
|
||||
"Subscribe": {
|
||||
"tier": "builtin",
|
||||
"reason": "Subscribe 只存在于内部 SDK(internal/sdk)与 Lua 桥;公开 sdk 的 EventSubscriber 接口虽声明了 Subscribe,但该接口实例在外部插件路径上恒为 nil(见 Events 条目)。"
|
||||
},
|
||||
|
||||
"Publish": {
|
||||
"tier": "builtin",
|
||||
"reason": "Publish 只在 internal/sdk/plugin.go:465(内置插件面)。公开 SDK 的 EventSubscriber 接口刻意只有 Subscribe 没有 Publish——sdk/plugin.go:276 注释「restricted interface: plugins can subscribe but the kernel controls which events are delivered」。"
|
||||
},
|
||||
|
||||
"OutputChan": {
|
||||
"tier": "builtin",
|
||||
"reason": "只存在于 internal/sdk/plugin.go:438。外部插件的输出能力是 RegisterOutputChannel + 内核回调(output.invoke),不是自己持有 chan。"
|
||||
},
|
||||
|
||||
"RegisterChannel": {
|
||||
"tier": "builtin",
|
||||
"reason": "internal/sdk/plugin.go:445 的内部面(agentIO.Device 直接注册)。外部插件用公开的 RegisterInputChannel / RegisterOutputChannel。"
|
||||
},
|
||||
|
||||
"ListChannels": {
|
||||
"tier": "builtin",
|
||||
"reason": "internal/sdk/plugin.go:458。PLUGIN_DEV.md:530 已正确说明这些方法(RegisterChannel/UnregisterChannel/ListChannels/InjectInput 等)仅内置插件可用。"
|
||||
},
|
||||
|
||||
"InjectInput": {
|
||||
"tier": "builtin",
|
||||
"reason": "internal/sdk/plugin.go:413。外部插件用公开的 InjectText / InjectInputSync / InjectTextOpts 等。"
|
||||
},
|
||||
|
||||
"InjectInterrupt": {
|
||||
"tier": "builtin",
|
||||
"reason": "internal/sdk/plugin.go:420。外部插件用 InjectInterruptText / InjectInterruptMedia。"
|
||||
}
|
||||
},
|
||||
|
||||
"interfaces": {
|
||||
"_doc": "接口级裁定:外部插件拿到的是「受限接口」,方法是子集。",
|
||||
"SocialAPI": {
|
||||
"tier": "public",
|
||||
"reason": "外部插件由 proc_main.go.tmpl:690 注入 procSocial。注意公开 SocialAPI 只有 6 个**只读**方法(GetPerson/GetTrait/GetRelations/GetNetwork/ListPersons + 见 sdk/memory.go:100)。写操作(SetTrait/AddRelation 等)不在公开接口里——这是「受限 SDK」的实现方式:**按接口裁剪,而非按方法裁剪**。"
|
||||
},
|
||||
"EventSubscriber": {
|
||||
"tier": "builtin",
|
||||
"reason": "接口在公开包里,但实例恒 nil(见 Events)。"
|
||||
},
|
||||
"MemoryAPI": { "tier": "public", "reason": "proc_main.go.tmpl:686 注入 procMemory。" },
|
||||
"TextMemoryAPI": { "tier": "public", "reason": "proc_main.go.tmpl:691。" },
|
||||
"DocMemoryAPI": { "tier": "public", "reason": "proc_main.go.tmpl:687。" },
|
||||
"KnowledgeAPI": { "tier": "public", "reason": "proc_main.go.tmpl:688。" },
|
||||
"LLMAPI": { "tier": "public", "reason": "proc_main.go.tmpl:689。" },
|
||||
"SettingsAPI": { "tier": "public", "reason": "sdk 构造函数第三参数注入 procSettings(proc_main.go.tmpl:640)。" },
|
||||
"IOInjector": { "tier": "public", "reason": "proc_main.go.tmpl:685 注入 procIO。" },
|
||||
"PluginMgrAPI": {
|
||||
"tier": "public",
|
||||
"reason": "proc_main.go.tmpl:692。只有 3 个方法——与内部 PluginManager 不同。"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user