Files
HomeAgent/internal/sdk/memory_impl.go
JianFeeeee 687e5655bc 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 插件源码零改动通过类型检查。
2026-09-06 09:51:31 +08:00

591 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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"
)
// 插件侧记忆接口的实现SDK 桥接层)。
//
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText又走 Commit
// 而非 CommitWithMedia于是 sentences 表没有落点,媒体引用无从挂起;
// - 知识库Query 只回 ID/Title/ContentInsert 只写这三个,读写两个方向
// 都把媒体元数据裁掉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
}
result, err := m.db.Recall(query, nil, depth, "")
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,
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, 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)
}
}
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
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
}
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
}
return m.db.Purge(criteria, mode)
}
func (m *graphMemory) GraphData() (map[string]interface{}, error) {
if m.db == nil {
return map[string]interface{}{}, nil
}
return m.db.GraphData()
}
// ---------- 文本记忆 ----------
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
}
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: content, AgentID: evt.Channel,
})
}
func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
if m.tm == nil {
return nil, nil
}
got, err := m.tm.RecentEvents(n)
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,
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
}
}
return out, nil
}
func (m *textMemoryImpl) Stats() map[string]interface{} {
if m.tm == nil {
return map[string]interface{}{}
}
return m.tm.Stats()
}
// ---------- 文档记忆(知识库) ----------
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
}
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
}
// 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,
})
}
}
// 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{}{}
}
return m.ds.Stats()
}
var _ MemoryAPI = (*graphMemory)(nil)
var _ TextMemoryAPI = (*textMemoryImpl)(nil)
var _ DocMemoryAPI = (*docMemoryImpl)(nil)