mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
让**外部 agent** 也能按分类树用这套知识库。HomeAgent 自己的 agent 仍
直接调内部方法(knowledge_search/create 等),走进程内直调,不经此服务。
一、内核树视图(internal/knowledge/tree.go)
为什么不复用 TreeIndex:那个是**内部导出物**,面向 .index.json 落盘,
每个条目带 top-20 的 TF-IDF 特征向量。直接序列化给外部有三个问题:
体积(200 条时 .index.json 已 246KB 且冗余存了 preview,而正本在
content.md)、泄漏(稀疏特征表 = 分词/IDF 内部表示)、语义错位
(外部要的是"有哪些分类、每类下有什么")。
新增 TreeView/Subtree/Categories/CategoryCounts:不含向量,带条目数
与可读摘要,支持 MaxDepth 懒加载、IncludeItems 只看结构。
节点 Name 是**本级段名**("go")、Path 是完整路径("tech/go")——
最初把全路径写进 Name,前端拼层级会得到 "tech/tech/go",已修。
二、kbtree 插件:独立 HTTP 服务(默认 127.0.0.1:9892)
为何不挂在 WebUI 的 /api/v1/knowledge* 下:
1. 不共享鉴权与端口。WebUI 的 api_key 是给人操作界面用的,把它分发给
外部 agent 等于把管理面凭据扩散出去。本服务用**独立 token** +
独立端口,可单独关闭(token 未配置则启动时随机生成)。
2. 只读。写入要决定分类归属与媒体处理,外部自行拼装容易造出越界/重名
条目 —— 写入留给内核工具。
3. 形状按树组织,而不是平铺搜索接口。
端点:/tree(可指定 category/depth/items)、/categories、/counts、
/search、/ (自述)。全部需 token(X-API-Key / Bearer / ?token=),
非 GET 一律 405。无知识库时 Start 直接失败,不占端口。
鉴权与 Slowloris/超时设置照 remotedevice 范式。
三、agent 技能(assets/skills/knowledge-base/SKILL.md)
指令文档型 skill:教模型"先看树 → 定位分类 → 分类内检索",并列出
易错点(name 已含分类别再拼、只看第一条、404 附现有分类)。
加载与校验由 internal/plugin/skill_bundled_test.go 守住 —— 这条断言
的由来:非白名单的二级标题会被 extractToolDefs 当成工具定义,报错
"invalid tool name",而提示与真正原因(标题层级)毫无关联。
kbtree 的测试还会校验文档提到的端点与代码一致,防漂移。
四、WebUI 树浏览(前端真正用起来,而非留一个没人调的端点)
面板加可折叠的分类树:逐级点选即把搜索范围切到该子树(原先是让人
手打分类名)。当前范围有可见标签与「全库」复位。
371 lines
11 KiB
Go
371 lines
11 KiB
Go
// Package kbtree 把知识库的**分类树**作为独立 HTTP 服务暴露给外部 agent。
|
||
//
|
||
// 定位(与 HomeAgent 内部知识工具的分工):
|
||
// - HomeAgent 自己的 agent **直接调内部方法**(knowledge_search /
|
||
// knowledge_create 等内核工具),走的是进程内直调,最快、也最少攻击面。
|
||
// - 其它 agent(别的进程、别的机器、别的语言写的)走本插件的 HTTP 接口。
|
||
//
|
||
// 为何独立成服务而不是复用 WebUI 的 /api/v1/knowledge*:
|
||
// 1. **不共享 WebUI 的鉴权与端口**。WebUI 的 api_key 是给人操作界面用的,
|
||
// 把它分发给外部 agent 等于把管理面凭据扩散出去。本服务用**独立 token**
|
||
// 且**独立端口**,可单独关闭。
|
||
// 2. **只读**。外部 agent 读知识库就够了;写入要决定分类归属与媒体处理,
|
||
// 让外部自行拼装反而容易造出越界/重名的条目 —— 写入留给内核工具。
|
||
// 3. 形状按树组织(分类导航、分类内检索、懒加载),而不是平铺的搜索接口。
|
||
//
|
||
// 配套的 assets/skills/knowledge-base/SKILL.md 是给 agent 读的指令文档,
|
||
// 两者配合即可"别的 agent 也能查这套知识库"。
|
||
package kbtree
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// defaultAddr 默认只监听本机。外部 agent 若在别的机器,改配置显式对外,
|
||
// 并自行确认 token 的分发方式(见 RegisterDef 里的说明)。
|
||
const defaultAddr = "127.0.0.1:9892"
|
||
|
||
func init() {
|
||
plugin.RegisterPluginMeta("kbtree", "知识库树服务", "Knowledge Tree Service")
|
||
plugin.RegisterFactory("kbtree", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return New(name), nil
|
||
})
|
||
}
|
||
|
||
type Plugin struct {
|
||
name string
|
||
sdkRef *sdk.PluginSDK
|
||
addr string
|
||
token string
|
||
server *http.Server
|
||
mux *http.ServeMux
|
||
// 启动时未显式配置 token 则自动生成(与 remotedevice 同策略)
|
||
generatedToken bool
|
||
}
|
||
|
||
func New(name string) *Plugin {
|
||
return &Plugin{
|
||
name: name,
|
||
addr: defaultAddr,
|
||
mux: http.NewServeMux(),
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) Name() string { return p.name }
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
if s == nil {
|
||
return fmt.Errorf("kbtree: SDK 不可用")
|
||
}
|
||
p.sdkRef = s
|
||
|
||
// Settings 缺失时不能直接 RegisterDef —— 那是空接口上的调用,会 panic。
|
||
// 缺 Settings 意味着宿主装配不完整(测试环境或裁剪版内核),
|
||
// 此时用默认值把服务跑起来,而不是让整个插件加载失败。
|
||
if set := s.Settings(); set != nil {
|
||
set.RegisterDef(sdk.ConfigDef{
|
||
Key: "listen_addr", Default: defaultAddr, Type: "string",
|
||
DisplayName: "监听地址", Category: "kbtree",
|
||
Description: "知识库树服务 HTTP 监听地址(默认 127.0.0.1:9892,仅本机)。改为对外地址前请确认 token 分发方式",
|
||
})
|
||
set.RegisterDef(sdk.ConfigDef{
|
||
Key: "token", Default: "", Type: "password",
|
||
DisplayName: "访问令牌", Category: "kbtree",
|
||
Description: "外部 agent 访问本服务所需的令牌;留空则启动时随机生成(仅本次运行有效)",
|
||
})
|
||
if v, _ := set.Get("listen_addr"); v != nil {
|
||
if a, ok := v.(string); ok && strings.TrimSpace(a) != "" {
|
||
p.addr = strings.TrimSpace(a)
|
||
}
|
||
}
|
||
if v, _ := set.Get("token"); v != nil {
|
||
if tk, ok := v.(string); ok && strings.TrimSpace(tk) != "" {
|
||
p.token = strings.TrimSpace(tk)
|
||
}
|
||
}
|
||
}
|
||
if p.token == "" {
|
||
p.token = genToken()
|
||
p.generatedToken = true
|
||
}
|
||
|
||
// 启动前确认知识库可用:不可用就别占着端口
|
||
kn := s.Knowledge()
|
||
if kn == nil {
|
||
return fmt.Errorf("kbtree: 知识库不可用,未启动服务(避免占端口后只回 503)")
|
||
}
|
||
|
||
p.registerRoutes()
|
||
p.server = &http.Server{
|
||
Addr: p.addr,
|
||
Handler: p.mux,
|
||
ReadHeaderTimeout: 5 * time.Second, // Slowloris 防护
|
||
WriteTimeout: 30 * time.Second,
|
||
IdleTimeout: 60 * time.Second,
|
||
}
|
||
go func() {
|
||
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
log.Printf("[kbtree] 服务错误: %v", err)
|
||
}
|
||
}()
|
||
|
||
if p.generatedToken {
|
||
log.Printf("[kbtree] 已启动 http://%s(token 未配置,本次随机生成;重启后失效)", p.addr)
|
||
} else {
|
||
log.Printf("[kbtree] 已启动 http://%s", p.addr)
|
||
}
|
||
log.Printf("[kbtree] 外部 agent 可用:GET /tree、/categories、/counts、/search?q=&category=")
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error {
|
||
if p.server != nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||
defer cancel()
|
||
return p.server.Shutdown(ctx)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// registerRoutes 挂载只读路由。全部需 token。
|
||
func (p *Plugin) registerRoutes() {
|
||
p.mux.HandleFunc("/tree", p.requireToken(p.handleTree))
|
||
p.mux.HandleFunc("/categories", p.requireToken(p.handleCategories))
|
||
p.mux.HandleFunc("/counts", p.requireToken(p.handleCounts))
|
||
p.mux.HandleFunc("/search", p.requireToken(p.handleSearch))
|
||
// 根路径给个自述,便于外部 agent 摸索
|
||
p.mux.HandleFunc("/", p.requireToken(p.handleRoot))
|
||
}
|
||
|
||
func (p *Plugin) requireToken(next http.HandlerFunc) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
k := r.Header.Get("X-API-Key")
|
||
if k == "" {
|
||
k = r.Header.Get("Authorization")
|
||
k = strings.TrimPrefix(k, "Bearer ")
|
||
k = strings.TrimSpace(k)
|
||
}
|
||
if k == "" {
|
||
k = r.URL.Query().Get("token")
|
||
}
|
||
if k == "" || k != p.token {
|
||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||
return
|
||
}
|
||
if r.Method != http.MethodGet {
|
||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "只支持 GET(本服务只读)"})
|
||
return
|
||
}
|
||
next(w, r)
|
||
}
|
||
}
|
||
|
||
// handleRoot 自述端点:告诉调用方有哪些接口可用。
|
||
func (p *Plugin) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/" {
|
||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"service": "kbtree",
|
||
"purpose": "HomeAgent 知识库分类树的只读访问(供外部 agent 使用)",
|
||
"read_only": true,
|
||
"endpoints": []string{
|
||
"GET /tree?category=&depth=&items= 分类树(可指定子树/层数)",
|
||
"GET /categories 全部分类路径(含中间层)",
|
||
"GET /counts 各分类条目数(按数量倒序)",
|
||
"GET /search?q=&category=&limit= 检索(category 前缀匹配子树)",
|
||
},
|
||
"auth": "X-API-Key 头 或 Authorization: Bearer <token> 或 ?token=",
|
||
"notes": "结果按相关度排序,只采用第一条;节点 name 是本级段名,path 是完整路径",
|
||
})
|
||
}
|
||
|
||
// handleTree 返回分类树。
|
||
func (p *Plugin) handleTree(w http.ResponseWriter, r *http.Request) {
|
||
kn := p.knowledge(w)
|
||
if kn == nil {
|
||
return
|
||
}
|
||
q := r.URL.Query()
|
||
opt := sdk.KnowledgeTreeOptions{
|
||
MaxDepth: intQ(q.Get("depth"), 0),
|
||
IncludeItems: boolQ(q.Get("items"), true),
|
||
PreviewLimit: intQ(q.Get("preview"), 0),
|
||
}
|
||
cat := strings.Trim(strings.TrimSpace(q.Get("category")), "/")
|
||
view, err := kn.Subtree(cat, opt)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
if view == nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]interface{}{
|
||
"error": "分类不存在: " + cat,
|
||
"categories": p.safeCategories(kn),
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"tree": view})
|
||
}
|
||
|
||
// handleCategories 返回平铺分类列表。
|
||
func (p *Plugin) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||
kn := p.knowledge(w)
|
||
if kn == nil {
|
||
return
|
||
}
|
||
names := p.safeCategories(kn)
|
||
if names == nil {
|
||
names = []string{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"categories": names})
|
||
}
|
||
|
||
// handleCounts 返回各分类条目数(倒序)。
|
||
func (p *Plugin) handleCounts(w http.ResponseWriter, r *http.Request) {
|
||
kn := p.knowledge(w)
|
||
if kn == nil {
|
||
return
|
||
}
|
||
counts, err := kn.CategoryCounts()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
if counts == nil {
|
||
counts = []sdk.KnowledgeCategoryCount{}
|
||
}
|
||
// 附总量:只数叶子分类,避免中间层重复计数
|
||
total := 0
|
||
byCat := make(map[string]int, len(counts))
|
||
for _, c := range counts {
|
||
byCat[c.Category] = c.Count
|
||
}
|
||
for c := range byCat {
|
||
if !isParentCategory(counts, c) {
|
||
total += byCat[c]
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"counts": counts, "total": total})
|
||
}
|
||
|
||
// handleSearch 按分类 + 关键词检索。
|
||
func (p *Plugin) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||
kn := p.knowledge(w)
|
||
if kn == nil {
|
||
return
|
||
}
|
||
q := r.URL.Query()
|
||
query := strings.TrimSpace(q.Get("q"))
|
||
if query == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "q 必填"})
|
||
return
|
||
}
|
||
limit := intQ(q.Get("limit"), 10)
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 10
|
||
}
|
||
cat := strings.Trim(strings.TrimSpace(q.Get("category")), "/")
|
||
results, err := kn.SearchIn(query, cat, limit)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
items := make([]map[string]interface{}, 0, len(results))
|
||
for _, k := range results {
|
||
items = append(items, map[string]interface{}{
|
||
"name": k.Name,
|
||
"content": k.Content,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"query": query,
|
||
"category": cat,
|
||
"results": items,
|
||
"hint": "结果按相关度排序;只采用第一条,第一条不相关请换分类或关键词",
|
||
})
|
||
}
|
||
|
||
// knowledge 取知识库;不可用时写 503 并返回 nil。
|
||
func (p *Plugin) knowledge(w http.ResponseWriter) sdk.KnowledgeAPI {
|
||
if p.sdkRef == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "plugin not started"})
|
||
return nil
|
||
}
|
||
kn := p.sdkRef.Knowledge()
|
||
if kn == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
|
||
return nil
|
||
}
|
||
return kn
|
||
}
|
||
|
||
func (p *Plugin) safeCategories(kn sdk.KnowledgeAPI) []string {
|
||
names, err := kn.Categories()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return names
|
||
}
|
||
|
||
// isParentCategory 判断 c 是否是别的分类的前缀(即中间层)。
|
||
func isParentCategory(counts []sdk.KnowledgeCategoryCount, c string) bool {
|
||
for _, o := range counts {
|
||
if o.Category != c && strings.HasPrefix(o.Category, c+"/") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func genToken() string {
|
||
buf := make([]byte, 16)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return fmt.Sprintf("tok-%d", time.Now().UnixNano())
|
||
}
|
||
return hex.EncodeToString(buf)
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func intQ(s string, def int) int {
|
||
if s == "" {
|
||
return def
|
||
}
|
||
n, err := strconv.Atoi(s)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func boolQ(s string, def bool) bool {
|
||
if s == "" {
|
||
return def
|
||
}
|
||
switch strings.ToLower(s) {
|
||
case "1", "true", "yes", "on":
|
||
return true
|
||
case "0", "false", "no", "off":
|
||
return false
|
||
}
|
||
return def
|
||
}
|