Files
homeagent-sdk/docs/javascripts/api-search.js
JianFeeeee 0a6e2b7dc4 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`(预览)。
2026-09-24 12:08:37 +08:00

246 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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;
// 限定符: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;
}
// 中文按字匹配:中文没有词边界,逐字命中比整串更实用。
if (/[\u4e00-\u9fa5]/.test(q)) {
var hit = 0;
for (var i = 0; i < q.length; i++) {
if (desc.indexOf(q[i]) >= 0) hit++;
}
if (hit === q.length) s += 100; // 全部字都出现
else s += hit * 8;
}
// 公开 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.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();
}
})();