feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干

记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert
交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。

## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,全塞回去会把跨进程消息撑爆。

媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体
要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断
注入,需要自己的签名,且媒体在**本轮**就送到模型。

## 二、内核桥接层:原先在静默裁字段

`internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil:

- 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit`
  而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences
  → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上;
- 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用,
  于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减
  (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。

规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接
层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方
写下的标记。

标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`,
之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。

## 三、跨进程链路:不接线就是全体外部插件编译失败

`go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。
公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。
六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、
`corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧
实现、以及三个测试替身。

## 四、统一输入主干:把模态从「函数选择」降级为「字段」

`processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史
产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` +
`mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看
`stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**,
不是输入的**种类**。

媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、
`_consolidation_` 路由、正确的 `EventRawInput`。

最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而
`webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。
**用户发的图从来没出现在 WebUI 聊天记录里。**

`media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`:
字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。

## 五、模型可调用的三个工具

`memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节;
连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加
`media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断,
标记通常就在尾部)。

标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误
静默切断引用绑定,而全链路无人报错。

## 六、WebUI 上传走真实媒体链路

图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。
此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回
文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type
时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。

## 测试

- `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**)
- `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体)
- `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测)

压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart`
无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine
注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定
只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类
阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。

测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表
是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名
时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。

存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。
17 个 example 插件源码零改动通过类型检查。
This commit is contained in:
JianFeeeee
2026-09-06 09:51:31 +08:00
parent c928ff4376
commit c49c06e0cb
28 changed files with 3229 additions and 267 deletions

View File

