Files
homeagent-sdk/tools/apidoc/gensite/agent.go
JianFeeeee 255d6479ad feat(docs): 换品牌图标 + 补备案号 + 给 agent 的直读入口
三件事,都是「站点上线后看出来的」问题。

## ① 图标用的是 Material 默认,不是我们的

站上 favicon 是 mkdocs-material 自带的那张(`assets/images/favicon.png`),
与主站 introduce 不一致。改为引入主站同一份 logo:

- `docs/assets/logo.svg` —— 原样(浅色底,适合 favicon)
- `docs/assets/logo-mark.svg` —— 去掉底色矩形(否则在靛蓝页头上是个白方块)

`theme.favicon` / `theme.logo` 分别指向它们。实测子页面路径也正确
(mkdocs 生成 `../../assets/logo.svg`,不是错误的相对路径)。

## ② 缺备案号

`introduce` 底部有 ICP + 公安备案,文档站没有。Material 的 footer 只渲染
`config.copyright` 一个字符串,塞不进「两条带链接的备案」——所以覆盖了
`overrides/partials/footer.html`(`theme.custom_dir`),并顺带把许可也写进底部,
读者在任何一页都能看到,不必翻到首页。

## ③ 没有给 agent 的入口

站点是给人看的(HTML + 主题 + JS 搜索),但越来越多读者是 agent。让它们爬
HTML 既浪费 token(样板占大头)又容易漏内容。

新增 `tools/apidoc/gensite/agent.go`,随构建产出:

| 路径 | 内容 |
|---|---|
| `/llms.txt` | 站点目录:每页一行,带 URL 与一句话说明(4.3KB)|
| `/llms-full.txt` | 全部文档正文拼成一份,可一次读完(115KB)|
| `/<page>.md` | 每个页面的 Markdown 原文,含生成的 API 页(text/markdown)|

沿用 llms.txt 社区约定:`llms.txt` 读目录、`llms-full.txt` 一次读全。

**一个必须处理的坑**:mkdocs 只把 `.md` **渲染**成 HTML,不会把 Markdown
放进产物目录 —— 那样 `llms.txt` 里的链接会全部 404。所以 `build.sh` 增加了
第 4 步 `copy_agent_files`,在构建后把 23 个 Markdown 复制进 `site_build/`。

`docs/llms-full.txt` 已 gitignore:它是派生件,改任何一页都会整份重写,
进版本库只产生噪声(`llms.txt` 索引小且稳定,仍提交)。

验证:`mkdocs build --strict` 零告警;favicon/logo 可取(200,naturalWidth=400);
三页底部均含两个备案号;4 个 agent 入口均可访问且 content-type 正确。
2026-09-24 13:27:58 +08:00

171 lines
5.7 KiB
Go
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.

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)
}