diff --git a/assets/skills/knowledge-base/SKILL.md b/assets/skills/knowledge-base/SKILL.md new file mode 100644 index 0000000..c3e3666 --- /dev/null +++ b/assets/skills/knowledge-base/SKILL.md @@ -0,0 +1,131 @@ +--- +name: knowledge-base +description: 按分类树检索 HomeAgent 知识库。当需要查阅项目知识、架构约定、历史决策,或用户提到"知识库/knowledge/知识库有哪些/这个项目的约定是什么"时使用。支持树形导航、分类内检索、以图搜知识。 +version: 1.1.0 +author: HomeAgent +--- + +## Usage + +先看树定位分类,再做定向检索。接口由 HomeAgent 的 `kbtree` 插件提供, +只读、需 token。详见下方"接入"。 + +HomeAgent 知识库按**分类树**组织(`tech/go/并发`、`life/sleep` …)。 + +### 何时用 + +- 用户问"这个项目/内核的某个约定是什么" → 先看树,找对分类再检索 +- 用户提到"知识库"或某个看起来像分类名的词(如 `tech/go`)→ 查该子树 +- 用户给了图片并问"知识库里有相关的吗" → 见"以图搜知识" + +### 接入 + +服务默认监听 `127.0.0.1:9892`(配置项 `kbtree.listen_addr`),只读, +每次请求需带 token(配置项 `kbtree.token`;未配置则启动时随机生成)。 + +```bash +BASE=http://127.0.0.1:9892 +AUTH="X-API-Key: $KB_TOKEN" # 或 Authorization: Bearer 或 ?token= +``` + +先摸清可用接口: + +```bash +curl -s -H "$AUTH" "$BASE/" +``` + +### 核心工作流:先看树,再定向检索 + +**别一上来就全文搜索。** 知识库是分层的,先定位分类能显著提高命中率, +也能避免把范围外的弱匹配当答案。 + +#### 第 1 步 · 看有哪些分类 + +```bash +curl -s -H "$AUTH" "$BASE/categories" +# {"categories":["cook","life","tech","tech/go","tech/rust"]} + +# 内容最多的分类(按条目数倒序) +curl -s -H "$AUTH" "$BASE/counts" +# {"counts":[{"category":"tech","count":3}, ...], "total":5} +``` + +#### 第 2 步 · 浏览树结构 + +```bash +# 整棵树,只要结构 +curl -s -H "$AUTH" "$BASE/tree?items=0" + +# 只要第一层 —— 分类多时用来做懒加载 +curl -s -H "$AUTH" "$BASE/tree?depth=1&items=0" + +# 某棵子树,带条目详情 +curl -s -H "$AUTH" "$BASE/tree?category=tech/go" +``` + +节点里 `name` 是**本级段名**(`"go"`),`path` 是**完整路径** +(`"tech/go"`)。拼层级用 `name`,把 `path` 拿去请求子节点。 +`item_count` 是本级条目数,`total_count` 是整棵子树。 + +#### 第 3 步 · 分类内检索 + +```bash +# 关键词 + 分类子树(推荐) +curl -s -H "$AUTH" "$BASE/search?q=goroutine&category=tech" + +# 全库 +curl -s -H "$AUTH" "$BASE/search?q=goroutine&limit=5" +``` + +`category` 是**前缀匹配**:`tech` 会命中 `tech/go`、`tech/rust` 下的条目; +传 `tech/go` 只命中它自己的子树。 + +### 以图搜知识(多模态) + +若知识条目挂了图片,它在**多模态统一空间**里有向量,能被图片本身检索到。 +前提是宿主已接入多模态向量 provider(否则只是记录了媒体,不参与召回)。 + +判断是否就绪:HomeAgent 自身的知识库接口会返回稠密路状态 +(`dense.enabled` / `dense.ready` / `dense.total`)。若为未启用, +**不要承诺"能以图搜"**。 + +本服务只提供**按关键词检索**——把图片字节提交给嵌入服务计算向量不在此接口内。所以: +- 用户给了图 → 用图的**可见内容**(或你先读图得到的文字)当关键词检索 +- 或用本机可用的读图工具先看图,再拿描述来检索 + +### 读结果 + +`/search` 每条结果: + +| 字段 | 含义 | +|---|---| +| `name` | 知识名(**已含分类前缀**,如 `tech/go/并发`) | +| `content` | 正文全文 | + +`/tree` 里的条目额外有 `preview`(前 120 字)、`size`、`updated_at`、 +`media`(挂载的媒体 digest/mime/kind)。 + +### 易错点 + +- **`name` 已经含分类**。不要再拼 `category + "/" + name`,会得到 + `tech/go/tech/go/并发`。 +- **检索会返回弱匹配。** 词法路会给所有条目打一个低分,靠排序把强命中顶到 + 前面。**只看第一条**;第一条明显不相关就换个分类或关键词,别把第 2、3 条 + 当答案。 +- **分类不存在返回 404**,并在 `categories` 字段里附上现有分类 —— 用它自查 + 拼写。 +- **`items=0` 只是不要正文**,`total_count` 仍准确,可用于判断规模。 +- **本服务只读**。写方法返回 405。要写知识请用 HomeAgent 主 agent 的 + `knowledge_create`(或 WebUI 界面),不要试图绕过它直接写这个接口。 +- 知识名里不能有 `..`、空格(会被规范成 `_`)、点开头的段。 + +### 写入 + +本服务不提供写入。若你在 HomeAgent 主 agent 内部,写入用内核工具: + +``` +knowledge_create name="tech/go/调度" content="正文..." +``` + +`name` 用 `/` 表示分类层级(如 `tech/go/调度`)。写完它**立刻可检索** +(词法 IDF 是增量维护的,不必重启)。 diff --git a/assets/skills/knowledge-base/skill.json b/assets/skills/knowledge-base/skill.json new file mode 100644 index 0000000..4fca11f --- /dev/null +++ b/assets/skills/knowledge-base/skill.json @@ -0,0 +1,6 @@ +{ + "name": "knowledge-base", + "description": "按分类树检索 HomeAgent 知识库:树形导航、分类内检索、以图搜知识", + "version": "1.1.0", + "author": "HomeAgent" +} diff --git a/cmd/homed/main.go b/cmd/homed/main.go index d4093d6..4c20528 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -13,6 +13,7 @@ import ( _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/clawhubadapter" cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/kbtree" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" diff --git a/internal/knowledge/tree.go b/internal/knowledge/tree.go new file mode 100644 index 0000000..53d0b0d --- /dev/null +++ b/internal/knowledge/tree.go @@ -0,0 +1,278 @@ +package knowledge + +import ( + "sort" + "strings" +) + +// timeLayout 是树视图对外暴露时间戳的格式(RFC3339,秒精度)。 +// 显式声明而非用 time.RFC3339 常量:调用方按字面量解析,格式变了会静默错解。 +const timeLayout = "2006-01-02T15:04:05Z07:00" + +// 树形只读视图(供 HTTP/Skill 等外部消费者使用)。 +// +// 与 TreeIndex 的区别,也是本文件存在的理由:TreeIndex 是**内部导出物**, +// 面向 .index.json 落盘,每个 IndexItem 带着 top-20 的 TF-IDF 特征向量。 +// 把那个结构直接序列化给外部会有三个问题: +// 1. 体积:每条几百个浮点数(实测 200 条时 .index.json 已 246KB,且里面 +// 还冗余存了 preview,而正本在 content.md); +// 2. 泄漏:Vector 是稀疏特征表,等于把分词/IDF 统计细节对外暴露; +// 3. 语义错位:外部调用方要的是「有哪些分类、每个分类下有什么」, +// 而不是一个分词器内部表示。 +// +// 所以这里定义**面向服务**的视图:不带向量,带条目数与可读摘要, +// 并支持按需只取某棵子树(懒加载),避免一次性吐出整棵树。 + +// TreeView 是分类树的一个节点。 +type TreeView struct { + // Name 是本节点名。根节点为 "root"。 + Name string `json:"name"` + // Path 是从根到本节点的分类路径(不含根),如 "tech/go"。根节点为空。 + Path string `json:"path"` + // ItemCount 是本节点**直接挂载**的条目数(不含子节点下的)。 + ItemCount int `json:"item_count"` + // TotalCount 是本节点整棵子树下的条目总数(含后代)。前端做懒加载时 + // 只给 ItemCount 也能显示"展开前有多少",TotalCount 则能一眼看出规模。 + TotalCount int `json:"total_count"` + // Items 是直接挂在本节点的条目。depth=0 时不填(只给计数)。 + Items []TreeItemView `json:"items,omitempty"` + // Children 是子分类,按名字排序(map 迭代无序,不排序则响应不可复现)。 + Children []TreeView `json:"children,omitempty"` +} + +// TreeItemView 是一个知识条目的只读视图。 +type TreeItemView struct { + Name string `json:"name"` + Preview string `json:"preview"` + Size int `json:"size"` + Tags []string `json:"tags,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + // Media 是该条目挂载的媒体(多模态:条目可被"以图搜"召回)。 + Media []TreeMediaView `json:"media,omitempty"` +} + +// TreeMediaView 是媒体引用摘要。 +type TreeMediaView struct { + Digest string `json:"digest"` + MIME string `json:"mime"` + Kind string `json:"kind,omitempty"` +} + +// TreeOptions 控制 TreeView 的取舍。 +type TreeOptions struct { + // MaxDepth 限制返回的层数。0 = 不限;1 = 只根 + 第一层分类。 + // 分类很多时用它做懒加载,避免单次响应膨胀。 + MaxDepth int + // IncludeItems 是否填充 Items。只看结构时可关掉以减小响应。 + IncludeItems bool + // PreviewLimit 是预览字数上限,0 用默认 120。 + PreviewLimit int +} + +// 树形视图的默认预览长度。 +const defaultTreePreview = 120 + +// Tree 返回面向服务的分类树视图。 +func (s *Store) Tree(opt TreeOptions) *TreeView { + s.mu.RLock() + defer s.mu.RUnlock() + return s.treeLocked("", opt, 1) +} + +// Subtree 返回某棵子树的视图。category 为空时等价于 Tree。 +func (s *Store) Subtree(category string, opt TreeOptions) *TreeView { + s.mu.RLock() + defer s.mu.RUnlock() + cat := strings.Trim(strings.TrimSpace(category), "/") + if cat == "" { + return s.treeLocked("", opt, 1) + } + return s.treeLocked(cat, opt, 1) +} + +// nodeSegment 取路径的**最后一段**作为本级段名。 +func nodeSegment(category, root string) string { + if i := strings.LastIndex(category, "/"); i >= 0 { + return category[i+1:] + } + if category != "" { + return category + } + return root +} + +// treeLocked 是 Tree/Subtree 的实现(调用方须持读锁)。 +// +// category 非空时返回那棵子树(根节点 Path 即 category); +// 为空时返回整棵树(根节点 Name "root",Path 空)。 +// +// 节点 Name 是**本级段名**(如 "go"),Path 是完整路径(如 "tech/go")。 +// 两者分工:Name 给前端渲染层级标签,Path 给程序定位与再请求。 +// 曾经 Name 直接写全路径,于是 tech 的子节点叫 "tech/go",前端拿它拼 +// 层级会得到 "tech/tech/go",且与根节点 "root" 的语义不一致。 +func (s *Store) treeLocked(category string, opt TreeOptions, depth int) *TreeView { + previewLimit := opt.PreviewLimit + if previewLimit <= 0 { + previewLimit = defaultTreePreview + } + // 该子树是否存在 + if category != "" { + if !s.hasInScopeLocked(category) { + return nil + } + } + + out := &TreeView{Name: nodeSegment(category, "root"), Path: category} + + // 收集本节点直接挂载的条目(Category == category) + for _, k := range s.items { + if k.Category != category { + continue + } + out.ItemCount++ + if !opt.IncludeItems { + continue + } + item := TreeItemView{ + Name: k.Name, + Preview: previewOf(k.Content, previewLimit), + Size: len(k.Content), + Tags: k.Tags, + UpdatedAt: k.UpdatedAt.Format(timeLayout), + } + for _, m := range k.Media { + item.Media = append(item.Media, TreeMediaView{Digest: m.Digest, MIME: m.MIME, Kind: m.Kind}) + } + out.Items = append(out.Items, item) + } + sort.Slice(out.Items, func(i, j int) bool { return out.Items[i].Name < out.Items[j].Name }) + + // 收集直接子分类:Category == category+"/xxx"(即只有一层深) + seen := make(map[string]struct{}) + prefix := category + if prefix != "" { + prefix += "/" + } + for _, k := range s.items { + if k.Category == category || !strings.HasPrefix(k.Category, prefix) { + continue + } + rest := strings.TrimPrefix(k.Category, prefix) + child := rest + if i := strings.Index(rest, "/"); i >= 0 { + child = rest[:i] // 只取第一段 ⇒ 直接子分类 + } + if child == "" { + continue + } + seen[child] = struct{}{} + } + + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + + // MaxDepth 剪枝:depth > MaxDepth 时只给计数,不再下钻 + if opt.MaxDepth > 0 && depth >= opt.MaxDepth { + for _, n := range names { + sub := s.treeLocked(categoryPath(category, n), TreeOptions{MaxDepth: 0, IncludeItems: false}, depth+1) + out.TotalCount += sub.TotalCount + } + out.TotalCount += out.ItemCount + return out + } + + for _, n := range names { + childPath := categoryPath(category, n) + sub := s.treeLocked(childPath, opt, depth+1) + if sub == nil { + continue + } + out.Children = append(out.Children, *sub) + out.TotalCount += sub.TotalCount + } + out.TotalCount += out.ItemCount + return out +} + +// categoryPath 拼子分类路径(根为空时返回裸名)。 +func categoryPath(parent, child string) string { + if parent == "" { + return child + } + return parent + "/" + child +} + +// previewOf 按 rune 截断预览(按字节切会把多字节字符切成乱码)。 +func previewOf(s string, limit int) string { + if s == "" { + return "" + } + r := []rune(s) + if len(r) <= limit { + return s + } + return string(r[:limit]) + "…" +} + +// Categories 列出全部分类路径(去重、排序)。给"只想要平铺分类列表"的调用方。 +func (s *Store) Categories() []string { + s.mu.RLock() + defer s.mu.RUnlock() + seen := map[string]struct{}{} + for _, k := range s.items { + if k.Category == "" { + continue + } + // 一并给出中间层(tech/go/并发 ⇒ tech、tech/go 都要出现) + parts := strings.Split(k.Category, "/") + for i := range parts { + seen[strings.Join(parts[:i+1], "/")] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} + +// CategoryCounts 给出每个分类(含中间层)下的条目总数,按数量倒序。 +// 给"哪些分类最值得先看"这类场景。 +func (s *Store) CategoryCounts() []CategoryCount { + s.mu.RLock() + defer s.mu.RUnlock() + exact := map[string]int{} + for _, k := range s.items { + if k.Category != "" { + exact[k.Category]++ + } + } + agg := map[string]int{} + for cat, n := range exact { + parts := strings.Split(cat, "/") + for i := range parts { + agg[strings.Join(parts[:i+1], "/")] += n + } + } + out := make([]CategoryCount, 0, len(agg)) + for c, n := range agg { + out = append(out, CategoryCount{Category: c, Count: n}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Category < out[j].Category + }) + return out +} + +// CategoryCount 是一个分类的条目数。 +type CategoryCount struct { + Category string `json:"category"` + Count int `json:"count"` +} diff --git a/internal/knowledge/tree_test.go b/internal/knowledge/tree_test.go new file mode 100644 index 0000000..5be4235 --- /dev/null +++ b/internal/knowledge/tree_test.go @@ -0,0 +1,252 @@ +package knowledge + +import ( + "encoding/json" + "strings" + "testing" +) + +func seedTreeStore(t *testing.T) *Store { + t.Helper() + s := NewStore(t.TempDir()) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + for _, e := range []struct{ name, body string }{ + {"tech/go/并发", "goroutine 调度 GMP 抢占"}, + {"tech/go/context", "context 取消 超时"}, + {"tech/rust/所有权", "borrow checker move 语义"}, + {"life/sleep", "作息 褪黑素"}, + {"cook/coffee", "手冲 烘焙"}, + {"顶层条目", "没有分类的条目"}, + } { + if err := s.Add(e.name, e.body); err != nil { + t.Fatal(err) + } + } + return s +} + +// 树视图必须呈现正确的层级、计数与挂载点。 +func TestTreeViewShape(t *testing.T) { + s := seedTreeStore(t) + root := s.Tree(TreeOptions{IncludeItems: true}) + if root == nil { + t.Fatal("Tree 返回 nil") + } + if root.Name != "root" || root.Path != "" { + t.Errorf("根节点应为 root/空 path,实为 %q/%q", root.Name, root.Path) + } + // 顶层无分类条目挂在 root.Items + if root.ItemCount != 1 || len(root.Items) != 1 || root.Items[0].Name != "顶层条目" { + t.Errorf("root 应挂 1 条无分类条目,实为 %d 条 %v", root.ItemCount, itemNames(root.Items)) + } + // 子分类按名排序 + var kids []string + for _, c := range root.Children { + kids = append(kids, c.Name) + } + if strings.Join(kids, ",") != "cook,life,tech" { + t.Errorf("子分类应按名排序为 cook,life,tech,实为 %v", kids) + } + // tech 有两个子分类 + tech := findChild(root, "tech") + if tech == nil { + t.Fatal("缺少 tech 节点") + } + if tech.TotalCount != 3 { + t.Errorf("tech 子树应有 3 条(go 2 + rust 1),实为 %d", tech.TotalCount) + } + // tech/go 是 tech 的**子节点**(层级体现在树上,不是平铺) + goNode := findChild(tech, "go") + if goNode == nil { + t.Fatal("缺少 tech/go 节点") + } + if goNode.ItemCount != 2 || goNode.Path != "tech/go" { + t.Errorf("tech/go 应有 2 条且 path 正确,实为 %d / %q", goNode.ItemCount, goNode.Path) + } + // 条目视图字段完整 + for _, it := range goNode.Items { + if it.Preview == "" || it.Size == 0 { + t.Errorf("%s 缺 preview/size: %+v", it.Name, it) + } + } +} + +// 树视图**不得**带向量(那是内部导出物的内容,见 tree.go 头部说明)。 +func TestTreeViewCarriesNoVectors(t *testing.T) { + s := seedTreeStore(t) + data := mustJSON(t, s.Tree(TreeOptions{IncludeItems: true})) + for _, forbidden := range []string{`"vector"`, `"Vector"`} { + if strings.Contains(data, forbidden) { + t.Errorf("树视图不应含 %s(会把稀疏特征表泄漏给外部调用方)", forbidden) + } + } +} + +// MaxDepth 剪枝:只给计数不下钻,且 TotalCount 仍准确。 +func TestTreeMaxDepthPrunesButKeepsCount(t *testing.T) { + s := seedTreeStore(t) + full := s.Tree(TreeOptions{}) + shallow := s.Tree(TreeOptions{MaxDepth: 1}) + + if full.TotalCount != shallow.TotalCount { + t.Errorf("剪枝不应改变 TotalCount:%d vs %d", full.TotalCount, shallow.TotalCount) + } + if full.TotalCount != 6 { + t.Errorf("总条目应为 6,实为 %d", full.TotalCount) + } + // depth=1 ⇒ 根的直接子分类有节点,但它们的 Children 为空 + for _, c := range shallow.Children { + if len(c.Children) != 0 { + t.Errorf("depth=1 时 %s 下不应再展开子节点,实为 %d 个", c.Name, len(c.Children)) + } + if c.TotalCount == 0 { + t.Errorf("depth=1 时 %s 的 TotalCount 仍应是真实值", c.Name) + } + } + tech := findChild(shallow, "tech") + if tech != nil && tech.TotalCount != 3 { + t.Errorf("剪枝后 tech 的 TotalCount 应仍为 3,实为 %d", tech.TotalCount) + } +} + +// Subtree 只返回那棵子树。 +func TestTreeSubtree(t *testing.T) { + s := seedTreeStore(t) + view := s.Subtree("tech/go", TreeOptions{IncludeItems: true}) + if view == nil { + t.Fatal("Subtree 返回 nil") + } + // Name 是**本级段名**,Path 是完整路径(前端拼层级用 Name,定位用 Path) + if view.Path != "tech/go" { + t.Errorf("子树根 Path 应为 tech/go,实为 %q", view.Path) + } + if view.Name != "go" { + t.Errorf("子树根 Name 应为本级段名 go,实为 %q", view.Name) + } + if view.ItemCount != 2 { + t.Errorf("tech/go 应有 2 条,实为 %d", view.ItemCount) + } + // 空分类 → 整棵树 + if r := s.Subtree("", TreeOptions{}); r == nil || r.Name != "root" { + t.Errorf("空分类应返回整棵树,实为 %+v", r) + } + // 不存在的分类 → nil(调用方据此给 404) + if s.Subtree("no/such", TreeOptions{}) != nil { + t.Error("不存在的分类应返回 nil") + } + // 前后斜杠应被归一化 + if s.Subtree("/tech/go/", TreeOptions{}) == nil { + t.Error("前后斜杠应被归一化后仍能命中") + } +} + +// IncludeItems=false 时只给计数,不给条目。 +func TestTreeWithoutItems(t *testing.T) { + s := seedTreeStore(t) + view := s.Tree(TreeOptions{IncludeItems: false}) + if view.ItemCount != 1 { + t.Errorf("ItemCount 应仍为 1,实为 %d", view.ItemCount) + } + if len(view.Items) != 0 { + t.Errorf("IncludeItems=false 不应返回条目,实为 %v", itemNames(view.Items)) + } +} + +// Categories 应含中间层(tech/go/并发 ⇒ tech、tech/go 都在列表里)。 +func TestTreeCategoriesIncludesIntermediate(t *testing.T) { + s := seedTreeStore(t) + cats := s.Categories() + want := map[string]bool{"tech": true, "tech/go": true, "tech/rust": true, "life": true, "cook": true} + for _, c := range cats { + delete(want, c) + } + if len(want) != 0 { + t.Errorf("缺少分类 %v,实际列表 %v", want, cats) + } + // 已排序 + if !isSorted(cats) { + t.Errorf("Categories 应有序,实为 %v", cats) + } +} + +// CategoryCounts 按数量倒序,且中间层计入其后代。 +func TestTreeCategoryCounts(t *testing.T) { + s := seedTreeStore(t) + counts := s.CategoryCounts() + byName := map[string]int{} + for _, c := range counts { + byName[c.Category] = c.Count + } + if byName["tech"] != 3 { + t.Errorf("tech 应聚合 3 条,实为 %d", byName["tech"]) + } + if byName["tech/go"] != 2 { + t.Errorf("tech/go 应为 2 条,实为 %d", byName["tech/go"]) + } + // 倒序:首项应是条目最多的分类 + if len(counts) == 0 || counts[0].Category != "tech" { + t.Errorf("首项应为条目最多的 tech,实为 %+v", counts) + } +} + +// 预览按 rune 截断,不能把多字节字符切坏。 +func TestTreePreviewRuneSafe(t *testing.T) { + dir := t.TempDir() + s := NewStore(dir) + if err := s.Start(); err != nil { + t.Fatal(err) + } + defer s.Stop() + if err := s.Add("zh", strings.Repeat("知识库条目内容", 100)); err != nil { + t.Fatal(err) + } + view := s.Tree(TreeOptions{IncludeItems: true, PreviewLimit: 10}) + it := view.Items[0] + if strings.ContainsRune(it.Preview, 0xFFFD) { + t.Errorf("预览含替换字符,说明按字节切了多字节字符: %q", it.Preview) + } + if runeLen(it.Preview) > 11 { // 10 字 + 省略号 + t.Errorf("预览长度应约 11 rune,实为 %d: %q", runeLen(it.Preview), it.Preview) + } +} + +// findChild 按**本级段名**在子节点里查找(Name 语义见 tree.go 的说明)。 +func findChild(v *TreeView, name string) *TreeView { + for i := range v.Children { + if v.Children[i].Name == name { + return &v.Children[i] + } + } + return nil +} + +func itemNames(items []TreeItemView) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Name + } + return out +} + +func isSorted(s []string) bool { + for i := 1; i < len(s); i++ { + if s[i-1] > s[i] { + return false + } + } + return true +} + +func runeLen(s string) int { return len([]rune(s)) } + +func mustJSON(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/plugin/skill_bundled_test.go b/internal/plugin/skill_bundled_test.go new file mode 100644 index 0000000..10336bb --- /dev/null +++ b/internal/plugin/skill_bundled_test.go @@ -0,0 +1,42 @@ +package plugin + +import ( + "path/filepath" + "testing" +) + +// 随发行版分发的 knowledge-base skill 必须能被 LoadSKILL 正确解析。 +// +// 这条断言的由来:SKILL.md 里**非白名单的二级标题(## )会被 +// extractToolDefs 当成工具定义**,进而 ValidateSKILLContent 报 +// "invalid tool name"。写文档时用中文小标题("## 前提:…")就会踩到, +// 报错的提示与真正的原因(标题层级)毫无关联,极难自查。 +// 故把「bundled skill 必须可加载且可校验」固化成测试。 +func TestBundledSkillsAreLoadable(t *testing.T) { + root := filepath.Join("..", "..", "assets", "skills") + dirs, err := filepath.Glob(filepath.Join(root, "*")) + if err != nil { + t.Fatal(err) + } + if len(dirs) == 0 { + t.Skip("没有随发行版分发的 skill") + } + for _, dir := range dirs { + name := filepath.Base(dir) + t.Run(name, func(t *testing.T) { + sk, err := LoadSKILL(dir) + if err != nil { + t.Fatalf("LoadSKILL 失败: %v", err) + } + if sk.Name() == "" { + t.Error("skill 名为空") + } + if sk.Description() == "" || sk.Description() == "---" { + t.Errorf("description 解析失败(frontmatter 未被正确跳过): %q", sk.Description()) + } + if err := ValidateSKILLContent(sk.RawContent()); err != nil { + t.Errorf("ValidateSKILLContent 失败: %v", err) + } + }) + } +} diff --git a/internal/plugins/kbtree/plugin.go b/internal/plugins/kbtree/plugin.go new file mode 100644 index 0000000..20d93dd --- /dev/null +++ b/internal/plugins/kbtree/plugin.go @@ -0,0 +1,370 @@ +// 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=", + "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 +} diff --git a/internal/plugins/kbtree/plugin_test.go b/internal/plugins/kbtree/plugin_test.go new file mode 100644 index 0000000..decbfe5 --- /dev/null +++ b/internal/plugins/kbtree/plugin_test.go @@ -0,0 +1,352 @@ +package kbtree + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +type rig struct { + p *Plugin + ks *knowledge.Store + sup *supervisor.Daemon + srv *httptest.Server + tok string +} + +func newRig(t *testing.T) *rig { + t.Helper() + ks := knowledge.NewStore(t.TempDir()) + if err := ks.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(ks.Stop) + for _, e := range []struct{ name, body string }{ + {"tech/go/并发", "goroutine 调度 GMP 抢占 通道"}, + {"tech/go/context", "context 取消 超时 传播"}, + {"tech/rust/所有权", "borrow checker move 语义"}, + {"life/sleep", "作息 褪黑素 深睡"}, + {"cook/coffee", "手冲 烘焙 水温"}, + } { + if err := ks.Add(e.name, e.body); err != nil { + t.Fatal(err) + } + } + cfg := &types.Config{Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second, + }} + sup := supervisor.New(cfg) + sup.Start() + t.Cleanup(sup.Shutdown) + + s := sdk.New("test", sdk.SDKConfig{ + Supervisor: supervisor.NewSDKAdapter(sup), + Knowledge: sdk.NewKnowledge(ks), + Config: sdk.NewConfig(cfg), + }) + // 让 Start 监听 :0(随机空闲端口),这样既走真实启动路径又不撞固定端口 + p := New("kbtree") + p.addr = "127.0.0.1:0" + if err := p.Start(s); err != nil { + t.Fatalf("Start 失败: %v", err) + } + t.Cleanup(func() { _ = p.Stop() }) + + // 测试直接打插件的 mux(真实端口已由 :0 分配,无需再开 server) + srv := httptest.NewServer(p.mux) + t.Cleanup(srv.Close) + + return &rig{p: p, ks: ks, sup: sup, srv: srv, tok: p.token} +} + +// get 带 token 请求。 +func (r *rig) get(t *testing.T, path string) (int, []byte) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, r.srv.URL+path, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-API-Key", r.tok) + resp, err := r.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, b +} + +func (r *rig) getNoAuth(t *testing.T, path string) int { + t.Helper() + resp, err := r.srv.Client().Get(r.srv.URL + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + return resp.StatusCode +} + +// 无 token / 错 token 一律 401。 +func TestRequiresToken(t *testing.T) { + r := newRig(t) + if code := r.getNoAuth(t, "/tree"); code != http.StatusUnauthorized { + t.Errorf("无 token 应 401,实为 %d", code) + } + // 错 token + resp, err := r.srv.Client().Get(r.srv.URL + "/tree?token=wrong") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("错 token 应 401,实为 %d", resp.StatusCode) + } +} + +// token 也可用 ?token= 传(外部脚本友好)。 +func TestTokenViaQuery(t *testing.T) { + r := newRig(t) + resp, err := r.srv.Client().Get(r.srv.URL + "/tree?token=" + r.tok) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("?token= 应放行,实为 %d", resp.StatusCode) + } + // Bearer 也行 + req, _ := http.NewRequest(http.MethodGet, r.srv.URL+"/tree", nil) + req.Header.Set("Authorization", "Bearer "+r.tok) + resp2, err := r.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + t.Errorf("Bearer token 应放行,实为 %d", resp2.StatusCode) + } +} + +// 根路径自述端点。 +func TestRootSelfDescription(t *testing.T) { + r := newRig(t) + code, body := r.get(t, "/") + if code != http.StatusOK { + t.Fatalf("应 200,实为 %d", code) + } + if !strings.Contains(string(body), "read_only") { + t.Errorf("自述里应标明只读: %s", body) + } + if !strings.Contains(string(body), "/tree") { + t.Errorf("自述里应列出端点: %s", body) + } +} + +// 树端点:结构、计数、懒加载。 +func TestTreeEndpoint(t *testing.T) { + r := newRig(t) + code, body := r.get(t, "/tree?items=0") + if code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", code, body) + } + if !strings.Contains(string(body), `"total_count":5`) { + t.Errorf("总条目应为 5: %s", body) + } + if !strings.Contains(string(body), `"tech"`) { + t.Errorf("应含 tech 分类: %s", body) + } + // items=0 时无 preview + if strings.Contains(string(body), `"preview"`) { + t.Error("items=0 不应返回 preview") + } + + // 懒加载:depth=1 仍给出准确 total + code, body = r.get(t, "/tree?depth=1&items=0") + if code != http.StatusOK { + t.Fatalf("depth=1 应 200,实为 %d", code) + } + if !strings.Contains(string(body), `"total_count":5`) { + t.Errorf("剪枝不应改变总数: %s", body) + } +} + +// 子树端点。 +func TestTreeSubtreeEndpoint(t *testing.T) { + r := newRig(t) + code, body := r.get(t, "/tree?category=tech/go&items=1") + if code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", code, body) + } + var resp struct { + Tree struct { + Path string `json:"path"` + Name string `json:"name"` + ItemCount int `json:"item_count"` + } `json:"tree"` + } + json.Unmarshal(body, &resp) + if resp.Tree.Path != "tech/go" || resp.Tree.ItemCount != 2 { + t.Errorf("tech/go 应 2 条,实为 %q/%d", resp.Tree.Path, resp.Tree.ItemCount) + } + if resp.Tree.Name != "go" { + t.Errorf("name 应为本级段名 go,实为 %q", resp.Tree.Name) + } +} + +// 不存在的分类:404 + 附现有分类。 +func TestTreeNotFound(t *testing.T) { + r := newRig(t) + code, body := r.get(t, "/tree?category=nope/here") + if code != http.StatusNotFound { + t.Fatalf("应 404,实为 %d", code) + } + if !strings.Contains(string(body), "categories") { + t.Errorf("404 应附现有分类便于自查: %s", body) + } +} + +// 检索端点:首位最相关、范围外不混入、缺 q 报 400。 +func TestSearchEndpoint(t *testing.T) { + r := newRig(t) + code, body := r.get(t, "/search?q=borrow&category=tech") + if code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", code, body) + } + var resp struct { + Results []struct { + Name string `json:"name"` + } `json:"results"` + Hint string `json:"hint"` + } + json.Unmarshal(body, &resp) + if len(resp.Results) == 0 { + t.Fatal("应有命中") + } + if resp.Results[0].Name != "tech/rust/所有权" { + t.Errorf("首位应最相关,实为 %q", resp.Results[0].Name) + } + for _, it := range resp.Results { + if !strings.HasPrefix(it.Name, "tech/") { + t.Errorf("范围外 %q 混入", it.Name) + } + } + if resp.Hint == "" { + t.Error("应带 hint") + } + // 缺 q + if code, _ := r.get(t, "/search"); code != http.StatusBadRequest { + t.Errorf("缺 q 应 400,实为 %d", code) + } +} + +// 分类与计数端点。 +func TestCategoriesAndCountsEndpoint(t *testing.T) { + r := newRig(t) + _, body := r.get(t, "/categories") + for _, want := range []string{"tech", "tech/go", "tech/rust", "life", "cook"} { + if !strings.Contains(string(body), `"`+want+`"`) { + t.Errorf("缺少分类 %s: %s", want, body) + } + } + + _, body = r.get(t, "/counts") + var resp struct { + Counts []struct { + Category string `json:"category"` + Count int `json:"count"` + } `json:"counts"` + Total int `json:"total"` + } + json.Unmarshal(body, &resp) + byCat := map[string]int{} + for _, c := range resp.Counts { + byCat[c.Category] = c.Count + } + if byCat["tech"] != 3 { + t.Errorf("tech 应聚合 3 条,实为 %d", byCat["tech"]) + } + if resp.Total != 5 { + t.Errorf("total 只数叶子应为 5,实为 %d", resp.Total) + } +} + +// 只读:写方法一律 405,且不改变知识库。 +func TestReadOnly(t *testing.T) { + r := newRig(t) + before := len(r.ks.List()) + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + req, _ := http.NewRequest(m, r.srv.URL+"/tree", nil) + req.Header.Set("X-API-Key", r.tok) + resp, err := r.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("%s 应 405,实为 %d", m, resp.StatusCode) + } + } + if len(r.ks.List()) != before { + t.Error("只读服务不应改变知识库") + } +} + +// 知识库不可用时 Start 应失败(不占端口)。 +func TestStartFailsWithoutKnowledge(t *testing.T) { + cfg := &types.Config{Daemon: types.DaemonConfig{CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second}} + sup := supervisor.New(cfg) + sup.Start() + defer sup.Shutdown() + s := sdk.New("test", sdk.SDKConfig{ + Supervisor: supervisor.NewSDKAdapter(sup), + Config: sdk.NewConfig(cfg), + }) + if err := New("kbtree").Start(s); err == nil { + t.Error("无知识库时 Start 应失败") + } +} + +// 技能文档里的端点路径必须与代码一致(防止文档漂移)。 +func TestSkillDocEndpointsMatchCode(t *testing.T) { + doc, err := readSkillDoc() + if err != nil { + t.Skipf("读不到 skill 文档: %v", err) + } + for _, ep := range []string{"/tree", "/categories", "/counts", "/search"} { + if !strings.Contains(doc, ep) { + t.Errorf("SKILL.md 未提及端点 %s(文档与代码漂移)", ep) + } + } +} + +func readSkillDoc() (string, error) { + // 测试在 internal/plugins/kbtree,skill 在 ../../assets/skills + paths := []string{ + filepath.Join("..", "..", "assets", "skills", "knowledge-base", "SKILL.md"), + filepath.Join("..", "..", "..", "assets", "skills", "knowledge-base", "SKILL.md"), + } + var lastErr error + for _, p := range paths { + if b, err := readFile(p); err == nil { + return string(b), nil + } else { + lastErr = err + } + } + return "", lastErr +} + +func readFile(p string) ([]byte, error) { + return os.ReadFile(p) +} diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index dba914c..d430734 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -1647,19 +1647,23 @@ '' + (k?.knowledge?.item_count ?? "-") + "" + - '
' + + '
' + + '' + + __("按分类浏览", "Browse by category") + + '' + + '
' + + '
' + + '
' + '' + - '' + + '' + '" + + '" + '
' + '
' + '' + + '▸ ' + + '' + + knowEsc(it.name) + + "" + + (it.size ? ' ' + it.size + "B" : "") + + (it.media && it.media.length + ? ' [' + it.media.length + __(" 媒体", " media") + "]" + : "") + + "
"; + }); + // 子分类 + (node.children || []).forEach(function (c) { + var isCur = _knowCat === c.path; + var mark = isCur ? "● " : ""; + html += + '
' + + mark + + knowEsc(c.name) + + ' ' + + c.item_count + + "/" + + c.total_count + + "
"; + html += renderKnowTree(c, depth + 1); + }); + return html; + } + + async function loadKnowledgeTree() { + var box = document.getElementById("know-tree"); + if (!box) return; + box.innerHTML = '
'; + try { + var d = await api("/knowledge/tree?items=1"); + if (!d || !d.tree) { + box.innerHTML = + '

' + __("暂无分类", "No categories yet") + "

"; + return; + } + box.innerHTML = renderKnowTree(d.tree, 0); + } catch (e) { + box.innerHTML = + '

' + __("加载分类失败: ", "Load categories failed: ") + knowEsc(e.message) + "

"; + } + } + + function selectKnowledgeCategory(path) { + _knowCat = path || ""; + var lbl = document.getElementById("know-cat-scope"); + if (lbl) { + lbl.textContent = _knowCat + ? __("范围:", "scope: ") + _knowCat + : __("范围:全库", "scope: all"); + } + loadKnowledgeTree(); + // 立刻按新范围搜一次,省一次点击 + var q = document.getElementById("know-query")?.value; + if (q) searchKnowledgeChat(); + } + + function clearKnowledgeCategory() { + selectKnowledgeCategory(""); + } + async function searchKnowledgeChat() { var q = document.getElementById("know-query")?.value; - var cat = document.getElementById("know-category")?.value || ""; + var cat = _knowCat || ""; var r = document.getElementById("know-result-chat"); if (!r) return; if (!q) { @@ -3082,7 +3169,14 @@ function switchChatPanel(tab, el) { // 切到知识面板时拉实时计数:面板里的数字来自 state.kernel 快照, // 而知识条目会经工具/上传增删,快照不会自己变(实测创建后仍显示 "-")。 - if (tab === "knowledge") refreshKnowledgeCount(); + if (tab === "knowledge") { + refreshKnowledgeCount(); + var lbl = document.getElementById("know-cat-scope"); + if (lbl && !lbl.textContent) { + lbl.textContent = _knowCat ? __("范围:", "scope: ") + _knowCat : __("范围:全库", "scope: all"); + } + loadKnowledgeTree(); + } var panels = { chat: document.getElementById("chat-panel-chat"), starmap: document.getElementById("chat-panel-starmap"), diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 5555ba6..29a9889 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -467,6 +467,11 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/settings/", h.requireAPI(h.handleSettings)) mux.HandleFunc("/api/v1/knowledge", h.requireAPI(h.handleKnowledge)) mux.HandleFunc("/api/v1/knowledge/", h.requireAPI(h.handleKnowledge)) + // 知识库的树形只读面(供外部 agent / skill 按分类导航)。 + // 路由顺序无关:Go 1.22+ 的 ServeMux 取**最长前缀匹配** + // (/knowledge/ 只是子树通配,不会吃掉 /knowledge/tree)。go.mod 要求 1.25。 + mux.HandleFunc("/api/v1/knowledge/tree", h.requireAPI(h.handleKnowledgeTree)) + mux.HandleFunc("/api/v1/knowledge/tree/", h.requireAPI(h.handleKnowledgeTree)) mux.HandleFunc("/api/v1/adapters", h.requireAPI(h.handleAdapters)) mux.HandleFunc("/api/v1/adapters/", h.requireAPI(h.handleAdapterByID)) mux.HandleFunc("/api/v1/tracker", h.requireAPI(h.handleTracker)) diff --git a/internal/plugins/webui/handler_knowledge_tree.go b/internal/plugins/webui/handler_knowledge_tree.go new file mode 100644 index 0000000..1570f88 --- /dev/null +++ b/internal/plugins/webui/handler_knowledge_tree.go @@ -0,0 +1,150 @@ +package webui + +import ( + "net/http" + "strconv" + "strings" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// 知识库的**树形只读 API**。供外部 agent、skill、脚本按分类导航知识库。 +// +// 为何只读:写入面已有 POST /api/v1/knowledge(可带媒体)。而写操作 +// 需要决定"挂到哪个分类、是否带媒体、是否重算稠密向量",那是内核的事; +// 让外部 agent 自己拼分类路径反而容易写出越界/重名的条目。读多写少, +// 且读才是"让别的 agent 用起来"的关键。 +// +// 鉴权沿用 requireAPI(api_key 或 cookie session),不新增鉴权面。 +// +// 路由: +// +// GET /api/v1/knowledge/tree 整棵树 +// GET /api/v1/knowledge/tree/{category} 某棵子树 +// ?depth=N 限制层数(0/省略 = 不限)——分类多时做懒加载 +// ?items=0|1 是否返回条目详情,默认 1 +// ?preview=N 预览字数上限,默认 120 +// ?q=关键词 在**该子树内**检索(分类 + 关键词组合) +// ?limit=N q 时的返回条数,默认 10 +// GET /api/v1/knowledge/tree/categories 平铺分类列表 +// GET /api/v1/knowledge/tree/counts 各分类条目数(倒序) +// +// 为什么把 categories/counts 也挂在 /tree/ 下:它们是**导航辅助**, +// 回答"有哪些分类、哪里的内容最多",与树形视图同源,挂在别处会割裂。 + +// handleKnowledgeTree 处理树形只读请求。 +func (h *Handler) handleKnowledgeTree(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "只支持 GET"}) + return + } + if h.knowledge == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"}) + return + } + + // path 形如 /api/v1/knowledge/tree[/sub/category] + sub := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/knowledge/tree"), "/") + + // 平铺分类列表 + if sub == "categories" { + names, err := h.knowledge.Categories() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if names == nil { + names = []string{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"categories": names}) + return + } + // 各分类条目数 + if sub == "counts" { + counts, err := h.knowledge.CategoryCounts() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if counts == nil { + counts = []sdk.KnowledgeCategoryCount{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"counts": counts}) + return + } + + q := r.URL.Query() + opt := sdk.KnowledgeTreeOptions{ + MaxDepth: intQuery(q.Get("depth"), 0), + IncludeItems: boolQuery(q.Get("items"), true), + PreviewLimit: intQuery(q.Get("preview"), 0), + } + + // 分类 + 关键词:在该子树内检索 + if keyword := strings.TrimSpace(q.Get("q")); keyword != "" { + limit := intQuery(q.Get("limit"), 10) + if limit <= 0 || limit > 100 { + limit = 10 + } + results, err := h.knowledge.SearchIn(keyword, sub, limit) + if err != nil { + code, msg := knowledgeStatusFor(err) + writeJSON(w, code, map[string]string{"error": msg}) + return + } + views := make([]knowledgeView, 0, len(results)) + for _, k := range results { + views = append(views, toKnowledgeView(k, k.Name)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "results": views, + "category": sub, + "query": keyword, + }) + return + } + + // 树视图(category 为空 = 整棵树) + view, err := h.knowledge.Subtree(sub, opt) + if err != nil { + code, msg := knowledgeStatusFor(err) + writeJSON(w, code, map[string]string{"error": msg}) + return + } + if view == nil { + // 分类不存在:这是调用方能自己纠正的错误,给 404 + 现有分类便于自查 + known, _ := h.knowledge.Categories() + writeJSON(w, http.StatusNotFound, map[string]interface{}{ + "error": "分类不存在: " + sub, + "categories": known, + }) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"tree": view}) +} + +// intQuery 解析整数查询参数;空/非法时用 def。 +func intQuery(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// boolQuery 解析布尔查询参数;空时用 def。接受 1/0/true/false。 +func boolQuery(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 +} diff --git a/internal/plugins/webui/handler_knowledge_tree_test.go b/internal/plugins/webui/handler_knowledge_tree_test.go new file mode 100644 index 0000000..9d4fb14 --- /dev/null +++ b/internal/plugins/webui/handler_knowledge_tree_test.go @@ -0,0 +1,314 @@ +package webui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +func newTreeHandler(t *testing.T) (*Handler, *knowledge.Store) { + t.Helper() + ks := knowledge.NewStore(t.TempDir()) + if err := ks.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(ks.Stop) + for _, e := range []struct{ name, body string }{ + {"tech/go/并发", "goroutine 调度 GMP 抢占"}, + {"tech/go/context", "context 取消 超时"}, + {"tech/rust/所有权", "borrow checker move 语义"}, + {"life/sleep", "作息 褪黑素"}, + } { + if err := ks.Add(e.name, e.body); err != nil { + t.Fatal(err) + } + } + cfg := &types.Config{Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second, + }} + sup := supervisor.New(cfg) + sup.Start() + t.Cleanup(sup.Shutdown) + return NewHandler(testSDK(sdk.SDKConfig{ + Supervisor: supervisor.NewSDKAdapter(sup), + Knowledge: sdk.NewKnowledge(ks), + Config: sdk.NewConfig(cfg), + })), ks +} + +// 路由不被 /api/v1/knowledge/ 通配吃掉(Go 1.22+ 最长前缀匹配)。 +func TestKnowledgeTreeRouteNotSwallowed(t *testing.T) { + h, _ := newTreeHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/knowledge/tree", nil)) + if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 { + t.Fatal("路由未命中:/api/v1/knowledge/tree 返回了 mux 的 404") + } + // 鉴权未配置时是 503(说明命中了我们的 handler,而不是 mux 404) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("未配 api_key 时应为 503,实为 %d body=%s", rec.Code, rec.Body.String()) + } +} + +func treeGet(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + // 绕过鉴权直接打 handler:这里测的是路由与业务,鉴权另有测试覆盖 + h.handleKnowledgeTree(rec, req) + return rec +} + +func TestKnowledgeTreeAPI(t *testing.T) { + h, _ := newTreeHandler(t) + + rec := treeGet(t, h, "/api/v1/knowledge/tree") + if rec.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Tree struct { + Name string `json:"name"` + Path string `json:"path"` + TotalCount int `json:"total_count"` + Children []struct { + Name string `json:"name"` + Path string `json:"path"` + TotalCount int `json:"total_count"` + Children []struct { + ItemCount int `json:"item_count"` + } `json:"children"` + } `json:"children"` + } `json:"tree"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Tree.Name != "root" || resp.Tree.Path != "" { + t.Errorf("根应为 root/空,实为 %q/%q", resp.Tree.Name, resp.Tree.Path) + } + if resp.Tree.TotalCount != 4 { + t.Errorf("总条目应为 4,实为 %d", resp.Tree.TotalCount) + } + // 子节点 Name 是本级段名、Path 是全路径 + var tech *struct { + Name string `json:"name"` + Path string `json:"path"` + TotalCount int `json:"total_count"` + Children []struct { + ItemCount int `json:"item_count"` + } `json:"children"` + } + for i := range resp.Tree.Children { + if resp.Tree.Children[i].Name == "tech" { + tech = &resp.Tree.Children[i] + } + } + if tech == nil { + t.Fatal("缺少 tech 节点") + } + if tech.Path != "tech" || tech.TotalCount != 3 { + t.Errorf("tech 应为 path=tech total=3,实为 %q/%d", tech.Path, tech.TotalCount) + } + if len(tech.Children) != 2 { + t.Errorf("tech 下应有 2 个子分类,实为 %d", len(tech.Children)) + } + + // 响应里不得出现向量 + if containsStr(rec.Body.String(), `"vector"`) { + t.Error("树 API 不应返回向量") + } +} + +// 子树路由:/tree/tech/go +func TestKnowledgeTreeSubtreeAPI(t *testing.T) { + h, _ := newTreeHandler(t) + rec := treeGet(t, h, "/api/v1/knowledge/tree/tech/go") + if rec.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Tree struct { + Name string `json:"name"` + Path string `json:"path"` + ItemCount int `json:"item_count"` + } `json:"tree"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Tree.Path != "tech/go" || resp.Tree.ItemCount != 2 { + t.Errorf("tech/go 应有 2 条,实为 %q/%d", resp.Tree.Path, resp.Tree.ItemCount) + } + if resp.Tree.Name != "go" { + t.Errorf("Name 应为本级段名 go,实为 %q", resp.Tree.Name) + } +} + +// 不存在的分类:404 + 附上现有分类便于自查。 +func TestKnowledgeTreeNotFound(t *testing.T) { + h, _ := newTreeHandler(t) + rec := treeGet(t, h, "/api/v1/knowledge/tree/nope/here") + if rec.Code != http.StatusNotFound { + t.Fatalf("应 404,实为 %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Error string `json:"error"` + Categories []string `json:"categories"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if len(resp.Categories) == 0 { + t.Error("404 应附带现有分类列表,便于调用方自查") + } +} + +// 懒加载:depth=1 只给一层但计数准确;items=0 不返回条目。 +func TestKnowledgeTreeLazyLoad(t *testing.T) { + h, _ := newTreeHandler(t) + rec := treeGet(t, h, "/api/v1/knowledge/tree?depth=1&items=0") + if rec.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d", rec.Code) + } + var resp struct { + Tree struct { + TotalCount int `json:"total_count"` + Children []struct { + Name string `json:"name"` + TotalCount int `json:"total_count"` + Children []any `json:"children"` + } `json:"children"` + } `json:"tree"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Tree.TotalCount != 4 { + t.Errorf("剪枝不应改变总数,实为 %d", resp.Tree.TotalCount) + } + for _, c := range resp.Tree.Children { + if len(c.Children) != 0 { + t.Errorf("depth=1 时 %s 不应展开子节点", c.Name) + } + } + if containsStr(rec.Body.String(), `"preview"`) { + t.Error("items=0 时不应返回 preview") + } +} + +// 分类内检索:/tree/tech?q=... +func TestKnowledgeTreeSearchInCategory(t *testing.T) { + h, _ := newTreeHandler(t) + rec := treeGet(t, h, "/api/v1/knowledge/tree/tech?q=borrow") + if rec.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Results []knowledgeView `json:"results"` + Category string `json:"category"` + Query string `json:"query"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + // 首位必须是真正相关的 rust/所有权。 + // + // 不断言"只命中 1 条":词法路对任何查询都会给全库打个低分(这是它 + // 融合设计的已知性质——靠排序把强命中顶到前面,而不是靠过滤把弱命中 + // 删掉),所以 tech 子树内会有 3 条候选。这是有意行为,不是缺陷。 + if len(resp.Results) == 0 { + t.Fatal("tech 子树内检索 borrow 应有命中") + } + if resp.Results[0].Name != "tech/rust/所有权" { + t.Errorf("首位应是最相关的 rust/所有权,实为 %q", resp.Results[0].Name) + } + // 但范围外的 life/sleep 绝不能出现 + for _, r := range resp.Results { + if !strings.HasPrefix(r.Name, "tech/") { + t.Errorf("范围外条目 %q 混入 tech 子树检索结果", r.Name) + } + } + if resp.Category != "tech" { + t.Errorf("应回显 category=tech,实为 %q", resp.Category) + } +} + +// 平铺分类与计数端点。 +func TestKnowledgeTreeCategoriesAndCounts(t *testing.T) { + h, _ := newTreeHandler(t) + + rec := treeGet(t, h, "/api/v1/knowledge/tree/categories") + if rec.Code != http.StatusOK { + t.Fatalf("categories 应 200,实为 %d", rec.Code) + } + var c1 struct { + Categories []string `json:"categories"` + } + json.Unmarshal(rec.Body.Bytes(), &c1) + want := map[string]bool{"tech": true, "tech/go": true, "tech/rust": true, "life": true} + for _, c := range c1.Categories { + delete(want, c) + } + if len(want) != 0 { + t.Errorf("缺少 %v,实为 %v", want, c1.Categories) + } + + rec = treeGet(t, h, "/api/v1/knowledge/tree/counts") + if rec.Code != http.StatusOK { + t.Fatalf("counts 应 200,实为 %d", rec.Code) + } + var c2 struct { + Counts []struct { + Category string `json:"category"` + Count int `json:"count"` + } `json:"counts"` + } + json.Unmarshal(rec.Body.Bytes(), &c2) + byName := map[string]int{} + for _, x := range c2.Counts { + byName[x.Category] = x.Count + } + if byName["tech"] != 3 || byName["life"] != 1 { + t.Errorf("计数错误: %v", byName) + } +} + +// 只读面:非 GET 一律拒绝(防止有人以为能通过它写入)。 +func TestKnowledgeTreeIsReadOnly(t *testing.T) { + h, ks := newTreeHandler(t) + before := len(ks.List()) + for _, m := range []string{http.MethodPost, http.MethodDelete, http.MethodPut, http.MethodPatch} { + rec := httptest.NewRecorder() + h.handleKnowledgeTree(rec, httptest.NewRequest(m, "/api/v1/knowledge/tree", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s 应 405,实为 %d", m, rec.Code) + } + } + if len(ks.List()) != before { + t.Error("只读端点不应改变条目数") + } +} + +// 知识库不可用时给 503 而不是 panic。 +func TestKnowledgeTreeUnavailable(t *testing.T) { + h, _ := newTestHandler(t) + rec := treeGet(t, h, "/api/v1/knowledge/tree") + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("无知识库应 503,实为 %d", rec.Code) + } +} + +func containsStr(hay, needle string) bool { + return len(hay) >= len(needle) && (func() bool { + for i := 0; i+len(needle) <= len(hay); i++ { + if hay[i:i+len(needle)] == needle { + return true + } + } + return false + })() +} diff --git a/internal/sdk/knowledge.go b/internal/sdk/knowledge.go index c1f50bf..5532c37 100644 --- a/internal/sdk/knowledge.go +++ b/internal/sdk/knowledge.go @@ -25,6 +25,15 @@ type KnowledgeAPI interface { // SearchIn 在某个分类子树内检索(category 为空 = 全库)。 SearchIn(query, category string, topK int) ([]*Knowledge, error) + + // Tree 返回分类树视图(面向服务:不带向量,只带计数与摘要)。 + Tree(opt KnowledgeTreeOptions) (*KnowledgeTreeView, error) + // Subtree 返回某棵子树;category 为空等价于 Tree。 + Subtree(category string, opt KnowledgeTreeOptions) (*KnowledgeTreeView, error) + // Categories 列出全部分类路径(去重排序)。 + Categories() ([]string, error) + // CategoryCounts 给出每个分类的条目数,按数量倒序。 + CategoryCounts() ([]KnowledgeCategoryCount, error) // AddWithMedia 写入带媒体的知识。媒体是一等节点:其向量会与正文向量 // 在多模态统一空间内融合,使该条目能按图本身被召回。 // @@ -48,3 +57,25 @@ type KnowledgeMediaRef = knowledge.KnowledgeMediaRef // Knowledge 沿用公共 SDK 的类型,保证内外两侧对同一批知识条目的 // 字段理解一致(跨 ABI 传递时按此结构序列化)。 type Knowledge = pubsdk.Knowledge + +// KnowledgeTreeOptions 控制树视图的取舍。 +type KnowledgeTreeOptions struct { + // MaxDepth 限制层数,0 = 不限。分类多时用它做懒加载。 + MaxDepth int + // IncludeItems 是否填充条目详情(只看结构时可关掉)。 + IncludeItems bool + // PreviewLimit 预览字数上限,0 用内核默认。 + PreviewLimit int +} + +// KnowledgeTreeView 是分类树节点。 +type KnowledgeTreeView = knowledge.TreeView + +// KnowledgeTreeItemView 是树上的知识条目。 +type KnowledgeTreeItemView = knowledge.TreeItemView + +// KnowledgeTreeMediaView 是条目挂载的媒体摘要。 +type KnowledgeTreeMediaView = knowledge.TreeMediaView + +// KnowledgeCategoryCount 是一个分类的条目数。 +type KnowledgeCategoryCount = knowledge.CategoryCount diff --git a/internal/sdk/knowledge_impl.go b/internal/sdk/knowledge_impl.go index 447cc26..bf3bd32 100644 --- a/internal/sdk/knowledge_impl.go +++ b/internal/sdk/knowledge_impl.go @@ -37,6 +37,42 @@ func (k *knowledgeImpl) SearchIn(query, category string, topK int) ([]*Knowledge return out, nil } +func (k *knowledgeImpl) Tree(opt KnowledgeTreeOptions) (*KnowledgeTreeView, error) { + if k.ks == nil { + return nil, nil + } + return k.ks.Tree(knowledge.TreeOptions{ + MaxDepth: opt.MaxDepth, + IncludeItems: opt.IncludeItems, + PreviewLimit: opt.PreviewLimit, + }), nil +} + +func (k *knowledgeImpl) Subtree(category string, opt KnowledgeTreeOptions) (*KnowledgeTreeView, error) { + if k.ks == nil { + return nil, nil + } + return k.ks.Subtree(category, knowledge.TreeOptions{ + MaxDepth: opt.MaxDepth, + IncludeItems: opt.IncludeItems, + PreviewLimit: opt.PreviewLimit, + }), nil +} + +func (k *knowledgeImpl) Categories() ([]string, error) { + if k.ks == nil { + return nil, nil + } + return k.ks.Categories(), nil +} + +func (k *knowledgeImpl) CategoryCounts() ([]KnowledgeCategoryCount, error) { + if k.ks == nil { + return nil, nil + } + return k.ks.CategoryCounts(), nil +} + func (k *knowledgeImpl) AddWithMedia(name, content string, media []KnowledgeMediaRef) error { if k.ks == nil { return nil