@ -24,5 +24,9 @@ type TextMemoryAPI interface {
type TextEvent = pubsdk.TextEvent
// MediaAttachment 是记忆附件(媒体)在插件边界上的表示。
// 与公共 SDK 同一类型,内置插件与外部插件用同一套字段。
type MediaAttachment = pubsdk.MediaAttachment
type DocMemoryAPI = pubsdk.DocMemoryAPI
type Doc = pubsdk.Doc

View File

@ -1,110 +1,587 @@
package sdk
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
type graphMemory struct{ db *memory.GraphDB }
// 插件侧记忆接口的实现(SDK 桥接层)。
//
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起;
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。
//
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
// ---------- 媒体标记(本层内部) ----------
//
// 标记是媒体在**纯文本记忆**里的表示形式:
//
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
// └ label └ 短 digest └ 描述
//
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
// digest 是回到字节的钥匙(反查靠它)。
//
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
const sdkShortDigestLen = 12
// sdkMarkerPattern 拆解一条标记,捕获组依次为 label、短 digest、该行剩余描述。
// digest 放宽到 8-64 位以容忍完整 digest 手写的情况;描述取到行尾而非贪婪到底,
// 因为一条记忆可能挂多份媒体、各占一行。
var sdkMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
func sdkShortDigest(d string) string {
if len(d) > sdkShortDigestLen {
return d[:sdkShortDigestLen]
}
return d
}
// sdkMarkerFor 为一份已入库的媒体生成标记行。查不到就返回空串——
// 媒体可能已被 GC 清掉,此时不该凭空造出一条指向虚无的标记。
func sdkMarkerFor(ms *media.Store, digest string) string {
it, err := ms.Stat(digest)
if err != nil || it == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
if it.Description == "" {
// 「已入库但还没描述」与「压根没有媒体」必须可区分:
// 描述由后台循环异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
return fmt.Sprintf("[%s %s] (未描述)", label, sdkShortDigest(digest))
}
return fmt.Sprintf("[%s %s] %s", label, sdkShortDigest(digest), it.Description)
}
// sdkDigestsIn 返回文本里出现过的短 digest 集合,用于避免重复追加标记。
func sdkDigestsIn(s string) map[string]bool {
out := map[string]bool{}
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
out[m[2]] = true
}
return out
}
// sdkBindText 把文本里引用的媒体挂到 owner 上,返回新挂上的条数。
//
// 短 digest 补全失败(内容已 GC、或前缀有歧义)就跳过那一条:挂一条对不上的
// 引用比不挂更糟——owner_kind/owner_id/digest 三者进了主键,digest 错了则
// DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
//
// done 记录本次已处理过的 digest。AddRef 幂等,重复挂不会多出一条引用,
// 但会让计数虚高——文档路径先按附件挂一遍、再扫正文标记挂一遍,
// 同一份媒体会被数两次,日志里「绑定 2 个」而实际只有 1 条引用。
func sdkBindText(ms *media.Store, text, ownerKind, ownerID string, done map[string]bool) int {
if ms == nil || text == "" || ownerID == "" {
return 0
}
bound := 0
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
full, err := ms.ResolvePrefix(m[2])
if err != nil {
continue
}
if done != nil && done[full] {
continue
}
if err := ms.AddRef(full, ownerKind, ownerID); err != nil {
continue
}
if done != nil {
done[full] = true
}
bound++
}
return bound
}
// sdkPutAttachment 把一份附件解析成完整 digest。
//
// 两种入口:带 Data 的是新内容(落进 CAS,相同字节自动去重);
// 只给 Digest 的是引用已有内容(补全前缀即可)。两者都不给则无效。
func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string, error) {
if len(a.Data) > 0 {
mime := a.MIME
if mime == "" {
mime = "application/octet-stream"
}
return ms.Put(a.Data, media.Item{
MIME: mime,
Tool: tool,
OriginPath: a.Name,
Description: a.Description,
})
}
if a.Digest == "" {
return "", fmt.Errorf("附件既无 data 也无 digest")
}
full, err := ms.ResolvePrefix(a.Digest)
if err != nil {
return "", fmt.Errorf("digest %s: %w", a.Digest, err)
}
return full, nil
}
// sdkAttachmentsFromText 从文本标记反解出附件元数据(不含字节),
// 让插件不必自己写正则去认标记。
func sdkAttachmentsFromText(ms *media.Store, s string) []MediaAttachment {
if ms == nil || s == "" {
return nil
}
var out []MediaAttachment
seen := map[string]bool{}
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
full, err := ms.ResolvePrefix(m[2])
if err != nil || seen[full] {
continue
}
seen[full] = true
att := MediaAttachment{Digest: full, MIME: m[1], Description: strings.TrimSpace(m[3])}
if it, err := ms.Stat(full); err == nil && it != nil {
att.MIME = it.MIME
if it.Description != "" {
att.Description = it.Description
}
}
out = append(out, att)
}
return out
}
// ---------- 图记忆 ----------
type graphMemory struct {
db *memory.GraphDB
ms *media.Store
plugin string
}
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
// NewGraphMemoryWithMedia 创建带媒体能力的图记忆包装。plugin 仅用于日志溯源。
func NewGraphMemoryWithMedia(plugin string, db *memory.GraphDB, ms *media.Store) MemoryAPI {
return &graphMemory{db: db, ms: ms, plugin: plugin}
}
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
if m.db == nil { return nil, nil, nil }
if m.db == nil {
return nil, nil, nil
}
result, err := m.db.Recall(query, nil, depth, "")
if err != nil { return nil, nil, err }
if err != nil {
return nil, nil, err
}
entities := make([]Entity, len(result.Entities))
for i, e := range result.Entities {
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
}
// Confidence 此前被丢弃:插件拿不到置信度就无法判断一条关系可不可信,
// 只能把所有召回结果等同对待。
relations := make([]Relation, len(result.Relations))
for i, r := range result.Relations {
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
relations[i] = Relation{
SourceName: r.SourceName,
TargetName: r.TargetName,
RelationType: r.RelationType,
Confidence: r.Confidence,
}
}
return entities, relations, nil
}
// Commit 把插件的三元组写入图库,并把三元组引用的媒体挂到句子上。
//
// 媒体的绑定链是 SentenceText → sentences 表 → sentence_id → media_refs。
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通:
// 插件即便按格式写好标记,媒体也永远挂不上。
func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil { return nil }
ts := make([]memory.Triple, len(triples))
for i, t := range triples {
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
if m.db == nil {
return nil
}
ts := make([]memory.Triple, 0, len(triples))
for _, t := range triples {
mt := memory.Triple{
Subject: t.Subject,
Relation: t.Relation,
Object: t.Object,
Confidence: t.Confidence,
SubjectType: t.SubjectType,
ObjectType: t.ObjectType,
SentenceText: t.SentenceText,
}
if len(t.MediaDigests) > 0 {
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
}
ts = append(ts, mt)
}
sentenceIDs, _, _, err := m.db.CommitWithMedia(ts, "plugin", 0)
if err != nil {
return err
}
m.bindSentences(sentenceIDs)
return nil
}
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。
//
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了
// MediaDigests,重复标记会让同一份媒体产生两条一样的句子引用。
func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) string {
present := sdkDigestsIn(sentence)
var add []string
for _, d := range digests {
if d == "" || present[sdkShortDigest(d)] {
continue
}
if m.ms == nil {
// 没有媒体存储时也把 digest 留在文本里:拿不到描述,
// 但将来存储可用时这条记忆仍能反查回字节。
add = append(add, fmt.Sprintf("[media %s] (未描述)", sdkShortDigest(d)))
present[sdkShortDigest(d)] = true
continue
}
full, err := m.ms.ResolvePrefix(d)
if err != nil {
log.Printf("[sdk media] 插件 %s 提交的 digest %s 无法解析: %v", m.plugin, d, err)
continue
}
if line := sdkMarkerFor(m.ms, full); line != "" {
add = append(add, line)
present[sdkShortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// bindSentences 把每条句子里引用的媒体挂到该句子的 graph_sentence owner 上。
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
if m.ms == nil || len(sentenceIDs) == 0 {
return
}
bound := 0
for text, sid := range sentenceIDs {
if sid == 0 {
continue
}
// 每条句子一个独立的 done 集:同一份媒体挂在不同句子上是两条
// 合法引用(owner_id 不同),不该被跨句子去重。
bound += sdkBindText(m.ms, text, media.OwnerGraphSentence,
strconv.FormatInt(sid, 10), map[string]bool{})
}
if bound > 0 {
log.Printf("[sdk media] 插件 %s 的三元组绑定 %d 个媒体引用", m.plugin, bound)
}
_, _, err := m.db.Commit(ts, "plugin", 0)
return err
}
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
if m.db == nil { return map[string]interface{}{}, nil }
if m.db == nil {
return map[string]interface{}{}, nil
}
return m.db.Introspect()
}
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
if m.db == nil { return 0, nil }
if m.db == nil {
return 0, nil
}
return m.db.MergeEntities(source, target)
}
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
if m.db == nil { return 0, nil }
if m.db == nil {
return 0, nil
}
return m.db.Purge(criteria, mode)
}
func (m *graphMemory) GraphData() (map[string]interface{}, error) {
if m.db == nil { return map[string]interface{}{}, nil }
if m.db == nil {
return map[string]interface{}{}, nil
}
return m.db.GraphData()
}
type textMemoryImpl struct{ tm *text.Memory }
// ---------- 文本记忆 ----------
type textMemoryImpl struct {
tm *text.Memory
ms *media.Store
plugin string
}
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
// NewTextMemoryWithMedia 创建带媒体能力的文本记忆包装。
func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) TextMemoryAPI {
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
}
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
//
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂 media_refs,所以媒体在这一层
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
func (m *textMemoryImpl) Append(evt TextEvent) error {
if m.tm == nil { return nil }
if m.tm == nil {
return nil
}
content := evt.Content
if len(evt.Attachments) > 0 && m.ms != nil {
var lines []string
for _, a := range evt.Attachments {
d, err := sdkPutAttachment(m.ms, a, "plugin_text:"+m.plugin)
if err != nil {
log.Printf("[sdk media] 插件 %s 文本附件入库失败: %v", m.plugin, err)
continue
}
if line := sdkMarkerFor(m.ms, d); line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
if content == "" {
content = strings.Join(lines, "\n")
} else {
content += "\n" + strings.Join(lines, "\n")
}
}
}
return m.tm.Append(text.Event{
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel,
})
}
func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
if m.tm == nil { return nil, nil }
if m.tm == nil {
return nil, nil
}
got, err := m.tm.RecentEvents(n)
if err != nil { return nil, err }
if err != nil {
return nil, err
}
out := make([]TextEvent, len(got))
for i, e := range got {
out[i] = TextEvent{Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID}
out[i] = TextEvent{
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
}
}
return out, nil
}
func (m *textMemoryImpl) Stats() map[string]interface{} {
if m.tm == nil { return map[string]interface{}{} }
if m.tm == nil {
return map[string]interface{}{}
}
return m.tm.Stats()
}
type docMemoryImpl struct{ ds *doc.Store }
// ---------- 文档记忆(知识库) ----------
type docMemoryImpl struct {
ds *doc.Store
ms *media.Store
plugin string
}
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
// NewDocMemoryWithMedia 创建带媒体能力的文档记忆包装。
func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMemoryAPI {
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
}
// Query 检索文档,并补齐媒体元数据。
//
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
// 需要字节时按 digest 单取。
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
if m.ds == nil { return nil }
if m.ds == nil {
return nil
}
got := m.ds.Query(text, topK)
out := make([]*Doc, len(got))
for i, d := range got {
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
m.fillMedia(out[i])
}
return out
}
func (m *docMemoryImpl) Insert(d *Doc) error {
if m.ds == nil { return nil }
return m.ds.Insert(&doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content})
// fillMedia 填充文档的媒体字段。
//
// 优先用 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
// 历史文档与经旧版插件写入的文档只有标记、没有引用。
func (m *docMemoryImpl) fillMedia(out *Doc) {
if m.ms == nil {
return
}
digests, err := m.ms.Refs(media.OwnerDocument, out.ID)
if err != nil {
log.Printf("[sdk media] 读取文档 %s 的媒体引用失败: %v", out.ID, err)
}
if len(digests) == 0 {
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
for _, a := range out.Attachments {
out.MediaDigests = append(out.MediaDigests, a.Digest)
}
return
}
out.MediaDigests = digests
for _, d := range digests {
it, err := m.ms.Stat(d)
if err != nil || it == nil {
continue
}
out.Attachments = append(out.Attachments, MediaAttachment{
Digest: it.Digest, MIME: it.MIME, Description: it.Description,
})
}
}
func (m *docMemoryImpl) Remove(id string) { if m.ds != nil { m.ds.Remove(id) } }
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
// InsertWithMedia 写入文档并关联媒体。
//
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content
// 计算,标记进不去正文就检索不到这份媒体。
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
if m.ds == nil || d == nil {
return nil
}
target := &doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content}
if target.Source == "" {
target.Source = "plugin:" + m.plugin
}
digests := m.storeAttachments(attachments, &target.Content)
if err := m.ds.Insert(target); err != nil {
return err
}
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
d.ID = target.ID
d.Content = target.Content
m.bindDocMedia(target, digests)
return nil
}
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string {
if m.ms == nil || len(atts) == 0 {
return nil
}
present := sdkDigestsIn(*content)
var digests, lines []string
for _, a := range atts {
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
if err != nil {
// 媒体存不进去不该让文档写入失败——它是记忆增强,不是文档必需品
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
continue
}
digests = append(digests, full)
if present[sdkShortDigest(full)] {
continue // 插件自己写了标记,不重复追加
}
present[sdkShortDigest(full)] = true
if line := sdkMarkerFor(m.ms, full); line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
if *content == "" {
*content = strings.Join(lines, "\n")
} else {
*content += "\n" + strings.Join(lines, "\n")
}
}
return digests
}
// bindDocMedia 把附件与正文标记引用的媒体一起挂到文档 owner 上。
func (m *docMemoryImpl) bindDocMedia(target *doc.Doc, digests []string) {
if m.ms == nil || target.ID == "" {
return
}
bound := 0
done := make(map[string]bool, len(digests))
for _, full := range digests {
if done[full] {
continue
}
if err := m.ms.AddRef(full, media.OwnerDocument, target.ID); err != nil {
log.Printf("[sdk media] 文档引用绑定失败 (%s → doc %s): %v",
sdkShortDigest(full), target.ID, err)
continue
}
done[full] = true
bound++
}
// 插件手写在正文里的标记同样要挂上,否则那些媒体在文档里可见却无主。
// 共用 done:附件刚挂过的那些是同一份媒体(内核自己把标记补进了正文)。
bound += sdkBindText(m.ms, target.Content, media.OwnerDocument, target.ID, done)
if bound > 0 {
log.Printf("[sdk media] 插件 %s 写入文档 %s,绑定 %d 个媒体引用",
m.plugin, target.ID, bound)
}
}
// Remove 删除文档,同时释放它持有的媒体引用。
//
// 旧实现只删文档不解引用,于是那些媒体永久处于「被引用」状态:GC 不回收,
// 磁盘只增不减。内核的归档路径(distill 的 releaseDocMedia)做了这一步,
// 插件路径漏了同一步。
func (m *docMemoryImpl) Remove(id string) {
if m.ds == nil {
return
}
if m.ms != nil && id != "" {
if n, err := m.ms.DropOwner(media.OwnerDocument, id); err != nil {
log.Printf("[sdk media] 释放文档 %s 的媒体引用失败: %v", id, err)
} else if n > 0 {
log.Printf("[sdk media] 文档 %s 删除,释放 %d 个媒体引用", id, n)
}
}
m.ds.Remove(id)
}
func (m *docMemoryImpl) Stats() map[string]interface{} {
if m.ds == nil { return map[string]interface{}{} }
if m.ds == nil {
return map[string]interface{}{}
}
return m.ds.Stats()
}

