diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 6875d9f..8cbbd0b 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -25,6 +25,7 @@ import ( luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" @@ -324,6 +325,27 @@ func main() { log.Printf("[homed] warning: document store: %v", err) } + // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, + // L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过, + // 对话行为与本特性上线前完全一致。 + var mediaStore *media.Store + if cfgReg.GetBool("core.memory.media.enabled", true) { + mediaDir := cfgReg.GetString("core.memory.media.dir", + filepath.Join(cfg.Daemon.DataDir, "memory", "media")) + maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048) + ms, err := media.New(mediaDir, int64(maxMB)*1024*1024) + if err != nil { + // 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品 + log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err) + } else { + mediaStore = ms + defer mediaStore.Close() + st := mediaStore.Stats() + log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)", + st["count"], st["total_bytes"], maxMB) + } + } + ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) if err := ks.Start(); err != nil { log.Printf("[homed] warning: knowledge store: %v", err) @@ -431,6 +453,7 @@ func main() { Knowledge: ks, SocialStore: socialStore, TextMemory: textMem, + MediaStore: mediaStore, Personality: personality, PluginReg: pluginReg, PluginDir: cfg.Plugin.Dir, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 1aa3881..417d317 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -14,6 +14,7 @@ import ( "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/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -50,6 +51,11 @@ type Agent struct { // 文本记忆(原始对话日志) textMem *text.Memory + // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, + // L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— + // 它是记忆增强而非对话必需品,缺了不该让对话失败。 + mediaStore *media.Store + // 人格设定 personality *agentPkg.Personality @@ -106,6 +112,13 @@ type Agent struct { // 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问 pendingMedia map[string]interface{} + // pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。 + // + // 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获, + // 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 + // 与 pendingMedia 同受 a.mu 保护。 + pendingMediaDigests []string + // 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息) interruptInput bool @@ -119,7 +132,7 @@ type Agent struct { // 输入去重:防 webui/GUI 断线重连导致的消息重放 // key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃 - lastInput map[string]time.Time + lastInput map[string]time.Time lastInputMu sync.Mutex // 词嵌入模型,用于实体语义相似度计算 @@ -151,16 +164,17 @@ type AgentConfig struct { Knowledge *knowledge.Store SocialStore *social.SocialStore TextMemory *text.Memory + MediaStore *media.Store Personality *agentPkg.Personality PluginReg *plugin.Registry PluginDir string DistillInterval time.Duration - ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval - ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval - MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval - MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 - ContextSavePath string // 上下文持久化路径,空则不持久化 - EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 + ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval + ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval + MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval + MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 + ContextSavePath string // 上下文持久化路径,空则不持久化 + EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建 StageHost *StageHost EventBus *events.Bus @@ -227,6 +241,7 @@ func New(cfg AgentConfig) *Agent { knowledge: cfg.Knowledge, social: cfg.SocialStore, textMem: cfg.TextMemory, + mediaStore: cfg.MediaStore, personality: cfg.Personality, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 848b8e2..fb09429 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -3,6 +3,7 @@ package core import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -12,6 +13,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -22,13 +24,25 @@ type ToolResultItem struct { } type ContextEvent struct { - Timestamp time.Time `json:"timestamp"` - Source string `json:"source"` - Input string `json:"input"` - Response string `json:"response,omitempty"` - ToolsUsed []string `json:"tools_used,omitempty"` - ToolResults []ToolResultItem `json:"tool_results,omitempty"` - Vector vector.Vector `json:"-"` + // ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。 + // + // 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。 + // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 + // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 + ID string `json:"id,omitempty"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Input string `json:"input"` + Response string `json:"response,omitempty"` + ToolsUsed []string `json:"tools_used,omitempty"` + ToolResults []ToolResultItem `json:"tool_results,omitempty"` + // Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。 + // + // 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的 + // 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS + // 永远能取回原始字节——只要它还没被容量 GC 淘汰。 + Media []string `json:"media,omitempty"` + Vector vector.Vector `json:"-"` } const contextFlushInterval = 5 * time.Second @@ -42,6 +56,41 @@ type RelevanceContext struct { dirty bool toolDefLookup func(name string) *sdk.ToolDef channelDefLookup func(name string) (sdk.ChannelDef, bool) + + // mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。 + // 为 nil 时引用转移静默跳过(媒体存储未启用)。 + mediaStore *media.Store +} + +// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。 +func (c *RelevanceContext) SetMediaStore(s *media.Store) { + c.mu.Lock() + defer c.mu.Unlock() + c.mediaStore = s +} + +// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。 +// +// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑 +// 就会把仍被记忆引用的内容当孤儿清掉。 +func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) { + if c.mediaStore == nil || docID == "" { + return + } + for _, s := range archive { + evt := s.event + if evt == nil || evt.ID == "" || len(evt.Media) == 0 { + continue + } + for _, d := range evt.Media { + if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil { + log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err) + } + } + if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil { + log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err) + } + } } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { @@ -256,6 +305,16 @@ func (c *RelevanceContext) flush() { c.dirty = false } +// scoredEvent 是 Prune 里按相关度排序的事件。 +// +// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要 +// 把待归档列表传进去,局部类型无法出现在方法签名上。 +type scoredEvent struct { + event *ContextEvent + score float64 + idx int +} + func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { c.mu.Lock() defer c.mu.Unlock() @@ -277,15 +336,10 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume queryVec := c.embedder.VectorizeClean(currentInput) - type scored struct { - event *ContextEvent - score float64 - idx int - } - scoredEvents := make([]scored, len(candidates)) + scoredEvents := make([]scoredEvent, len(candidates)) for i, evt := range candidates { score := vector.CosineSimilarity(queryVec, evt.Vector) - scoredEvents[i] = scored{event: evt, score: score, idx: i} + scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i} } sort.Slice(scoredEvents, func(i, j int) bool { @@ -327,6 +381,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) if err == nil && doc != nil { archived = len(entries) + // 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上, + // 再注销原事件的引用。顺序不能反——先销后挂会让引用计数 + // 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的 + // 内容当孤儿清掉。 + c.transferMediaRefs(archive, doc.ID) } } @@ -385,5 +444,3 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem { } return result } - - diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index c538772..b4845f9 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -174,6 +174,11 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source) + // 用户直接发来的媒体:先落进 CAS。 + // 不存的后果是 ContextEvent.Input 只剩一句 alt 文本 + //("[从 qq 收到了 image]"),base64 随 message 数组发给模型后就丢了。 + a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...) + stageCtx := a.stageCtxFromInput(fallback, evt.Source, "") stageCtx.Extra = map[string]interface{}{ "media_blocks": blocks, @@ -216,14 +221,22 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { elapsed := time.Since(start) log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed) - a.context.Append(ContextEvent{ + // 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。 + // 媒体描述并进 Input:描述文本才是持久语义记忆,blob 只是缓存。 + digests := a.drainMediaDigests() + mediaEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", Input: fallback, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, - }) + } + a.bindEventMedia(&mediaEvt, digests) + if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" { + mediaEvt.Input = mediaEvt.Input + "\n" + s + } + a.context.Append(mediaEvt) a.emitResponse(evt, response) @@ -271,12 +284,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, } if mediaType == "image" { blocks = append(blocks, agentAPI.ContentBlock{ - Type: "image_url", + Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"}, }) } else if mediaType == "audio" { blocks = append(blocks, agentAPI.ContentBlock{ - Type: "audio_url", + Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: imgURL}, }) } @@ -371,14 +384,21 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { elapsed := time.Since(start) log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed) - a.context.Append(ContextEvent{ + // 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时, + // 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。 + textEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", Input: cleanInput, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, - }) + } + a.bindEventMedia(&textEvt, a.drainMediaDigests()) + if s := a.mediaSummaryForEvent(textEvt.Media); s != "" { + textEvt.Input = textEvt.Input + "\n" + s + } + a.context.Append(textEvt) a.emitResponse(evt, response) diff --git a/internal/agent/core/mediaref.go b/internal/agent/core/mediaref.go new file mode 100644 index 0000000..56a8099 --- /dev/null +++ b/internal/agent/core/mediaref.go @@ -0,0 +1,160 @@ +package core + +import ( + "fmt" + "log" + "strings" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), +// 并让 L0 的 ContextEvent 记住它们的 digest。 +// +// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆—— +// +// 1. 用户直接发图 → processMediaInput → mediaToBlocks +// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"), +// base64 随 message 数组发给模型后就丢了。 +// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg +// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。 +// +// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、 +// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。 +// +// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS, +// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。 + +// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。 +// +// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址, +// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了), +// 不在本层解决。 +func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string { + if a.mediaStore == nil || len(blocks) == 0 { + return nil + } + + var digests []string + for _, b := range blocks { + var url string + switch { + case b.ImageURL != nil && b.ImageURL.URL != "": + url = b.ImageURL.URL + case b.AudioURL != nil && b.AudioURL.URL != "": + url = b.AudioURL.URL + default: + continue + } + + mime, data, ok := media.ParseDataURL(url) + if !ok { + continue // http(s) URL 或格式不认,跳过 + } + + d, err := a.mediaStore.Put(data, media.Item{ + MIME: mime, + Tool: tool, + }) + if err != nil { + // 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品 + log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err) + continue + } + digests = append(digests, d) + } + return digests +} + +// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。 +// +// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的 +// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 +// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。 +func (a *Agent) stageMediaDigests(digests ...string) { + if len(digests) == 0 { + return + } + a.pendingMediaDigests = append(a.pendingMediaDigests, digests...) +} + +// drainMediaDigests 取出并清空本轮累积的 digest。 +func (a *Agent) drainMediaDigests() []string { + if len(a.pendingMediaDigests) == 0 { + return nil + } + out := a.pendingMediaDigests + a.pendingMediaDigests = nil + return out +} + +// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。 +// +// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化), +// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。 +// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容, +// 要么孤儿永远清不掉。 +func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { + if a.mediaStore == nil || evt == nil || len(digests) == 0 { + return + } + if evt.ID == "" { + evt.ID = newEventID() + } + for _, d := range digests { + if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil { + log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err) + continue + } + evt.Media = append(evt.Media, d) + } +} + +// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。 +// +// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。 +// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里, +// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。 +func (a *Agent) mediaSummaryForEvent(digests []string) string { + if a.mediaStore == nil || len(digests) == 0 { + return "" + } + var lines []string + for _, d := range digests { + it, err := a.mediaStore.Stat(d) + if err != nil || it == nil { + continue + } + label := string(it.Kind) + if it.MIME != "" { + label = it.MIME + } + if it.Description != "" { + lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description)) + } else { + lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d))) + } + } + if len(lines) == 0 { + return "" + } + return "媒体内容:\n" + strings.Join(lines, "\n") +} + +// newEventID 生成 ContextEvent 的稳定标识。 +// +// 沿用 document.Store 的 doc_ 手法(同一份代码库里保持一致, +// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由 +// a.mu 串行化 Append,不存在同纳秒两条。 +func newEventID() string { + return fmt.Sprintf("evt_%d", time.Now().UnixNano()) +} + +func shortDigest(d string) string { + if len(d) > 12 { + return d[:12] + } + return d +} diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go new file mode 100644 index 0000000..875b0dc --- /dev/null +++ b/internal/agent/core/mediaref_test.go @@ -0,0 +1,355 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、 +// 随归档转到 L2 文档的完整链路。 +// +// 核心断言不是"函数被调用了",而是不变量: +// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品) +// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口) +// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致 + +func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { + t.Helper() + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + emb := memory.NewStaticEmbedder() + a := &Agent{ + mediaStore: ms, + context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), + } + a.context.SetMediaStore(ms) + return a, ms +} + +// imageBlockURL 造一个带指定 URL 的图片块。 +// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的 +// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。 +func imageBlockURL(dataURL string) agentAPI.ContentBlock { + return agentAPI.ContentBlock{ + Type: "image_url", + ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"}, + } +} + +func TestCaptureBlockMedia_StoresDataURL(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3} + blocks := []agentAPI.ContentBlock{ + {Type: "text", Text: "看这张图"}, + imageBlockURL(media.DataURL("image/png", raw)), + } + + digests := a.captureBlockMedia(blocks, "multimodal_see_picture") + if len(digests) != 1 { + t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests)) + } + + got, err := ms.Get(digests[0]) + if err != nil { + t.Fatalf("回读失败: %v", err) + } + if string(got) != string(raw) { + t.Fatal("内容不一致") + } + it, _ := ms.Stat(digests[0]) + if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage { + t.Fatalf("元数据不对: %+v", it) + } +} + +func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) { + // http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话 + // 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。 + a, _ := newTestAgentWithMedia(t) + + blocks := []agentAPI.ContentBlock{ + imageBlockURL("https://example.com/x.png"), + } + if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 { + t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d)) + } +} + +func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) { + // mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错—— + // 行为必须与本特性上线前完全一致。 + a := &Agent{} + blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))} + if d := a.captureBlockMedia(blocks, "t"); d != nil { + t.Fatalf("nil store 应返回 nil,实际 %v", d) + } + a.stageMediaDigests("deadbeef") + if got := a.drainMediaDigests(); len(got) != 1 { + t.Fatal("stage/drain 不依赖 store,应正常工作") + } + // bindEventMedia 对 nil store 也必须安全 + evt := &ContextEvent{} + a.bindEventMedia(evt, []string{"deadbeef"}) + if len(evt.Media) != 0 || evt.ID != "" { + t.Fatalf("nil store 时不该改动事件: %+v", evt) + } + if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" { + t.Fatalf("nil store 时摘要应为空,得到 %q", s) + } +} + +func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + blocks := []agentAPI.ContentBlock{ + imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))), + {Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}}, + } + digests := a.captureBlockMedia(blocks, "multimodal_see_video") + if len(digests) != 2 { + t.Fatalf("应捕获 2 个,实际 %d", len(digests)) + } + + kinds := map[media.Kind]int{} + for _, d := range digests { + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + kinds[it.Kind]++ + } + if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 { + t.Fatalf("大类归属不对: %v", kinds) + } +} + +func TestStageDrainMediaDigests(t *testing.T) { + a, _ := newTestAgentWithMedia(t) + + a.stageMediaDigests("a", "b") + a.stageMediaDigests("c") + got := a.drainMediaDigests() + if len(got) != 3 { + t.Fatalf("应累积 3 个,实际 %d", len(got)) + } + // drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍 + if again := a.drainMediaDigests(); again != nil { + t.Fatalf("drain 后应为空,实际 %v", again) + } +} + +func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + + evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"} + a.bindEventMedia(evt, []string{d}) + + if evt.ID == "" { + t.Fatal("应懒生成事件 ID") + } + if len(evt.Media) != 1 || evt.Media[0] != d { + t.Fatalf("事件应记住 digest: %+v", evt.Media) + } + // 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删 + it, _ := ms.Stat(d) + if it.RefCount != 1 { + t.Fatalf("引用计数应为 1,实际 %d", it.RefCount) + } + refs, _ := ms.Refs(media.OwnerContext, evt.ID) + if len(refs) != 1 { + t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs)) + } +} + +func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) { + // 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json + a, _ := newTestAgentWithMedia(t) + evt := &ContextEvent{Input: "纯文本"} + a.bindEventMedia(evt, nil) + if evt.ID != "" { + t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID) + } +} + +func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) { + // 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。 + // blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。 + a, ms := newTestAgentWithMedia(t) + + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if s := a.mediaSummaryForEvent([]string{d}); s == "" { + t.Fatal("未描述项也应产出一行(标注未描述)") + } + + ms.Describe(d, "一张紫蓝红三色带图", "visionllm") + s := a.mediaSummaryForEvent([]string{d}) + if s == "" { + t.Fatal("应产出摘要") + } + if !strings.Contains(s, "紫蓝红三色带图") { + t.Fatalf("摘要应含描述文本: %q", s) + } + if !strings.Contains(s, "image/png") { + t.Fatalf("摘要应含 MIME 标注: %q", s) + } +} + +func TestPrune_TransfersMediaRefsToDocument(t *testing.T) { + // L0→L2 归档:媒体引用从 context 事件转到归档文档, + // 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。 + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + emb := memory.NewStaticEmbedder() + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatal(err) + } + rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) + rc.SetMediaStore(ms) + + a := &Agent{mediaStore: ms, context: rc} + + // 造一张被引用的图,挂到一条会被淘汰的老事件上 + payload := []byte("archived-image") + d, _ := ms.Put(payload, media.Item{MIME: "image/png"}) + oldEvt := ContextEvent{ + Timestamp: time.Now().Add(-time.Hour), + Source: "qq", + Input: "很久以前的一张图", + } + a.bindEventMedia(&oldEvt, []string{d}) + oldEvtID := oldEvt.ID + rc.Append(oldEvt) + + // 再塞满 12 条新事件,逼 Prune 把老事件淘汰 + // (Prune 保护最近 10 条,topK 传 5 使候选全部进归档) + for i := 0; i < 12; i++ { + rc.Append(ContextEvent{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "qq", + Input: "无关内容", + }) + } + + archived := rc.Prune("完全不相关的查询", 5, docStore) + if archived == 0 { + t.Fatal("应有事件被归档") + } + + // 关键断言:内容仍可读(引用被转走而非归零后被清) + got, err := ms.Get(d) + if err != nil { + t.Fatalf("归档后内容应仍可读: %v", err) + } + if string(got) != string(payload) { + t.Fatal("内容被改") + } + + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + if it.RefCount < 1 { + t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount) + } + // 原 context 引用应已注销 + if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 { + t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs)) + } + // 应挂到某个 document owner 上 + var docOwned bool + docs := docStore.RecentDocs(10) + for _, doc := range docs { + if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 { + docOwned = true + break + } + } + if !docOwned { + t.Fatal("引用应已挂到归档文档上") + } +} + +func TestPrune_NilMediaStoreStillArchives(t *testing.T) { + // 媒体存储未启用时归档链路必须照常工作 + dir := t.TempDir() + emb := memory.NewStaticEmbedder() + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatal(err) + } + rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) + // 刻意不 SetMediaStore + + for i := 0; i < 15; i++ { + rc.Append(ContextEvent{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "qq", + Input: "内容", + }) + } + if n := rc.Prune("查询", 5, docStore); n == 0 { + t.Fatal("无媒体存储时归档也应正常") + } +} + +func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { + // context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空, + // 不影响任何既有行为。 + dir := t.TempDir() + path := filepath.Join(dir, "context.json") + + // 写一份"存量格式"(无 id / media 字段) + legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]` + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + + emb := memory.NewStaticEmbedder() + rc := NewRelevanceContext(path, emb) + if rc.Len() != 1 { + t.Fatalf("应读回 1 条,实际 %d", rc.Len()) + } + + // 新写入带媒体的事件,再读回 + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + a := &Agent{mediaStore: ms, context: rc} + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"} + a.bindEventMedia(&evt, []string{d}) + rc.Append(evt) + rc.flush() + + rc2 := NewRelevanceContext(path, emb) + if rc2.Len() != 2 { + t.Fatalf("应有 2 条,实际 %d", rc2.Len()) + } +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 1528b4b..39f9333 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -344,6 +344,11 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } } if len(blocks) > 0 { + // 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。 + // 不存的后果是 ToolResultItem.Output 只剩那句 + // "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。 + a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...) + if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 { // 能直视:另起一条 user message 承载媒体,并补一句来源说明, // 否则模型会把它当成用户新发的图而不是工具拉回来的。 diff --git a/internal/config/registry.go b/internal/config/registry.go index 75c7c50..77f52df 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -544,6 +544,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db")) set("core.memory.text", filepath.Join(dataDir, "memory", "text")) set("core.memory.documents", filepath.Join(dataDir, "memory", "documents")) + set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media")) set("core.knowledge.path", filepath.Join(dataDir, "knowledge")) set("core.log.path", filepath.Join(dataDir, "log")) @@ -648,6 +649,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"}) reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"}) + reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"}) reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"}) diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index 2007b8f..181e219 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -32,8 +32,22 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。 +// +// 定义为常量而不是让调用方写字符串:owner_kind 进了主键, +// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错, +// DropOwner 也永远匹配不到)。 +const ( + // OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。 + OwnerContext = "context" + // OwnerDocument 是 L2 文档记忆(Doc.ID)。 + OwnerDocument = "document" + // OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。 + OwnerGraphSentence = "graph_sentence" +) + // Kind 是媒体大类。刻意只分三类而不细分具体格式: -// 记忆检索关心的是"这是张图还是段音频",具体编码交给 MIME 字段。 +// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。 type Kind string const (