diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index f77f47a..8459398 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -335,6 +335,22 @@ func New(cfg AgentConfig) *Agent { cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace) cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace) } + // 知识库接入同一多模态空间:媒体作为一等节点参与稠密召回, + // 于是「按图搜知识」「按文搜含图知识」成立。 + // + // 稀疏两路(词向量 + TF-IDF)**保持启用**且仍是主召回路径:多模态 + // 空间未配置时知识库行为与此前逐字一致(退化为 0.5/0.5 两路融合)。 + if cfg.Knowledge != nil { + // 顺序要紧:先接线(含 MediaStore),再重建。ReindexDense 会 + // 尝试从 .dense.json 缓存恢复,恢复不了才重算,最后把结果落盘。 + // 若先重建后接线,首次启动算出的向量会被丢掉而不落盘。 + cfg.Knowledge.SetDenseSpace(cfg.MultimodalSpace) + cfg.Knowledge.SetMediaGetter(cfg.MediaStore) + built, skipped := cfg.Knowledge.ReindexDense() + if built > 0 || skipped > 0 { + log.Printf("[knowledge] 多模态稠密索引: 新建 %d 跳过 %d(其余命中缓存)", built, skipped) + } + } } a := &Agent{ diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 720586c..4eedef5 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -8,6 +8,7 @@ import ( "time" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" @@ -536,6 +537,30 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string { } } +// knowledgeMediaRefs 把模型给的 digest 列表解析成知识条目的媒体引用。 +// +// 复用 doc_commit 的既有约定:digest 可传前缀(ResolvePrefix),解析不了 +// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。 +// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。 +func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef { + if a.mediaStore == nil || len(digests) == 0 { + return nil + } + var out []knowledge.KnowledgeMediaRef + for _, d := range a.resolveMediaDigests(digests) { + it, err := a.mediaStore.Stat(d) + if err != nil { + continue + } + out = append(out, knowledge.KnowledgeMediaRef{ + Digest: it.Digest, + MIME: it.MIME, + Kind: string(it.Kind), + }) + } + return out +} + func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { if a.knowledge == nil { return "知识库不可用" @@ -550,7 +575,10 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { if query == "" { return "请输入查询关键词" } - results := a.knowledge.Search(query, topK) + // 可选分类限定:把召回限制在某棵分类子树内(前缀匹配,见 + // knowledge.Store.SearchIn)。不传 = 全库。 + category, _ := tc.Arguments["category"].(string) + results := a.knowledge.SearchIn(query, category, topK) if len(results) == 0 { return "未找到相关知识" } @@ -559,11 +587,9 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { if i >= topK { break } - label := k.Name - if k.Category != "" { - label = k.Category + "/" + k.Name - } - parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200))) + // Name 已是含分类的规范名("tech/go/并发"),分类前缀就在里面。 + // 曾经这里再拼一次 Category,输出成 "tech/go/tech/go/并发"(实测)。 + parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, truncateStr(k.Content, 200))) } return strings.Join(parts, "\n---\n") @@ -573,9 +599,16 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string { if name == "" || content == "" { return "name 和 content 不能为空" } - if err := a.knowledge.Add(name, content); err != nil { + // 模型可显式关联已入库的媒体(与 doc_commit 的 media_digests 同形)。 + // 这些媒体成为知识条目的一等节点:其向量会与正文向量融合, + // 使该条目能按图本身被召回,而不依赖任何生成的描述文本。 + media := a.knowledgeMediaRefs(getStringSlice(tc.Arguments, "media_digests")) + if err := a.knowledge.AddWithMedia(name, content, media); err != nil { return fmt.Sprintf("知识创建失败: %v", err) } + if len(media) > 0 { + return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符,%d 个媒体参与跨模态召回)", name, len(content), len(media)) + } return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content)) case "knowledge_list": diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index bf5b4a4..c120594 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -373,17 +373,23 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.knowledge != nil { - tools = append(tools, toolDef("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。", map[string]interface{}{ - "query": map[string]interface{}{"type": "string", "description": "查询关键词"}, - "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5}, + tools = append(tools, toolDef("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。可用 category 把搜索限定在某个分类子树内。", map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "查询关键词"}, + "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5}, + "category": map[string]interface{}{"type": "string", "description": "可选:限定在某个分类内(前缀匹配子树,如 tech 会搜 tech/go、tech/rust)。留空则搜全库"}, }, "query")) tools = append(tools, toolDef("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{})) } if a.knowledge != nil { - tools = append(tools, toolDef("knowledge_create", "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。", map[string]interface{}{ + tools = append(tools, toolDef("knowledge_create", "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。可关联已入库媒体(附图/音视频)使该知识能被图本身检索到。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, "content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"}, + "media_digests": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "可选:关联的媒体 digest(可传前缀)。媒体作为一等节点参与跨模态检索——知识能按图本身被搜到,而不依赖生成的描述文本", + }, }, "name", "content")) tools = append(tools, toolDef("knowledge_delete", "删除知识库中的指定知识条目。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "要删除的知识名称"}, diff --git a/internal/plugin/proc/capability.go b/internal/plugin/proc/capability.go index 3c9cdb7..748a868 100644 --- a/internal/plugin/proc/capability.go +++ b/internal/plugin/proc/capability.go @@ -126,9 +126,10 @@ var methodCapability = map[string]Capability{ MethodDocInsertMedia: CapDocMemory, // ---- 知识库 ---- - MethodKnowledgeSearch: CapKnowledge, - MethodKnowledgeAdd: CapKnowledge, - MethodKnowledgeList: CapKnowledge, + MethodKnowledgeSearch: CapKnowledge, + MethodKnowledgeAdd: CapKnowledge, + MethodKnowledgeAddMedia: CapKnowledge, + MethodKnowledgeList: CapKnowledge, // ---- 文本记忆 ---- MethodTextMemoryAppend: CapTextMemory, diff --git a/internal/plugin/proc/corehandler.go b/internal/plugin/proc/corehandler.go index 6047571..16f7c09 100644 --- a/internal/plugin/proc/corehandler.go +++ b/internal/plugin/proc/corehandler.go @@ -166,7 +166,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{} MethodDocStats: return h.handleDocMemory(method, params) - case MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList: + case MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeAddMedia, MethodKnowledgeList: return h.handleKnowledge(method, params) case MethodTextMemoryAppend: diff --git a/internal/plugin/proc/corehandler_memory.go b/internal/plugin/proc/corehandler_memory.go index 84a4379..d29e169 100644 --- a/internal/plugin/proc/corehandler_memory.go +++ b/internal/plugin/proc/corehandler_memory.go @@ -3,6 +3,7 @@ package proc import ( "encoding/json" "fmt" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) @@ -209,6 +210,16 @@ func (h *coreHandler) handleDocMemory(method string, params json.RawMessage) (in return nil, fmt.Errorf("未知 method: %s", method) } +// knowledgeMediaAdder 是 knowledge.addWithMedia 需要的扩展能力。 +// +// 定义为局部接口而非直接依赖 internal/sdk.KnowledgeAPI:那样会让 +// internal/plugin/proc → internal/sdk,而后者已依赖 internal/plugin 的类型, +// 形成循环(CoreSDK 的注释已说明这一点)。断言失败时返回"能力不可用", +// 而不是静默退化成不写媒体——后者会让调用方以为媒体已入库。 +type knowledgeMediaAdder interface { + AddWithMedia(name, content string, media []knowledge.KnowledgeMediaRef) error +} + // handleKnowledge 处理知识库:search / add / list。 // // 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**: @@ -222,12 +233,30 @@ func (h *coreHandler) handleKnowledge(method string, params json.RawMessage) (in return nil, errUnavailable("knowledge") } var p struct { - Query string `json:"query"` - TopK int `json:"top_k"` + Query string `json:"query"` + TopK int `json:"top_k"` + Category string `json:"category,omitempty"` } if err := unmarshal(params, &p); err != nil { return nil, err } + // 分类限定是内核侧扩展能力(见 internal/sdk/knowledge.go), + // 用局部接口断言取用;能力缺失时退回全库搜索而不是报错—— + // 那只是"少了个筛选条件",不是调用失败。 + if p.Category != "" { + if scoped, ok := kn.(interface { + SearchIn(query, category string, topK int) ([]*pubsdk.Knowledge, error) + }); ok { + results, err := scoped.SearchIn(p.Query, p.Category, p.TopK) + if err != nil { + return nil, err + } + if results == nil { + results = []*pubsdk.Knowledge{} + } + return map[string]interface{}{"results": results}, nil + } + } results, err := kn.Search(p.Query, p.TopK) if err != nil { return nil, err @@ -257,6 +286,41 @@ func (h *coreHandler) handleKnowledge(method string, params json.RawMessage) (in } return nil, kn.Add(p.Name, p.Content) + case MethodKnowledgeAddMedia: + kn := h.sdk.Knowledge() + if kn == nil { + return nil, errUnavailable("knowledge") + } + var p struct { + Name string `json:"name"` + Content string `json:"content,omitempty"` + Media []knowledge.KnowledgeMediaRef `json:"media,omitempty"` + ContentRef SharedRef `json:"content_ref,omitempty"` + MediaRef SharedRef `json:"media_ref,omitempty"` + } + if err := unmarshal(params, &p); err != nil { + return nil, err + } + // 正文与媒体清单都可能很大,优先走共享内存(与 knowledge.add 同形)。 + if err := h.resolveJSONRef(p.ContentRef, &p.Content); err != nil { + return nil, err + } + if err := h.resolveJSONRef(p.MediaRef, &p.Media); err != nil { + return nil, err + } + // 多模态是内核侧扩展能力(公开 SDK 契约不含它,见 internal/sdk/knowledge.go)。 + // 这里用局部接口 + 类型断言取用,而不把 internal/sdk 拉进本包: + // 后者已依赖 internal/plugin,直接引会成环(见 corehandler.go 顶部注释)。 + adder, ok := kn.(knowledgeMediaAdder) + if !ok { + return nil, errUnavailable("knowledge multimodal") + } + if err := adder.AddWithMedia(p.Name, p.Content, p.Media); err != nil { + return nil, err + } + // 回传媒体清单:插件后续要按 digest 引用同一份媒体。 + return map[string]interface{}{"media": p.Media}, nil + case MethodKnowledgeList: kn := h.sdk.Knowledge() if kn == nil { diff --git a/internal/plugin/proc/plugin_test.go b/internal/plugin/proc/plugin_test.go index 850edda..c24d2d0 100644 --- a/internal/plugin/proc/plugin_test.go +++ b/internal/plugin/proc/plugin_test.go @@ -3,6 +3,7 @@ package proc import ( "encoding/json" "fmt" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "strings" "sync" "testing" @@ -34,8 +35,17 @@ type fakeCoreSDK struct { // toolBlocks 累积 SetToolBlocks 收到的块(多模态注入通道)。 toolBlocks []pubsdk.ContentBlock // 文档/知识:验证大正文经 doc_ref / content_ref 走共享内存。 - docMem *fakeDocMemory - knowledge *fakeKnowledge + docMem *fakeDocMemory + // 用接口而非具体类型:需要能塞入"只实现公开 KnowledgeAPI、 + // 不具备内核多模态扩展"的替身,以验证能力缺失时的报错路径。 + knowledge knowledgeAPITest +} + +// knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。 +type knowledgeAPITest interface { + Search(query string, topK int) ([]*pubsdk.Knowledge, error) + Add(name, content string) error + List() ([]string, error) } func newFakeCore() *fakeCoreSDK { @@ -148,9 +158,18 @@ func (f *fakeDocMemory) Stats() map[string]interface{} { return nil } // fakeKnowledge 只实现测试需要的部分,记录 Add 收到的正文。 type fakeKnowledge struct { - mu sync.Mutex - name string - body string + mu sync.Mutex + name string + body string + media []knowledge.KnowledgeMediaRef +} + +// AddWithMedia 模拟内核的扩展能力(corehandler 用局部接口断言它)。 +func (f *fakeKnowledge) AddWithMedia(name, content string, media []knowledge.KnowledgeMediaRef) error { + f.mu.Lock() + f.name, f.body, f.media = name, content, media + f.mu.Unlock() + return nil } func (f *fakeKnowledge) Search(string, int) ([]*pubsdk.Knowledge, error) { return nil, nil } @@ -901,3 +920,77 @@ func TestPlugin_ToolInvokeArgsResultViaArena(t *testing.T) { } }) } + +// §13.14:knowledge.addWithMedia 走共享内存,且媒体清单能穿透到内核扩展能力。 +// +// 媒体参数可能很长(一篇知识挂几十张图),与正文同形走 content_ref/media_ref。 +func TestCoreHandler_KnowledgeAddWithMedia(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + core := newFakeCore() + kn := &fakeKnowledge{} + core.knowledge = kn + h := &coreHandler{sdk: core, name: "x", host: host, locks: &lockRegistry{}} + + content := strings.Repeat("带图知识", 2000) + blob, _ := json.Marshal(content) + cRef := arenaPutForTest(t, host, blob) + defer func() { _ = host.Arena().Free(OwnerHost, cRef) }() + + media := []knowledge.KnowledgeMediaRef{{Digest: "d1", MIME: "image/png", Kind: "image"}} + mBlob, _ := json.Marshal(media) + mRef := arenaPutForTest(t, host, mBlob) + defer func() { _ = host.Arena().Free(OwnerHost, mRef) }() + + params, _ := json.Marshal(map[string]interface{}{ + "name": "n", "content_ref": cRef, "media_ref": mRef, + }) + res, err := h.Handle(MethodKnowledgeAddMedia, params) + if err != nil { + t.Fatalf("knowledge.addWithMedia 应成功: %v", err) + } + kn.mu.Lock() + got, gotName := kn.body, kn.name + gotMedia := kn.media + kn.mu.Unlock() + if gotName != "n" { + t.Fatalf("name 传错: %q", gotName) + } + if got != content { + t.Fatalf("经共享内存送达的正文不一致(got len=%d want len=%d)", len(got), len(content)) + } + if len(gotMedia) != 1 || gotMedia[0].Digest != "d1" { + t.Fatalf("媒体清单未穿透到内核: %+v", gotMedia) + } + // 回传媒体清单(插件据此后续引用同一份媒体) + m, _ := res.(map[string]interface{}) + if m == nil || m["media"] == nil { + t.Errorf("应回传 media 清单,实为 %#v", res) + } +} + +// 内核不支持多模态扩展时必须**明确报错**,不能静默退化成"媒体已写入"。 +func TestCoreHandler_KnowledgeAddMediaCapabilityMissing(t *testing.T) { + core := newFakeCore() + // 用一个只实现公开 KnowledgeAPI 的假实现(无 AddWithMedia) + core.knowledge = &pubOnlyKnowledge{} + h := &coreHandler{sdk: core, name: "x", locks: &lockRegistry{}} + + params := json.RawMessage(`{"name":"n","content":"正文","media":[{"digest":"d1","mime":"image/png"}]}`) + if _, err := h.Handle(MethodKnowledgeAddMedia, params); err == nil { + t.Error("内核缺少多模态能力时应明确报错,而不是静默丢弃媒体") + } +} + +// pubOnlyKnowledge 只实现公开 SDK 的 KnowledgeAPI(**刻意不含**内核扩展的 +// AddWithMedia)。不能内嵌 fakeKnowledge——那会把 AddWithMedia 一起带进来, +// 断言就会成功,用例测不到「能力缺失」这条路径。 +type pubOnlyKnowledge struct{} + +func (f *pubOnlyKnowledge) Search(string, int) ([]*pubsdk.Knowledge, error) { return nil, nil } +func (f *pubOnlyKnowledge) Add(name, content string) error { return nil } +func (f *pubOnlyKnowledge) List() ([]string, error) { return nil, nil } diff --git a/internal/plugin/proc/protocol.go b/internal/plugin/proc/protocol.go index a8fbe55..22f34d4 100644 --- a/internal/plugin/proc/protocol.go +++ b/internal/plugin/proc/protocol.go @@ -128,6 +128,10 @@ const ( MethodKnowledgeSearch = "knowledge.search" // 15 MethodKnowledgeAdd = "knowledge.add" // 35 MethodKnowledgeList = "knowledge.list" // 36 + // MethodKnowledgeAddMedia 写入知识并关联媒体(与 doc.insertWithMedia + // 同形)。媒体成为一等节点参与跨模态召回;未接入多模态空间时与 + // knowledge.add 等价。 + MethodKnowledgeAddMedia = "knowledge.addWithMedia" // 文本记忆(原 case 41) MethodTextMemoryAppend = "textmemory.append" // 41 diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 58596f2..8f1ea5c 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -352,6 +352,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore), DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore), Knowledge: sdk.NewKnowledge(r.ks), + Media: sdk.NewMedia(r.mediaStore), LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey), Settings: sett, RegTool: regTool, diff --git a/internal/sdk/knowledge.go b/internal/sdk/knowledge.go index 2cbe059..c1f50bf 100644 --- a/internal/sdk/knowledge.go +++ b/internal/sdk/knowledge.go @@ -1,14 +1,50 @@ package sdk -import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +import ( + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) // KnowledgeAPI 是内置插件使用的全量知识库接口。 +// +// 为什么不把多模态加进 pubsdk.KnowledgeAPI:那会改动 +// third_party/homeagent-sdk/sdk/ 的公开契约,而发布纪律要求 +// `git diff main -- third_party/homeagent-sdk/sdk/` 恒为 0 +// (改动它等于一次 major bump + 完整的外部发版流程)。 +// internal/sdk 明确不受此约束("内部实现自由"),且这里本来就是 +// "内核侧接口 = 公共接口 + 内置插件额外能力" 的既有范式 +// (见 PluginSDK 遮蔽访问器:Settings/Memory/TextMemory/DocMemory…)。 +// 结果是:内置插件(含子进程插件的 core handler)拿到多模态能力, +// 而外部 SDK 契约保持逐字不变。 type KnowledgeAPI interface { pubsdk.KnowledgeAPI // Stats 返回知识库的运行统计。 Stats() map[string]interface{} // Remove 按名称删除一条知识。 Remove(name string) error + + // SearchIn 在某个分类子树内检索(category 为空 = 全库)。 + SearchIn(query, category string, topK int) ([]*Knowledge, error) + // AddWithMedia 写入带媒体的知识。媒体是一等节点:其向量会与正文向量 + // 在多模态统一空间内融合,使该条目能按图本身被召回。 + // + // 未接入多模态空间时与 Add 等价(媒体仍被记录,只是不参与召回)。 + AddWithMedia(name, content string, media []KnowledgeMediaRef) error + // AttachMedia 给已有知识追加媒体,并当场重算其稠密向量。 + AttachMedia(name string, media ...KnowledgeMediaRef) error + // ReindexDense 重建稠密向量(模型/维度变化后调用),返回新建与跳过条数。 + ReindexDense() (built, skipped int) + // DenseStats 报告稠密路的接线与覆盖情况。 + DenseStats() map[string]interface{} } +// KnowledgeMediaRef 是媒体在知识条目中的一等引用。 +// +// 与内核 knowledge.KnowledgeMediaRef 是**类型别名**而非新类型:别名 +// 才能穿过 C ABI / JSON 边界;若是两种结构,core handler 还得再做一次 +// 手工转换,漏一处就是「媒体被静默丢弃」。 +type KnowledgeMediaRef = knowledge.KnowledgeMediaRef + +// Knowledge 沿用公共 SDK 的类型,保证内外两侧对同一批知识条目的 +// 字段理解一致(跨 ABI 传递时按此结构序列化)。 type Knowledge = pubsdk.Knowledge diff --git a/internal/sdk/knowledge_impl.go b/internal/sdk/knowledge_impl.go index 6c4eb19..447cc26 100644 --- a/internal/sdk/knowledge_impl.go +++ b/internal/sdk/knowledge_impl.go @@ -25,6 +25,46 @@ func (k *knowledgeImpl) Add(name, content string) error { return k.ks.Add(name, content) } +func (k *knowledgeImpl) SearchIn(query, category string, topK int) ([]*Knowledge, error) { + if k.ks == nil { + return nil, nil + } + got := k.ks.SearchIn(query, category, topK) + out := make([]*Knowledge, len(got)) + for i, item := range got { + out[i] = &Knowledge{Name: item.Name, Content: item.Content} + } + return out, nil +} + +func (k *knowledgeImpl) AddWithMedia(name, content string, media []KnowledgeMediaRef) error { + if k.ks == nil { + return nil + } + return k.ks.AddWithMedia(name, content, media) +} + +func (k *knowledgeImpl) AttachMedia(name string, media ...KnowledgeMediaRef) error { + if k.ks == nil { + return nil + } + return k.ks.AttachMedia(name, media...) +} + +func (k *knowledgeImpl) ReindexDense() (int, int) { + if k.ks == nil { + return 0, 0 + } + return k.ks.ReindexDense() +} + +func (k *knowledgeImpl) DenseStats() map[string]interface{} { + if k.ks == nil { + return map[string]interface{}{} + } + return k.ks.DenseStats() +} + func (k *knowledgeImpl) List() ([]string, error) { if k.ks == nil { return nil, nil diff --git a/internal/sdk/media.go b/internal/sdk/media.go new file mode 100644 index 0000000..4cd4630 --- /dev/null +++ b/internal/sdk/media.go @@ -0,0 +1,79 @@ +package sdk + +import ( + "errors" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// ErrMediaUnavailable 表示媒体存储未接线(宿主没注入 media.Store)。 +// 单独成一个哨兵:调用方需要区分"没配"与"读失败",前者不该重试。 +var ErrMediaUnavailable = errors.New("media: 媒体存储不可用") + +// MediaAPI 是内置插件使用的媒体存储接口(内核侧扩展,非公开 SDK 契约)。 +// +// 存在的理由:多模态链路的一切都以**内容寻址**为锚——media.Store 按 +// sha256 去重落盘,向量按 digest 存在同一条记录上,知识/文档/记忆块都只 +// 存「引用」不存字节。而 WebUI 的上传(handleChatFile)历史上只把文件 +// 落在 uploads/ 目录、直接读字节拼 data URL,**从不入 CAS**,于是拿不到 +// digest,也就无法把图挂到知识条目上。 +// +// 为什么是 internal/sdk 而不是公开 SDK:同上 KnowledgeAPI 多模态方法的 +// 理由——third_party/homeagent-sdk/sdk/ 受接口冻结约束,diff 必须恒为 0。 +type MediaAPI interface { + // Put 把字节存入 CAS,返回内容 digest。同一内容重复 Put 幂等 + // (不重复落盘,只刷新 last_seen)。 + Put(data []byte, mime, tool string) (string, error) + // Stat 读元数据,不读内容。 + Stat(digest string) (MediaInfo, error) + // Get 读回内容并校验 digest。 + Get(digest string) ([]byte, error) +} + +// MediaInfo 是媒体元信息的只读视图。 +// +// 刻意与 media.Item 分离(而非直接别名):那会把 origin_path / first_seen +// 等溯源字段一并暴露给插件层,而插件只需要"这条媒体是什么、怎么取回"。 +type MediaInfo struct { + Digest string `json:"digest"` + Kind string `json:"kind"` + MIME string `json:"mime"` + Size int64 `json:"size"` +} + +type mediaImpl struct{ ms *media.Store } + +// NewMedia 构造媒体存储接口。ms 为 nil 时各方法安全降级(返回 error / 空值)。 +func NewMedia(ms *media.Store) MediaAPI { return &mediaImpl{ms: ms} } + +func (m *mediaImpl) Put(data []byte, mime, tool string) (string, error) { + if m.ms == nil { + return "", nil + } + return m.ms.Put(data, media.Item{MIME: mime, Tool: tool}) +} + +func (m *mediaImpl) Stat(digest string) (MediaInfo, error) { + if m.ms == nil { + return MediaInfo{}, ErrMediaUnavailable + } + it, err := m.ms.Stat(digest) + if err != nil { + return MediaInfo{}, err + } + return MediaInfo{ + Digest: it.Digest, + Kind: string(it.Kind), + MIME: it.MIME, + Size: it.Size, + }, nil +} + +func (m *mediaImpl) Get(digest string) ([]byte, error) { + if m.ms == nil { + return nil, ErrMediaUnavailable + } + return m.ms.Get(digest) +} + +var _ MediaAPI = (*mediaImpl)(nil) diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index e7c9f06..b9568fb 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -153,6 +153,7 @@ type PluginSDK struct { textMem TextMemoryAPI docMem DocMemoryAPI know KnowledgeAPI + media MediaAPI llm LLMAPI iom *agentIO.IOManager @@ -182,7 +183,11 @@ func (s *PluginSDK) Memory() MemoryAPI { return s.memory } func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem } func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem } func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know } -func (s *PluginSDK) LLM() LLMAPI { return s.llm } + +// Media 返回媒体存储(CAS)。多模态链路的锚:Put 拿 digest,Get 取回字节。 +// 内置插件用;公开 SDK 契约不含此方法(见 MediaAPI 的注释)。 +func (s *PluginSDK) Media() MediaAPI { return s.media } +func (s *PluginSDK) LLM() LLMAPI { return s.llm } // ioAdapter 桥接 IOManager 到公共 SDK 的 IOInjector 接口, // 确保外部插件通过 s.InjectText() 等方法的调用能被路由到内核 IO 层。 @@ -298,6 +303,7 @@ type SDKConfig struct { TextMemory TextMemoryAPI DocMemory DocMemoryAPI Knowledge KnowledgeAPI + Media MediaAPI LLM LLMAPI Settings SettingsAPI RegTool ToolRegistrar @@ -344,6 +350,7 @@ func New(name string, cfg SDKConfig) *PluginSDK { textMem: cfg.TextMemory, docMem: cfg.DocMemory, know: cfg.Knowledge, + media: cfg.Media, llm: cfg.LLM, iom: cfg.IOManager,