View File

@ -0,0 +1,434 @@
package sdk
import (
"path/filepath"
"strconv"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
// 插件边界的媒体透传测试。
//
// 断言的都是不变量,而不是"函数被调过":
// 1. 插件填的字段一个都不许丢(旧实现静默裁掉 Confidence/类型/SentenceText);
// 2. 插件不必知道媒体标记格式,内核负责补;
// 3. 媒体引用挂到正确的 owner 上,删除时释放;
// 4. mediaStore 为 nil 时整条链路退化成纯文本,不 panic 不报错。
func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *media.Store) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ds := doc.NewStore(filepath.Join(dir, "documents"))
if err := ds.Start(); err != nil {
t.Fatalf("doc store start: %v", err)
}
t.Cleanup(func() { ds.Stop() })
tm := text.New(filepath.Join(dir, "text"))
if err := tm.Start(); err != nil {
t.Fatalf("text memory start: %v", err)
}
t.Cleanup(func() { tm.Stop() })
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
return g, ds, tm, ms
}
// putDescribed 存一份带描述的媒体,返回完整 digest。
func putDescribed(t *testing.T, ms *media.Store, payload, desc string) string {
t.Helper()
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png", Description: desc})
if err != nil {
t.Fatalf("media.Put: %v", err)
}
return d
}
// ---------- 图记忆 ----------
// 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃:
// 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。
func TestGraphCommit_CarriesAllFields(t *testing.T) {
g, _, _, ms := newTestStores(t)
m := NewGraphMemoryWithMedia("tester", g, ms)
err := m.Commit([]Triple{{
Subject: "张三",
Relation: "养",
Object: "橘猫",
Confidence: 0.75,
SubjectType: "Person",
ObjectType: "Animal",
SentenceText: "张三养了一只橘猫。",
}})
if err != nil {
t.Fatalf("Commit: %v", err)
}
res, err := g.Recall([]string{"张三"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 {
t.Fatal("召回不到刚提交的关系")
}
r := res.Relations[0]
if r.Confidence != 0.75 {
t.Errorf("Confidence = %v,期望 0.75(插件标注的置信度被丢弃)", r.Confidence)
}
if r.SentenceText != "张三养了一只橘猫。" {
t.Errorf("SentenceText = %q,期望原句(丢了它媒体就没有落点)", r.SentenceText)
}
var subjType, objType string
for _, e := range res.Entities {
switch e.Name {
case "张三":
subjType = e.Type
case "橘猫":
objType = e.Type
}
}
if subjType != "Person" || objType != "Animal" {
t.Errorf("实体类型 = (%q,%q),期望 (Person,Animal)", subjType, objType)
}
}
// 插件只给 digest,标记与句子由内核合成;引用必须挂到 graph_sentence owner 上。
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
g, _, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "png-bytes", "一张紫蓝红三色带图")
m := NewGraphMemoryWithMedia("tester", g, ms)
if err := m.Commit([]Triple{{
Subject: "配色图",
Relation: "包含",
Object: "三色带",
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
res, err := g.Recall([]string{"配色图"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
}
sid := res.Relations[0].SentenceID
// 描述必须进句子:描述文本才是持久语义记忆,检索靠它。
if !strings.Contains(res.Relations[0].SentenceText, "三色带图") {
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
}
refs, err := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
if err != nil {
t.Fatalf("Refs: %v", err)
}
if len(refs) != 1 || refs[0] != digest {
t.Errorf("句子 #%d 的媒体引用 = %v,期望 [%s]", sid, refs, digest)
}
}
// 插件自己按格式写了标记又同时填了 MediaDigests,不能产生两条重复引用/两份标记。
func TestGraphCommit_NoDuplicateMarker(t *testing.T) {
g, _, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "dup-bytes", "重复标记测试图")
short := digest[:12]
m := NewGraphMemoryWithMedia("tester", g, ms)
if err := m.Commit([]Triple{{
Subject: "重复图",
Relation: "标记",
Object: "一次",
SentenceText: "看这个 [image/png " + short + "] 重复标记测试图",
MediaDigests: []string{short},
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
res, _ := g.Recall([]string{"重复图"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
if n := strings.Count(res.Relations[0].SentenceText, short); n != 1 {
t.Errorf("句子里出现 %d 次 digest,期望 1 次: %q", n, res.Relations[0].SentenceText)
}
}
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品),digest 留在文本里备查。
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
g, _, _, _ := newTestStores(t)
m := NewGraphMemory(g)
if err := m.Commit([]Triple{{
Subject: "无存储",
Relation: "仍可",
Object: "提交",
MediaDigests: []string{"aabbccddeeff"},
}}); err != nil {
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
}
res, _ := g.Recall([]string{"无存储"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
if !strings.Contains(res.Relations[0].SentenceText, "aabbccddeeff") {
t.Errorf("digest 应留在句子里以备将来反查: %q", res.Relations[0].SentenceText)
}
}
// Recall 必须把置信度带回插件:拿不到它,插件只能把所有召回结果等同看待。
func TestGraphRecall_CarriesConfidence(t *testing.T) {
g, _, _, ms := newTestStores(t)
m := NewGraphMemoryWithMedia("tester", g, ms)
// 实体名至少两个字符:validEntityName 会静默跳过单字实体,
// 那样 Commit 返回 nil 但什么都没写,测试会退化成假阳性。
if err := m.Commit([]Triple{{
Subject: "甲方", Relation: "疑似", Object: "乙方", Confidence: 0.3,
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
_, rels, err := m.Recall([]string{"甲方"}, 2)
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(rels) == 0 {
t.Fatal("召回为空")
}
if rels[0].Confidence != 0.3 {
t.Errorf("Confidence = %v,期望 0.3", rels[0].Confidence)
}
}
// ---------- 文档记忆(知识库) ----------
// 附件带 Data → 落进 CAS、标记补进正文、引用挂到文档 owner。
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
_, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png",
Data: []byte("attachment-bytes"),
Name: "chart.png",
Description: "一张柱状图",
}})
if err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if d.ID == "" {
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
}
// 标记必须进正文:向量索引用 Summary+Content 计算,
// 标记进不去正文就永远检索不到这份媒体。
if !strings.Contains(d.Content, "柱状图") {
t.Errorf("正文里没有媒体标记: %q", d.Content)
}
refs, err := ms.Refs(media.OwnerDocument, d.ID)
if err != nil {
t.Fatalf("Refs: %v", err)
}
if len(refs) != 1 {
t.Fatalf("文档媒体引用 = %v,期望 1 条", refs)
}
// 内容可读,说明真的落盘了而不只是记了个 digest。
got, err := ms.Get(refs[0])
if err != nil || string(got) != "attachment-bytes" {
t.Errorf("媒体内容读回失败: %v / %q", err, got)
}
}
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
_, ds, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "existing", "已有的图")
before := ms.Stats()["count"]
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "引用已有", Content: "正文"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{Digest: digest[:10]}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if after := ms.Stats()["count"]; after != before {
t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after)
}
refs, _ := ms.Refs(media.OwnerDocument, d.ID)
if len(refs) != 1 || refs[0] != digest {
t.Errorf("引用 = %v,期望 [%s]", refs, digest)
}
}
// Query 必须回媒体元数据但**不回字节**:一次检索可能命中几十份媒体,
// 全塞回插件会把跨进程消息撑爆。
func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
_, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("query-bytes"), Description: "三色带图",
}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
got := dm.Query("紫蓝红三色带 配色说明", 3)
if len(got) == 0 {
t.Fatal("检索不到刚写入的文档")
}
var hit *Doc
for _, g := range got {
if g.ID == d.ID {
hit = g
}
}
if hit == nil {
t.Fatalf("检索结果里没有目标文档: %+v", got)
}
if len(hit.MediaDigests) != 1 {
t.Errorf("MediaDigests = %v,期望 1 条", hit.MediaDigests)
}
if len(hit.Attachments) != 1 {
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
}
att := hit.Attachments[0]
if att.MIME != "image/png" || att.Description != "三色带图" {
t.Errorf("附件元数据 = %+v,期望 mime=image/png desc=三色带图", att)
}
if len(att.Data) != 0 {
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
}
}
// 历史文档只有标记、没有 media_refs(旧版插件写入的)。
// 此时要能从正文标记反解出附件,否则那些文档的媒体对插件永远不可见。
func TestDocQuery_FallsBackToMarkers(t *testing.T) {
_, ds, _, ms := newTestStores(t)
digest := putDescribed(t, ms, "legacy", "历史图片")
// 直接写底层 store,绕过 SDK 的绑定逻辑,模拟历史数据。
if err := ds.Insert(&doc.Doc{
Summary: "历史文档",
Content: "旧正文 [image/png " + digest[:12] + "] 历史图片",
}); err != nil {
t.Fatalf("Insert: %v", err)
}
dm := NewDocMemoryWithMedia("tester", ds, ms)
got := dm.Query("历史文档 旧正文", 3)
if len(got) == 0 {
t.Fatal("检索不到历史文档")
}
if len(got[0].MediaDigests) != 1 || got[0].MediaDigests[0] != digest {
t.Errorf("MediaDigests = %v,期望从标记反解出 [%s]", got[0].MediaDigests, digest)
}
}
// 旧实现删文档不解引用 → 媒体永久"被引用",GC 收不掉,磁盘只增不减。
func TestDocRemove_ReleasesMediaRefs(t *testing.T) {
_, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "待删除", Content: "正文"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("to-be-freed"), Description: "会被释放的图",
}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 1 {
t.Fatalf("前置条件不成立,引用 = %v", refs)
}
dm.Remove(d.ID)
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 0 {
t.Errorf("删除文档后仍有 %v 条引用 —— GC 永远收不掉这份媒体", refs)
}
}
// mediaStore 为 nil 时 Insert/Query/Remove 必须与本特性上线前完全一致。
func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
_, ds, _, _ := newTestStores(t)
dm := NewDocMemory(ds)
d := &Doc{Title: "无媒体存储", Content: "正文照常写入"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("ignored"),
}}); err != nil {
t.Fatalf("无媒体存储时写入不该失败: %v", err)
}
if d.ID == "" {
t.Error("ID 仍应回填")
}
got := dm.Query("无媒体存储 正文照常写入", 3)
if len(got) == 0 {
t.Fatal("检索不到文档")
}
if len(got[0].Attachments) != 0 {
t.Errorf("无媒体存储时不该有附件: %+v", got[0].Attachments)
}
dm.Remove(d.ID) // 不该 panic
}
// ---------- 文本记忆 ----------
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂引用,
// 媒体只能以标记形式留在正文里;读回时要能反解成结构化附件。
func TestTextMemory_AttachmentRoundTrip(t *testing.T) {
_, _, tm, ms := newTestStores(t)
m := NewTextMemoryWithMedia("tester", tm, ms)
if err := m.Append(TextEvent{
Role: "user",
Content: "看这张图",
Attachments: []MediaAttachment{{
MIME: "image/png", Data: []byte("text-mem-bytes"), Description: "文本记忆里的图",
}},
}); err != nil {
t.Fatalf("Append: %v", err)
}
got, err := m.RecentEvents(5)
if err != nil {
t.Fatalf("RecentEvents: %v", err)
}
if len(got) == 0 {
t.Fatal("读不到刚追加的事件")
}
last := got[len(got)-1]
if !strings.Contains(last.Content, "文本记忆里的图") {
t.Errorf("正文里没有媒体标记: %q", last.Content)
}
if len(last.Attachments) != 1 {
t.Fatalf("Attachments = %+v,期望 1 条(标记应能反解)", last.Attachments)
}
if last.Attachments[0].Description != "文本记忆里的图" {
t.Errorf("附件描述 = %q", last.Attachments[0].Description)
}
}

View File

@ -195,6 +195,57 @@ func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
}
}
// InjectInputMedia 注入带媒体内容块的输入。
//
// blocks 放在 payload 的 media_blocks 里,由 eventloop 取出转进
// stageCtx.Extra——与用户直接发图走的是同一条通道,因此自动获得
// CAS 落盘与媒体记忆绑定。与 SetToolBlocks 的区别:后者只能在工具
// 调用内部用,且媒体要等到下一条 tool message 才到模型手上。
func (a ioAdapter) InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
if a.iom != nil {
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{
"content": text,
"media_blocks": blocks,
})
}
}
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待回复。
func (a ioAdapter) InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string {
if a.iom == nil {
return ""
}
out := a.iom.InjectInputSyncTo(source, channel, "text", map[string]interface{}{
"content": text,
"media_blocks": blocks,
})
if out == nil {
return ""
}
reply, _ := out.Payload["content"].(string)
return reply
}
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
func (a ioAdapter) InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
if a.iom != nil {
a.iom.InjectInterrupt(source, channel, map[string]interface{}{
"type": "text",
"content": text,
"media_blocks": blocks,
})
}
}
// ContentBlock / ImageURL / AudioURL 是多模态内容块在插件边界上的类型。
//
// 别名到公共 SDK 而非另建一套:内置插件(webui/multimodal 等)与外部插件必须
// 用同一套结构,否则 resolveInput 的类型分支要认第三种类型,而漏认的后果是
// 媒体被静默丢弃。
type ContentBlock = pubsdk.ContentBlock
type ImageURL = pubsdk.ImageURL
type AudioURL = pubsdk.AudioURL
// SDKConfig holds all dependencies for creating a PluginSDK.
type SDKConfig struct {
IOManager *agentIO.IOManager