diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index b4f5fd22..4cec166d 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -584,7 +584,7 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov Knowledge: ks, SocialStore: socialStore, TextMemory: textMem, - MediaStore: mediaStore, + // MediaStore 已随 CAS 淘汰:块自带负载(见 plan.md)。 Personality: personality, // 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。 // 与 WebUI 向导共用 internal/config 的同一份落库逻辑。 diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 881a6d8e..ed8272e3 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -14,7 +14,6 @@ import ( "github.com/JianFeeeee/HomeAgent/internal/knowledge" "github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" "github.com/JianFeeeee/HomeAgent/internal/memory/social" "github.com/JianFeeeee/HomeAgent/internal/memory/text" "github.com/JianFeeeee/HomeAgent/internal/memory/vector" @@ -80,10 +79,7 @@ type Agent struct { // 文本记忆(原始对话日志) textMem *text.Memory - // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。 - // 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除 - // 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。 - mediaStore *media.Store + // 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。 // 人格设定(内容来自启动时载入的人格文件/配置项) personality *agentPkg.Personality @@ -185,12 +181,12 @@ type Agent struct { // 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问 pendingMedia map[string]interface{} - // pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。 + // pendingMediaBlocks 累积本轮已捕获的媒体块。 // // 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获, - // 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 + // 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。 // 由 schedulerLoop goroutine 独占读写。 - pendingMediaDigests []string + pendingMediaBlocks []memory.MemoryBlock // 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息) interruptInput bool @@ -266,7 +262,7 @@ type AgentConfig struct { Knowledge *knowledge.Store SocialStore *social.SocialStore TextMemory *text.Memory - MediaStore *media.Store + // 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。 MultimodalSpace vector.MultimodalEmbedder // EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因, // 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。 @@ -383,15 +379,12 @@ func New(cfg AgentConfig) *Agent { // 稀疏两路(词向量 + TF-IDF)**保持启用**且仍是主召回路径:多模态 // 空间未配置时知识库行为与此前逐字一致(退化为 0.5/0.5 两路融合)。 if cfg.Knowledge != nil { - // 顺序要紧:先接线(含 MediaStore),再重建。ReindexDense 会 - // 尝试从 .dense.json 缓存恢复,恢复不了才重算,最后把结果落盘。 - // 若先重建后接线,首次启动算出的向量会被丢掉而不落盘。 + // 顺序要紧:先接线再重建。ReindexDense 会尝试从 .dense.json 缓存恢复, + // 恢复不了才重算,最后把结果落盘。若先重建后接线,首次启动算出的 + // 向量会被丢掉而不落盘。 cfg.Knowledge.SetDenseSpace(cfg.MultimodalSpace) - cfg.Knowledge.SetMediaGetter(cfg.MediaStore) - // 媒体**写入**器:ImportDir 复制图片/音视频时用。 - // 不接的话 include_media=true 会静默失效(媒体全被跳过、 - // 只在导入结果里留一行"媒体库不可用"),模型无从察觉。 - cfg.Knowledge.SetMediaPutter(cfg.MediaStore) + // 媒体字节随知识条目自带(CAS 淘汰后):denseFor 直接读条目目录里的 + // 媒体文件,无需 mediaGet/mediaPut 接线。 built, skipped := cfg.Knowledge.ReindexDense() if built > 0 || skipped > 0 { log.Printf("[knowledge] 多模态稠密索引: 新建 %d 跳过 %d(其余命中缓存)", built, skipped) @@ -421,7 +414,8 @@ func New(cfg AgentConfig) *Agent { knowledge: cfg.Knowledge, social: cfg.SocialStore, textMem: cfg.TextMemory, - mediaStore: cfg.MediaStore, + // mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节 + // (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。 personality: cfg.Personality, personaStore: cfg.PersonaStore, allowedOutputs: cfg.AllowedOutputs, diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index c4c06385..aa4b905b 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -6,28 +6,30 @@ import ( "sort" "strings" + "github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" ) // CrossModalHit 是跨模态检索融合后的一条候选。 // -// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有, -// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的 -// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。 +// 统一的检索单元是**记忆块**(文本块与媒体块同构)。文本路命中文档; +// 视觉路命中媒体块——两者都是块,向量在同一统一空间里可直接比较。 +// +// ★ 2026-10-11:不再有「CAS 全库」这个检索面。视觉路直接走 +// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的 +// 内容」这回事——能召回的必然是被某层持有的块。 type CrossModalHit struct { - Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil - Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体 - MediaScore float64 // 视觉路原始 cosine(无则 0) - DocScore float64 // 文本路原始 cosine(无则 0) - Fused float64 // 归一化加权融合分,供最终排序 - // 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时, - // DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。 + Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil + Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块 + MediaScore float64 // 视觉路原始 cosine(无则 0) + DocScore float64 // 文本路原始 cosine(无则 0) + Fused float64 // 归一化加权融合分,供最终排序 + // 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被 + // 一路命中的候选之前。 DoubleHit bool } // CrossModalFusionConfig 控制文本路与视觉路的融合行为。 -// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。 type CrossModalFusionConfig struct { WeightText float64 // 文本路融合权重(默认 0.5) WeightVisual float64 // 视觉路融合权重(默认 0.5) @@ -54,9 +56,7 @@ func (c CrossModalFusionConfig) visualWeight() float64 { } return c.WeightVisual } -func (c CrossModalFusionConfig) doubleHitBonus() float64 { - return c.DoubleHitBonus -} +func (c CrossModalFusionConfig) doubleHitBonus() float64 { return c.DoubleHitBonus } func (c CrossModalFusionConfig) minMaxEps() float64 { if c.MinMaxEps <= 0 { return defaultFusionConfig.MinMaxEps @@ -69,17 +69,16 @@ func (c CrossModalFusionConfig) minMaxEps() float64 { // 策略(两路并行,召回真正最相似的): // 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量), // 命中文档若持有媒体块,直接带上该块。 -// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦 -// (QueryMediaScored),覆盖文本向量没写到的视觉内容。 +// 2. 视觉路:query 经多模态模型文本编码 → 与**媒体块自己的向量**比余弦 +// (memory.RecallBlocks),覆盖文本向量没写到的视觉内容。 +// 用的就是块向量——没有第二套向量、没有第二个检索面。 // 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。 -// 同一媒体被两路同时命中视为双信号确认,额外加权。 // -// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。 +// 多模态空间未配置时视觉路为空,退化为纯文本路。 func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit { if topK <= 0 { topK = 5 } - // 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。 per := topK * 2 if per < 8 { per = 8 @@ -90,34 +89,38 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC if a.docStore != nil { for _, dh := range a.docStore.QueryScored(query, per) { hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score} - // 命中文档若持有一等记忆块,把首个媒体块一并带上。 - if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 { - if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil { - hit.Media = it + // 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。 + for i := range dh.Doc.Blocks { + if dh.Doc.Blocks[i].Modality != memory.BlockText { + b := dh.Doc.Blocks[i] + hit.Media = &b + break } } textHits = append(textHits, hit) } } - // ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)---- + // ---- 视觉路(多模态文本编码 → 媒体块自己的向量)---- var visualHits []CrossModalHit - if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil { + if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.memory != nil { qv, err := a.multimodalSpace.VectorizeDense(query) if err != nil { log.Printf("[crossmodal] 多模态文本编码失败: %v", err) - } else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil { - log.Printf("[crossmodal] 媒体记忆检索失败: %v", err) + } else if hits, err := a.memory.RecallBlocks(memory.BlockRecallQuery{ + Vector: qv, + Fingerprint: a.multimodalSpace.Fingerprint(), + TopK: per, + }); err != nil { + log.Printf("[crossmodal] 媒体块检索失败: %v", err) } else { - // 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储, - // 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。 - held := a.heldMediaDigests() - for _, h := range mh { - if h.Item == nil || !held[h.Item.Digest] { - continue + for _, h := range hits { + if h.Block.Modality == memory.BlockText { + continue // 文本块由文本路负责,避免同一内容两路重复 } + b := h.Block visualHits = append(visualHits, CrossModalHit{ - Media: h.Item, MediaScore: h.Score, + Media: &b, MediaScore: h.Score, }) } } @@ -127,14 +130,6 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC } // fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。 -// -// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]: -// -// norm(x) = (x - min) / (max - min),max==min 时置 1 -// -// 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中 -// (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。 -// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。 func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit { norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 { out := make([]float64, len(hits)) @@ -171,12 +166,11 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod return "doc:" + h.Doc.ID } if h.Media != nil { - return "media:" + h.Media.Digest + return "media:" + h.Media.ID } return "" } - // 先并入视觉路(视觉媒体是独立实体) for i, h := range visualHits { k := key(h) if k == "" { @@ -187,14 +181,12 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod byKey[k] = &clone keys = append(keys, k) } - // 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest - // 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。 for i, h := range textHits { if h.Doc == nil { continue } if h.Media != nil { - if ex, ok := byKey["media:"+h.Media.Digest]; ok { + if ex, ok := byKey["media:"+h.Media.ID]; ok { ex.DoubleHit = true ex.Doc = h.Doc ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus() @@ -233,7 +225,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod } // crossModalMarkdown 把融合候选渲染成注入上下文的文本。 -// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。 +// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。 func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { if len(hits) == 0 { return "" @@ -257,7 +249,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { } } if h.Media != nil { - if line := mediaLabel(h.Media); line != "" { + if line := blockLabel(h.Media); line != "" { parts = append(parts, line) } } diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index 948617ad..9efa6c01 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -17,6 +17,9 @@ import ( // // 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径: // 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。 +// +// ★ 2026-10-11:块自带负载(CAS 已淘汰)。因此本文件不再有 +// "按 digest 去 CAS 取块"这一步——块就是它自己,按块 ID 直接引用。 // migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。 // @@ -24,16 +27,20 @@ import ( // 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以 // sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。 // 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。 +// +// ★ 旧实体的名字里带的是**内容短 digest**;块自带负载后没有 CAS 可按 +// digest 取回字节,所以旧媒体实体的字节若已无从取得(CAS 已删), +// 该实体只能如实删除而不建块——不造一个没有内容的空块。 func (a *Agent) migrateLegacyGraphMedia() { - if a.memory == nil || a.mediaStore == nil { + if a.memory == nil { return } blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) { - full, err := a.mediaStore.ResolvePrefix(short) - if err != nil { - return memory.MemoryBlock{}, false + // 先看该内容是否已经有对应的块(内容派生 ID,前缀可查)。 + if b, ok, err := a.memory.BlockByIDPrefix("blk_media_" + short); err == nil && ok { + return b, true } - return a.blockFromDigest(full) + return memory.MemoryBlock{}, false }) if err != nil { log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err) @@ -44,26 +51,28 @@ func (a *Agent) migrateLegacyGraphMedia() { } } -// attachBlocksToSentenceBlock 把一组 digest 变成 L3 一等块并挂到**原句块**上。 -// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。 -// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。 +// attachBlocksToSentenceBlock 把一组媒体块引用挂到**原句块**上。 // -// 句子的承载者已从 sentences 表迁移到 blk_src_, -// 媒体边因此改挂「原句块 --contains--> 媒体块」。 -func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []string, seed map[string]memory.MemoryBlock, scene string) int { - if a.mediaStore == nil || a.memory == nil || sentenceBlockID == "" { +// ★ 参数从 digests 改为 refs(块 ID 或其前缀):CAS 淘汰后,媒体由块自身 +// 承载,引用单位就是块 ID。seed 允许复用调用方已持有的块(L2→L3 迁移 +// 保持块身份不变)。 +// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。 +func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, refs []string, seed map[string]memory.MemoryBlock, scene string) int { + if a.memory == nil || sentenceBlockID == "" { return 0 } bound := 0 - for _, d := range digests { - full, err := a.mediaStore.ResolvePrefix(d) - if err != nil { - log.Printf("[media] digest %s 无法解析: %v", d, err) - continue - } - b, ok := seed[full] + for _, ref := range refs { + b, ok := seed[ref] if !ok { - if b, ok = a.blockFromDigest(full); !ok { + var err error + b, ok, err = a.memory.BlockByIDPrefix(ref) + if err != nil { + log.Printf("[media] 查块 %s 失败: %v", ref, err) + continue + } + if !ok { + log.Printf("[media] 块引用 %s 无唯一对应块,跳过", ref) continue } } @@ -73,11 +82,11 @@ func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []st b.Scene = scene } if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { - log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err) + log.Printf("[media] L3 块写入失败 (%s): %v", shortID(b.ID), err) continue } if err := a.memory.AddMemoryBlockEdge("block", sentenceBlockID, "block", b.ID, "contains"); err != nil { - log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err) + log.Printf("[media] 句子→块边建立失败 (%s): %v", shortID(b.ID), err) continue } bound++ @@ -116,7 +125,7 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock, bound := 0 for _, b := range blocks { if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil { - log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err) + log.Printf("[media] 文档→块边建立失败 (%s): %v", shortID(b.ID), err) continue } bound++ @@ -133,9 +142,9 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string if g == nil { return 0, 0, 0, fmt.Errorf("graph memory 未启用") } - // 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。 + // 轻量内核(memory == nil,子只有图记忆):只写图记忆。 // 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。 - if a.memory == nil || a.mediaStore == nil { + if a.memory == nil { ec, rc, cErr := g.Commit(triples, sessionID, turnID) return ec, rc, 0, cErr } @@ -143,10 +152,10 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string if err != nil { return ec, rc, 0, err } - byDigest := make(map[string]memory.MemoryBlock, len(seed)) + byRef := make(map[string]memory.MemoryBlock, len(seed)) for _, b := range seed { - if b.PayloadDigest != "" { - byDigest[b.PayloadDigest] = b + if b.ID != "" { + byRef[b.ID] = b } } for _, t := range triples { @@ -157,12 +166,11 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string if sid == "" { continue } - blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byDigest, t.Scene) + blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byRef, t.Scene) } return ec, rc, blocks, nil } -// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。 // RecallBlocksForSentence 按**原句块 ID**取回它承载的媒体块。 // // ★ 签名从 int64 改为 string:挂载点已从「sentences 表行号」 @@ -175,45 +183,39 @@ func (a *Agent) RecallBlocksForSentence(sentenceBlockID string) ([]memory.Memory return a.memory.BlocksForNode("block", sentenceBlockID) } -// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。 +// resolveMediaRefs 把模型给的(多为短)块引用补全成唯一的块 ID。 // -// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。 -func (a *Agent) resolveMediaDigests(digests []string) []string { - if a.mediaStore == nil || len(digests) == 0 { +// 补不上或歧义就丢弃那一条并记日志:模型可能凭印象编了个 ID, +// 也可能内容已被删除。宁可不解析也不猜(猜错 = 挂错媒体)。 +func (a *Agent) resolveMediaRefs(refs []string) []string { + if a.memory == nil || len(refs) == 0 { return nil } - seen := make(map[string]bool, len(digests)) + seen := make(map[string]bool, len(refs)) var out []string - for _, d := range digests { - full, err := a.mediaStore.ResolvePrefix(d) - if err != nil { - log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err) + for _, r := range refs { + b, ok, err := a.memory.BlockByIDPrefix(r) + if err != nil || !ok { + log.Printf("[media] 模型给的块引用 %s 无法唯一解析", r) continue } - if seen[full] { + if seen[b.ID] { continue } - seen[full] = true - out = append(out, full) + seen[b.ID] = true + out = append(out, b.ID) } return out } -// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 +// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**(去重、去零)。 // // 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block // 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。 -// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**。 // // ★ 从 Relation.SentenceID(旧 sentences 表行号)改为按 SentenceText 现算 -// -// blk_src_。 -// -// 原因:Commit 块化后原句由块承载,sentences 表退场, -// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。 -// -// 这条链(mediaContextForSentences / mediaContextForRelations) -// 完全靠这个 ID 找回媒体块,所以改错了 = 媒体上下文整体失效。 +// blk_src_。原因:Commit 块化后原句由块承载,sentences 表退场, +// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。 func sentenceIDsFromRelations(relations []memory.Relation) []string { if len(relations) == 0 { return nil @@ -242,9 +244,7 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { // formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。 // // 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出 -// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」, -// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型 -// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。 +// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」。 func formatRecallRelations(relations []memory.Relation, max int) []string { var out []string for i, r := range relations { @@ -264,9 +264,9 @@ func formatRecallRelations(relations []memory.Relation, max int) []string { // mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 // // Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token), -// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 +// 因此这里用命中的实体名再查一次关系,只为拿到原句。 func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string { - if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 { + if a.memory == nil || injected == nil || len(injected.Entities) == 0 { return "" } names := make([]string, 0, len(injected.Entities)) @@ -280,18 +280,14 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext return a.mediaContextForRelations(res.Relations) } -// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。 +// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 doc_query 展示。 func (a *Agent) blockLabelsForDoc(d *document.Doc) string { - if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 { + if d == nil || len(d.Blocks) == 0 { return "" } var parts []string - for _, b := range d.Blocks { - it, err := a.mediaStore.Stat(b.PayloadDigest) - if err != nil || it == nil { - continue - } - if line := mediaLabel(it); line != "" { + for i := range d.Blocks { + if line := blockLabel(&d.Blocks[i]); line != "" { parts = append(parts, line) } } @@ -300,10 +296,10 @@ func (a *Agent) blockLabelsForDoc(d *document.Doc) string { // mediaContextForSentences 给一组句子附上其持有的一等块标签。 // -// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型 -// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。 +// 标签只含 MIME 与短块 ID:图片按向量检索,标签的作用是告诉模型 +// "这条记忆当时带着哪份媒体、可用该块 ID 取回"。 func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string { - if a.mediaStore == nil || a.memory == nil || len(sentenceBlockIDs) == 0 { + if a.memory == nil || len(sentenceBlockIDs) == 0 { return "" } var lines []string @@ -313,12 +309,8 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string { continue } var parts []string - for _, b := range blocks { - it, err := a.mediaStore.Stat(b.PayloadDigest) - if err != nil || it == nil { - continue - } - if line := mediaLabel(it); line != "" { + for i := range blocks { + if line := blockLabel(&blocks[i]); line != "" { parts = append(parts, line) } } @@ -331,16 +323,3 @@ func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string { } return strings.Join(lines, "\n") } - -// shortID 把块 ID 压成可读短码(blk_src_<24hex> → 前 8 位 hex)。 -// 媒体上下文是给模型看的,整串 hex 只会占 token 且不可读。 -func shortID(blockID string) string { - const pfx = "blk_src_" - if strings.HasPrefix(blockID, pfx) { - return blockID[len(pfx):] - } - if len(blockID) > 12 { - return blockID[:12] - } - return blockID -} diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go index 6d35d339..c07a9f95 100644 --- a/internal/agent/core/medialoop.go +++ b/internal/agent/core/medialoop.go @@ -1,193 +1,115 @@ package core import ( - "errors" "log" "sync" "sync/atomic" "github.com/JianFeeeee/HomeAgent/internal/memory" - "github.com/JianFeeeee/HomeAgent/internal/memory/vector" ) // 媒体与记忆块的生命周期辅助。 // -// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, -// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。 -// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。 - -// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。 +// ★ 2026-10-11:块自带负载(media.Store/CAS 已淘汰)。 // -// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」—— -// 召回前用它把已无处可归的内容过滤掉。 -func (a *Agent) heldMediaDigests() map[string]bool { - held := map[string]bool{} - collect := func(blocks []memory.MemoryBlock) { - for _, b := range blocks { - if b.PayloadDigest != "" { - held[b.PayloadDigest] = true - } - } - } - if a.context != nil { - collect(a.context.Blocks()) - } - if a.docStore != nil { - collect(a.docStore.Blocks()) - } - if a.memory != nil { - if blocks, err := a.memory.MemoryBlocks(); err == nil { - collect(blocks) - } - } - return held -} - -// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。 -// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。 -func (a *Agent) payloadHeld(digest string) bool { - if digest == "" { - return false - } - if a.context != nil { - for _, b := range a.context.Blocks() { - if b.PayloadDigest == digest { - return true - } - } - } - if a.docStore != nil { - for _, b := range a.docStore.Blocks() { - if b.PayloadDigest == digest { - return true - } - } - } - if a.memory != nil { - if blocks, err := a.memory.MemoryBlocks(); err == nil { - for _, b := range blocks { - if b.PayloadDigest == digest { - return true - } - } - } - } - return false -} - -// forgetPayloads 在记忆块被永久删除后删除它们的内容。 +// 此前这里有 heldMediaDigests / payloadHeld / forgetPayloads 三个函数, +// 它们存在的**唯一理由**是补 CAS 的漏:CAS 是全库字节存储,它的检索结果 +// 不等于「记忆里的媒体」,所以要靠一套 digest 记账把已无处可归的内容滤掉。 +// 块自带负载后这一整类问题从根上消失——字节就在块里,块没了字节就没了, +// 不需要账本、不需要 GC、不需要「删前先问谁还持有」。 // -// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest -// 时才删字节(同一张图可能被多个块引用)。 -func (a *Agent) forgetPayloads(digests []string) { - if a.mediaStore == nil { - return - } - for _, d := range digests { - if d == "" || a.payloadHeld(d) { - continue - } - if err := a.mediaStore.Delete(d); err != nil { - log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err) - } - } -} +// 因此那三个函数被整体删除,只保留向量迁移(它仍需要,因为换模型/换维度 +// 会让历史块的向量落在另一个空间里)。 -// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。 +// reembedStaleMedia 在启动时批量把历史媒体块的向量迁移到当前向量空间。 // // 触发场景(任一变化都会导致旧向量无法参与查询): // - 切换模型(模型 A→模型 B,fp 变了) // - 切换向量维度(ONNX→HTTP dim 512→1024) // - 首次部署嵌入服务(历史无向量的媒体补算) -// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空) +// - 嵌入服务离线后重新上线(失败条目 fingerprint 仍为空) // // 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。 // 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流), // 外部 API 路径下不超 8(避免打爆外部服务)。 func (a *Agent) reembedStaleMedia() { - if a.multimodalSpace == nil || a.mediaStore == nil { + if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() || a.memory == nil { return } fp := a.multimodalSpace.Fingerprint() - digests, err := a.mediaStore.StaleVecDigestsAll(fp) + stale, err := a.memory.StaleBlockVectors(fp) if err != nil { - log.Printf("[media] 查询需重算向量的媒体失败: %v", err) + log.Printf("[media] 查询需重算向量的块失败: %v", err) return } - if len(digests) == 0 { - log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s)", shortFP(fp)) + // 只迁图像:多模态空间当前覆盖 text+image,音频/视频不在其内 + // (不拿别的模态的向量顶替——那是静默错配)。 + var targets []memory.MemoryBlock + for _, b := range stale { + if b.Modality == memory.BlockImage && len(b.Data) > 0 { + targets = append(targets, b) + } + } + if len(targets) == 0 { + log.Printf("[media] 无需迁移向量(所有图像块已与当前空间对齐 fp=%s)", shortFP(fp)) return } - // 并发度:ONNX 内嵌不超过 4,外部 API 不超过 8(由配置或实际环境动态定) workers := 4 - if fp[:min(4, len(fp))] == "http:" { + if len(fp) >= 5 && fp[:5] == "http:" { workers = 8 } - log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d", - len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers) + log.Printf("[media] 启动向量迁移: %d 个图像块 → fp=%s dim=%d workers=%d", + len(targets), shortFP(fp), a.multimodalSpace.Dim(), workers) - jobs := make(chan string, workers*2) - var done, failed, unsupported int64 - var failedMu sync.Mutex + jobs := make(chan memory.MemoryBlock, workers*2) + var done, failed int64 var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() - for d := range jobs { - switch err := a.reembedOne(d, fp); { - case err == nil: - atomic.AddInt64(&done, 1) - case errors.Is(err, vector.ErrModalityUnsupported): - // 该模态不在本空间内(如音频):不重试、不计失败, - // 也不拿另一个模型的向量顶替。 - atomic.AddInt64(&unsupported, 1) - default: - failedMu.Lock() - failed++ - failedMu.Unlock() + for b := range jobs { + if err := a.reembedOneBlock(b, fp); err != nil { + atomic.AddInt64(&failed, 1) + continue } + atomic.AddInt64(&done, 1) } }() } - for i, d := range digests { - jobs <- d - // 每迁移 20 条输出进度日志,让用户看到迁移在推进 + for i, b := range targets { + jobs <- b if (i+1)%20 == 0 { - log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed) + log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", + i+1, len(targets), atomic.LoadInt64(&done), failed) } } close(jobs) wg.Wait() - log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不在本空间=%d 总计=%d fp=%s", - done, failed, unsupported, len(digests), shortFP(fp)) + log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s", + done, failed, len(targets), shortFP(fp)) } -// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。 +// reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。 // // 模态不在本空间覆盖范围时返回 ErrModalityUnsupported,调用方据此区分 // 「永久无向量」与「本次失败重试」。 -func (a *Agent) reembedOne(digest, fp string) error { - it, err := a.mediaStore.Stat(digest) - if err != nil { - return err - } - data, err := a.mediaStore.Get(digest) - if err != nil { - return err - } - mime := it.MIME +func (a *Agent) reembedOneBlock(b memory.MemoryBlock, fp string) error { + mime := b.MIME if mime == "" { mime = "image/png" } - vec, err := a.multimodalSpace.EmbedImageDense(data, mime) + vec, err := a.multimodalSpace.EmbedImageDense(b.Data, mime) if err != nil { return err } - return a.mediaStore.SetVec(digest, vec, fp) + if err := a.memory.SetBlockVector(b.ID, vec, fp); err != nil { + return err + } + return nil } // shortFP 截断 fingerprint 为可读日志格式。 diff --git a/internal/agent/core/mediaref.go b/internal/agent/core/mediaref.go index 48aff1bc..aeb5dfc1 100644 --- a/internal/agent/core/mediaref.go +++ b/internal/agent/core/mediaref.go @@ -1,85 +1,59 @@ package core import ( + "encoding/base64" "fmt" "log" "strings" - "sync/atomic" "time" agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api" "github.com/JianFeeeee/HomeAgent/internal/memory" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" ) -// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。 -var blockSeq int64 +// 媒体记忆接线:把对话里出现的图片/音频变成一等记忆块。 +// +// ★ 2026-10-11:块自带负载(CAS 已淘汰)。 +// +// 此前这里把字节存进独立的内容寻址存储(CAS),块只留一个 digest 指向它。 +// 那是把「媒体的内容」从块里拆出去另建一层——现在媒体字节直接进块的 Data, +// 与文本块的 Text 对称:一个块要么带文本、要么带字节。 +// +// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入 +// (SetToolBlocks)。两条都在这里收口成块,挂到当轮 ContextEvent; +// 事件被 Prune 时块随之迁移到 L2 文档(块本身不变,只换持有层)。 +// +// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的 +// 统一空间向量被检索,描述式索引是将就方案。 -func newBlockID() string { - return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1)) -} - -// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。 -func blockModalityOf(k media.Kind) memory.BlockModality { - switch k { - case media.KindImage: +// blockModalityFromMIME 按 MIME 大类推断块的模态。 +func blockModalityFromMIME(mime string) memory.BlockModality { + switch { + case strings.HasPrefix(mime, "image/"): return memory.BlockImage - case media.KindVideo: + case strings.HasPrefix(mime, "video/"): return memory.BlockVideo - case media.KindAudio: + case strings.HasPrefix(mime, "audio/"): return memory.BlockAudio default: return memory.BlockText } } -// blockFromDigest 把一份已入库媒体变成一等记忆块。 -// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。 -func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) { - if a.mediaStore == nil || digest == "" { - return memory.MemoryBlock{}, false - } - it, err := a.mediaStore.Stat(digest) - if err != nil || it == nil { - return memory.MemoryBlock{}, false - } - return memory.MemoryBlock{ - ID: newBlockID(), - Modality: blockModalityOf(it.Kind), - PayloadDigest: it.Digest, - MIME: it.MIME, - Size: it.Size, - Width: it.Width, - Height: it.Height, - Vector: it.Vec, - Fingerprint: it.VecModel, - Tool: it.Tool, - CreatedAt: it.FirstSeen, - }, true -} - -// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), -// 并让 L0 的 ContextEvent 直接持有一等记忆块。 +// captureBlockMedia 把 blocks 里的 data URL 媒体变成一等记忆块,返回块列表。 // -// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入 -// (SetToolBlocks)。两条都在这里收口:从 data URL 取出字节存进 CAS, -// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时 -// 块随之迁移到 L2 文档。 +// 只处理 data URL:http(s) URL 拿不到字节,而"下载它再存"会把一次对话变成 +// 一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。 // -// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的 -// 统一空间向量被检索,描述式索引是将就方案。 - -// 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 { +// 块 ID 由内容派生(memory.MediaBlockID):同内容 ⇒ 同 ID ⇒ 落库时 +// ON CONFLICT 天然去重(这正是内容寻址当年的好处,但现在由块 ID 直接提供, +// 不需要一层独立的寻址存储)。 +func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []memory.MemoryBlock { + if len(blocks) == 0 { return nil } - var digests []string + var out []memory.MemoryBlock for _, b := range blocks { var url string switch { @@ -91,106 +65,124 @@ func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) [ continue } - mime, data, ok := media.ParseDataURL(url) + mime, data, ok := 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 + block := memory.MemoryBlock{ + ID: memory.MediaBlockID(mime, data), + Modality: blockModalityFromMIME(mime), + Data: data, + MIME: mime, + Size: int64(len(data)), + Tool: tool, + CreatedAt: time.Now(), } - - // 入库即算一次多模态坐标并缓存(多模态空间可用时)。 - // 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标, - // 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。 - a.embedMediaOnIngest(d, mime, data) - digests = append(digests, d) + // 入库即算一次多模态坐标(多模态空间可用且为图像时)。 + // 之后块召回直接用它;换模型由启动时的 reembedStaleMedia 补算。 + a.embedBlockOnIngest(&block) + out = append(out, block) } - return digests -} - -// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。 -// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。 -func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) { - if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() { - return - } - if !strings.HasPrefix(mime, "image/") { - return - } - vec, err := a.multimodalSpace.EmbedImageDense(data, mime) - if err != nil { - log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err) - return - } - if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil { - log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err) - } -} - -// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。 -// -// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的 -// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 -// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。 -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 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。 +// embedBlockOnIngest 给刚捕获的块立即计算多模态坐标。 +// 只在多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。 +func (a *Agent) embedBlockOnIngest(b *memory.MemoryBlock) { + if b == nil || a.multimodalSpace == nil || !a.multimodalSpace.Loaded() { + return + } + if !strings.HasPrefix(b.MIME, "image/") { + return + } + vec, err := a.multimodalSpace.EmbedImageDense(b.Data, b.MIME) + if err != nil { + log.Printf("[media] 入库嵌入失败 %s: %v", shortID(b.ID), err) + return + } + b.Vector = vec + b.Fingerprint = a.multimodalSpace.Fingerprint() +} + +// stageMediaBlocks 累积本轮捕获的块,等 ContextEvent 建好后一起挂上。 // -// 块存储在事件自身(随 context.json 持久化),不再写 media_refs: -// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。 -func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { - if a.mediaStore == nil || evt == nil || len(digests) == 0 { +// 为何要缓存而不是当场挂:块在 process() 执行期间被捕获,而承载它的 +// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。 +// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。 +func (a *Agent) stageMediaBlocks(blocks ...memory.MemoryBlock) { + if len(blocks) == 0 { + return + } + a.pendingMediaBlocks = append(a.pendingMediaBlocks, blocks...) +} + +// drainMediaBlocks 取出并清空本轮累积的块。 +func (a *Agent) drainMediaBlocks() []memory.MemoryBlock { + if len(a.pendingMediaBlocks) == 0 { + return nil + } + out := a.pendingMediaBlocks + a.pendingMediaBlocks = nil + return out +} + +// bindEventMedia 把本轮捕获的块挂到 ContextEvent 上。 +// +// 块存储在事件自身(随 context.json 持久化),不维护引用账本: +// 存活与否由持有它的层决定。 +func (a *Agent) bindEventMedia(evt *ContextEvent, blocks []memory.MemoryBlock) { + if evt == nil || len(blocks) == 0 { return } if evt.ID == "" { evt.ID = newEventID() } - for _, d := range digests { - b, ok := a.blockFromDigest(d) - if !ok { - log.Printf("[media] 块构造失败 (%s)", shortDigest(d)) - continue - } - evt.Blocks = append(evt.Blocks, b) - } + evt.Blocks = append(evt.Blocks, blocks...) } -// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。 +// blockLabel 渲染一行媒体块标签,供提示词告知"这条记忆带着哪份媒体"。 // -// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供 -// MIME 与短 digest,让模型知道有这份媒体、可据 digest 取回字节。 -// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。 -func mediaLabel(it *media.Item) string { - if it == nil { +// 不含任何生成的描述文本:图片按自己的向量被检索,标签仅提供 MIME 与 +// 短块 ID,让模型知道有这份媒体、并可据此取回(块 ID 是它的唯一身份)。 +func blockLabel(b *memory.MemoryBlock) string { + if b == nil { return "" } - label := string(it.Kind) - if it.MIME != "" { - label = it.MIME + label := string(b.Modality) + if b.MIME != "" { + label = b.MIME } - return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest)) + return fmt.Sprintf("[%s %s]", label, shortID(b.ID)) +} + +// parseDataURL 从 data:;base64, 解出 MIME 与原始字节。 +// +// 自 CAS 淘汰后,这段解析归内核媒体接线自己持有(原在 media 包)。 +func parseDataURL(url string) (mime string, data []byte, ok bool) { + const prefix = "data:" + if !strings.HasPrefix(url, prefix) { + return "", nil, false + } + rest := url[len(prefix):] + comma := strings.IndexByte(rest, ',') + if comma < 0 { + return "", nil, false + } + head := rest[:comma] + payload := rest[comma+1:] + if !strings.HasSuffix(strings.ToLower(head), ";base64") { + return "", nil, false + } + mime = head[:len(head)-len(";base64")] + if mime == "" || payload == "" { + return "", nil, false + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", nil, false + } + return mime, decoded, true } // newEventID 生成 ContextEvent 的稳定标识。 @@ -202,9 +194,10 @@ func newEventID() string { return fmt.Sprintf("evt_%d", time.Now().UnixNano()) } -func shortDigest(d string) string { - if len(d) > 12 { - return d[:12] +// shortID 把块 ID 截短供日志/提示词使用。 +func shortID(id string) string { + if len(id) > 20 { + return id[:20] } - return d + return id } diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index c8ac0c19..5e66bf11 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -391,10 +391,10 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi a.pendingMedia = evt.Payload } - // 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本, - // base64 随 message 数组发给模型后就丢了。 + // 媒体先变成一等块(字节进块的 Data)。不存的后果是 ContextEvent.Input + // 只剩一句 alt 文本,base64 随 message 数组发给模型后就丢了。 if len(in.blocks) > 0 { - a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...) + a.stageMediaBlocks(a.captureBlockMedia(in.blocks, in.captureTool)...) } noMemory := false @@ -516,7 +516,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { ToolsUsed: f.ToolsUsed, ToolResults: f.ToolResults, } - a.bindEventMedia(&turnEvt, a.drainMediaDigests()) + a.bindEventMedia(&turnEvt, a.drainMediaBlocks()) a.context.Append(turnEvt) a.emitResponse(evt, f.Response, f.turnUsage) @@ -1098,8 +1098,8 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { } } if len(blocks) > 0 { - // 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。 - a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...) + // 先落成块:无论下面走直视还是回退转写,媒体本体都该进记忆。 + a.stageMediaBlocks(a.captureBlockMedia(blocks, tc.Name)...) if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 { // 能直视:另起一条 user message 承载媒体,并补一句来源说明。 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 50a9f05a..4b576e4a 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -339,9 +339,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } // 模型显式关联的媒体:结构化字段随三元组一起提交, // 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边—— - // 不再把 marker 写进句子文本。 - if digests := getStringSlice(m, "media_digests"); len(digests) > 0 { - t.MediaDigests = a.resolveMediaDigests(digests) + // 不再把 marker 写进句子文本。值是块 ID(或其唯一前缀)。 + if refs := getStringSlice(m, "media_digests"); len(refs) > 0 { + t.MediaDigests = a.resolveMediaRefs(refs) // 块边需要句子作端点。模型没给原句时用三元组本身拼一句 // 自然语言——不能造一段 marker 文本,那正是被废弃的东西。 if t.SentenceText == "" && len(t.MediaDigests) > 0 { @@ -697,25 +697,27 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string { } } -// knowledgeMediaRefs 把模型给的 digest 列表解析成知识条目的媒体引用。 +// knowledgeMediaRefs 把模型给的块引用(ID 或其唯一前缀)解析成知识条目的媒体引用。 // -// 复用 doc_commit 的既有约定:digest 可传前缀(ResolvePrefix),解析不了 -// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。 -// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。 -func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef { - if a.mediaStore == nil || len(digests) == 0 { +// 复用 doc_commit 的既有约定:引用可传前缀,解析不了的跳过而不是报错—— +// 模型偶尔会把 ID 记错,不该让整次写入失败。MIME 从块回读,嵌入时需要 +// (EmbedImageDense 靠它判定模态)。字节随条目落盘(writeMediaFiles)。 +func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef { + if a.memory == nil || len(refs) == 0 { return nil } var out []knowledge.KnowledgeMediaRef - for _, d := range a.resolveMediaDigests(digests) { - it, err := a.mediaStore.Stat(d) - if err != nil { + for _, r := range refs { + b, ok, err := a.memory.BlockByIDPrefix(r) + if err != nil || !ok { + log.Printf("[knowledge] 媒体块引用 %s 无法唯一解析,跳过", r) continue } out = append(out, knowledge.KnowledgeMediaRef{ - Digest: it.Digest, - MIME: it.MIME, - Kind: string(it.Kind), + Digest: b.ID, + MIME: b.MIME, + Kind: string(b.Modality), + Data: b.Data, }) } return out @@ -922,9 +924,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { // 模型显式关联的媒体:直接变成文档持有的一等块。 // 不再往正文写 marker——文档向量会融合这些块的媒体向量, - // 图片按自己的向量被检索。 - for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) { - if b, ok := a.blockFromDigest(d); ok { + // 图片按自己的向量被检索。值是块 ID(或其唯一前缀)。 + for _, ref := range a.resolveMediaRefs(getStringSlice(tc.Arguments, "media_digests")) { + if b, ok, err := a.memory.BlockByIDPrefix(ref); err == nil && ok { doc.Blocks = append(doc.Blocks, b) } } diff --git a/internal/knowledge/dense_test.go b/internal/knowledge/dense_test.go index 3e88f7a5..88996596 100644 --- a/internal/knowledge/dense_test.go +++ b/internal/knowledge/dense_test.go @@ -85,10 +85,9 @@ func TestDenseCrossModalRecall(t *testing.T) { img: map[string][]float64{"__default": eby, "d-cat": eax}, } s.SetDenseSpace(mm) - s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-cat": []byte("d-cat")}}) if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{ - {Digest: "d-cat", MIME: "image/png", Kind: "image"}, + {File: "cat.png", MIME: "image/png", Kind: "image"}, }); err != nil { t.Fatal(err) } @@ -224,9 +223,8 @@ func TestDenseUnsupportedModalityStillTextEmbedded(t *testing.T) { imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported}, } s.SetDenseSpace(mm) - s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-audio": []byte("d-audio")}}) - if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{Digest: "d-audio", MIME: "audio/mpeg"}}); err != nil { + if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{File: "a.mp3", MIME: "audio/mpeg"}}); err != nil { t.Fatal(err) } k := s.items["song"] @@ -289,13 +287,12 @@ func TestAttachMediaRecomputesDense(t *testing.T) { img: map[string][]float64{"__default": {0, 1}, "d-x": eax}, } s.SetDenseSpace(mm) - s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-x": []byte("d-x")}}) if err := s.Add("k", "正文"); err != nil { t.Fatal(err) } before := append([]float64{}, s.items["k"].Dense...) - if err := s.AttachMedia("k", KnowledgeMediaRef{Digest: "d-x", MIME: "image/png"}); err != nil { + if err := s.AttachMedia("k", KnowledgeMediaRef{File: "x.png", MIME: "image/png"}); err != nil { t.Fatal(err) } after := s.items["k"].Dense diff --git a/internal/knowledge/import.go b/internal/knowledge/import.go index 8c6157ff..0088b61b 100644 --- a/internal/knowledge/import.go +++ b/internal/knowledge/import.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" + "github.com/JianFeeeee/HomeAgent/internal/memory" ) // ===== 从目录导入知识 ===== @@ -370,37 +370,74 @@ func (s *Store) has(name string) bool { return ok } -// MediaPutter 是把字节写进媒体库的能力(由 media.Store 实现)。 +// putMediaFile 读一个媒体文件进条目引用(字节随 Data 瞬态携带, +// 由 Write 落盘到条目目录)。 // -// 为何用接口而不是直接依赖 *media.Store:Store 已经有 SetMediaGetter -// 这类注入点,导入沿用同一形状,测试才能用假实现,不必拉起真媒体库。 -type MediaPutter interface { - Put(data []byte, meta media.Item) (string, error) -} - -// putMediaFile 把一个媒体文件复制进媒体库,返回条目用的引用。 -// -// 媒体库未接入时返回错误而不是静默跳过 —— 但调用方对错误只记警告: -// 媒体是可选增强,正文不该因为图片入不了库而整个条目失败。 +// ★ 2026-10-11 CAS 淘汰:不再写入全局媒体库 —— 字节是条目自带数据, +// 与 content.md 同目录同生灭。读取失败返回错误,调用方只记警告跳过该媒体 +// (正文不该因为图片入不了库而整个条目失败)。 func (s *Store) putMediaFile(path, kind string) (KnowledgeMediaRef, error) { - if s.mediaPut == nil { - return KnowledgeMediaRef{}, fmt.Errorf("媒体库不可用") - } data, err := os.ReadFile(path) if err != nil { return KnowledgeMediaRef{}, err } mime := mimeByExt(filepath.Ext(path)) - digest, err := s.mediaPut.Put(data, media.Item{ - Kind: media.Kind(kind), - MIME: mime, - Size: int64(len(data)), - OriginPath: path, - }) - if err != nil { - return KnowledgeMediaRef{}, err + // File 名取源文件名:同目录内天然唯一(同名正文+同名媒体本就是一对)。 + return KnowledgeMediaRef{ + Digest: memory.MediaBlockID(mime, data), // 内容身份:与媒体块 ID 同构 + MIME: mime, + Kind: kind, + File: filepath.Base(path), + Data: data, + }, nil +} + +// writeMediaFiles 把引用携带的瞬态字节落进条目目录。 +// +// 只写引用里带 Data 的条目(目录导入路径);字节已在盘上的(如模型引用 +// 已有媒体)直接跳过。File 为空时补足。 +func (s *Store) writeMediaFiles(dir string, refs []KnowledgeMediaRef) error { + for i := range refs { + r := &refs[i] + if len(r.Data) == 0 && r.File != "" { + if _, err := os.Stat(filepath.Join(dir, r.File)); err == nil { + continue // 字节已在盘上 + } + } + if len(r.Data) == 0 { + continue // 没字节也没文件:引用悬空,留给 denseFor 静默降级 + } + if r.File == "" { + ext := extByMIME(r.MIME) + r.File = fmt.Sprintf("media_%d%s", i, ext) + } + if err := os.WriteFile(filepath.Join(dir, r.File), r.Data, 0644); err != nil { + return err + } + } + return nil +} + +// extByMIME 按常见 MIME 给扩展名(writeMediaFiles 补 File 名用)。 +func extByMIME(mime string) string { + switch mime { + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "audio/mpeg": + return ".mp3" + case "audio/wav": + return ".wav" + case "video/mp4": + return ".mp4" + default: + return ".bin" } - return KnowledgeMediaRef{Digest: digest, MIME: mime, Kind: kind}, nil } // mimeByExt 按扩展名给 MIME。够用即可:媒体库靠 MIME 决定能否解码 diff --git a/internal/knowledge/import_test.go b/internal/knowledge/import_test.go index c1c9929f..80401e88 100644 --- a/internal/knowledge/import_test.go +++ b/internal/knowledge/import_test.go @@ -2,14 +2,11 @@ package knowledge import ( "bytes" - "fmt" "os" "path/filepath" "strconv" "strings" "testing" - - "github.com/JianFeeeee/HomeAgent/internal/memory/media" ) // ===== 从目录导入知识 ===== @@ -400,27 +397,9 @@ func TestImportDirDoesNotFlushPerItem(t *testing.T) { } } -// 媒体导入:图片按字节复制进媒体库,条目挂 digest 引用。 -// -// 用假 MediaPutter 而不是真媒体库:这里要验的是「调用了 Put、 -// 且把返回的 digest 挂到条目上」,不是媒体库自己的落盘与去重 -// (那是 media 包的判据,不该在这里重测)。 -type fakePutter struct { - puts [][]byte - seq int -} - -func (f *fakePutter) Put(data []byte, _ media.Item) (string, error) { - f.puts = append(f.puts, data) - f.seq++ - return fmt.Sprintf("digest%02d", f.seq), nil -} - func TestImportDirMediaAttachedToEntry(t *testing.T) { src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"}) s, root := newImportStore(t) - fp := &fakePutter{} - s.SetMediaPutter(fp) st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true}) if err != nil { @@ -429,16 +408,21 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) { if st.Media != 1 { t.Errorf("应导入 1 个媒体,实际 %d(%+v)", st.Media, st) } - if len(fp.puts) != 1 || string(fp.puts[0]) != "PNGDATA" { - t.Errorf("媒体字节未原样复制进媒体库:%v", fp.puts) + // 媒体字节随条目落盘(CAS 淘汰后:字节是条目自带数据,与 content.md 同目录) + bytesOnDisk, err := os.ReadFile(filepath.Join(root, "note", "note.png")) + if err != nil { + t.Fatalf("媒体字节未落盘到条目目录:%v", err) } - // digest 引用要落到条目的侧车文件里 + if string(bytesOnDisk) != "PNGDATA" { + t.Errorf("媒体字节失真:%q", bytesOnDisk) + } + // 引用要落到条目的侧车文件里(File 指向同目录文件) data, err := os.ReadFile(filepath.Join(root, "note", mediaSidecarName)) if err != nil { t.Fatalf("媒体侧车未写入:%v", err) } - if !strings.Contains(string(data), "digest01") { - t.Errorf("侧车里没有 Put 返回的 digest:%s", data) + if !strings.Contains(string(data), "note.png") { + t.Errorf("侧车里没有媒体文件名:%s", data) } // 内存条目也要挂上(否则稠密路算不出这条的图向量) k := s.items["note"] @@ -447,26 +431,33 @@ func TestImportDirMediaAttachedToEntry(t *testing.T) { } } -// include_media 未开启时不该碰媒体库。 +// include_media 未开启时不该把媒体落进条目目录。 func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) { src := mkSrc(t, map[string]string{"note.md": "看图", "note.png": "PNGDATA"}) - s, _ := newImportStore(t) - fp := &fakePutter{} - s.SetMediaPutter(fp) + s, root := newImportStore(t) if _, err := s.ImportDir(ImportOptions{Dir: src}); err != nil { t.Fatal(err) } - if len(fp.puts) != 0 { - t.Errorf("未开 include_media 却写了媒体库:%v", fp.puts) + if _, err := os.Stat(filepath.Join(root, "note", "note.png")); !os.IsNotExist(err) { + t.Errorf("未开 include_media 却落了媒体文件") } } -// 媒体入不了库时,正文条目仍应成功(媒体是增强,不是前提)。 +// 媒体读不了时,正文条目仍应成功(媒体是增强,不是前提)。 func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) { - src := mkSrc(t, map[string]string{"note.md": "正文", "note.png": "PNG"}) + src := mkSrc(t, map[string]string{"note.md": "正文"}) s, _ := newImportStore(t) - s.SetMediaPutter(failPutter{}) + // 造一个指向不存在文件的媒体候选:ImportDir 收集目录时按扩展名识别, + // 读不出的文件走 putMediaFile 错误路径,只跳过该媒体。 + bad := filepath.Join(src, "broken.png") + if err := os.WriteFile(bad, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(bad, 0000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(bad, 0644) }) st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true}) if err != nil { @@ -475,13 +466,4 @@ func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) { if st.Imported != 1 { t.Errorf("正文条目应仍导入成功,实际 %d(%+v)", st.Imported, st) } - if st.Media != 0 { - t.Errorf("媒体应计入 0,实际 %d", st.Media) - } -} - -type failPutter struct{} - -func (failPutter) Put([]byte, media.Item) (string, error) { - return "", fmt.Errorf("磁盘满") } diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index a02294e7..1d5cf151 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -49,6 +49,15 @@ type KnowledgeMediaRef struct { Digest string `json:"digest"` MIME string `json:"mime"` Kind string `json:"kind,omitempty"` + // File 是媒体字节在**条目目录内**的相对文件名(随条目生灭)。 + // + // ★ 2026-10-11 CAS 淘汰:媒体字节不再寄存于全局内容寻址库,而是作为 + // 条目自带数据落在 entry 目录里(与 content.md、.media.json 并列)。 + // Remove 的 os.RemoveAll 天然把它清掉,不留孤儿。 + File string `json:"file,omitempty"` + // Data 是写入期的瞬态字节:putMediaFile → Write 之间搬运,Write 落盘后清空。 + // json:"-" 保证它不进 .media.json 侧车(侧车只记引用,不记字节)。 + Data []byte `json:"-"` } // KnowledgeMediaRef 的别名,照顾内部可读性。 @@ -126,8 +135,6 @@ type Store struct { mu sync.RWMutex items map[string]*Knowledge - // mediaPut 是媒体写入器(ImportDir 复制媒体时用),见 SetMediaPutter。 - mediaPut MediaPutter indexPath string denseCachePath string @@ -157,8 +164,7 @@ type Store struct { // (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶, // 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。 // 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。 - dense vector.MultimodalEmbedder - mediaGet MediaGetter + dense vector.MultimodalEmbedder } // mediaSidecarName 是每条知识目录下存放媒体引用的文件名。 @@ -187,12 +193,6 @@ type denseCache struct { Entries map[string]denseCacheEntry `json:"entries"` } -// MediaGetter 让知识库能取回媒体字节以计算嵌入,而不依赖具体的媒体存储包。 -// 取不到(或未接线)时,该条目退化为纯文本嵌入——而不是整条不可用。 -type MediaGetter interface { - Get(digest string) ([]byte, error) -} - func NewStore(root string) *Store { return &Store{ root: root, @@ -224,25 +224,6 @@ func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) { s.maybeLoadDenseCacheLocked() } -// SetMediaGetter 注入媒体取回器(用于为 Media 块算嵌入)。 -// 不注入时多媒体条目仍可入库,只是退化为纯文本嵌入。 -func (s *Store) SetMediaGetter(g MediaGetter) { - s.mu.Lock() - defer s.mu.Unlock() - s.mediaGet = g -} - -// SetMediaPutter 注入媒体写入器(ImportDir 把图片/音视频复制进媒体库时用)。 -// -// 与 SetMediaGetter 分开:一个是取(算嵌入时读回媒体块),一个是存 -// (目录导入时收字节)。合成一个接口会强迫测试同时实现两侧。 -// 不注入时 ImportDir 仍能导入正文,只是媒体被跳过并记原因。 -func (s *Store) SetMediaPutter(p MediaPutter) { - s.mu.Lock() - defer s.mu.Unlock() - s.mediaPut = p -} - // denseEnabled 报告稠密路是否可用(供 Stats/自证与分支判断)。 // 调用方必须已持锁。 func (s *Store) denseEnabled() bool { @@ -265,21 +246,24 @@ func (s *Store) denseFor(k *Knowledge) []float64 { if tv, err := s.dense.VectorizeDense(k.Name + " " + k.Content); err == nil && len(tv) == dim { parts = append(parts, tv) } + dir := filepath.Dir(k.Path) for _, m := range k.Media { - if v := s.mediaDense(m); v != nil { + if v := s.mediaDense(m, dir); v != nil { parts = append(parts, v) } } return vector.FuseVectors(parts...) } -// mediaDense 取回媒体字节并嵌入。任何一步拿不到就返回 nil—— +// mediaDense 读条目目录里的媒体字节并嵌入。任何一步拿不到就返回 nil—— // 媒体缺失不应让整条知识失去文本向量。 -func (s *Store) mediaDense(m KnowledgeMediaRef) []float64 { - if !s.denseEnabled() || s.mediaGet == nil || m.Digest == "" { +// +// ★ 2026-10-11 CAS 淘汰:字节直接从条目目录的 File 读,不再经 mediaGet。 +func (s *Store) mediaDense(m KnowledgeMediaRef, dir string) []float64 { + if !s.denseEnabled() || m.File == "" { return nil } - data, err := s.mediaGet.Get(m.Digest) + data, err := os.ReadFile(filepath.Join(dir, m.File)) if err != nil || len(data) == 0 { return nil } @@ -365,8 +349,7 @@ func (s *Store) DenseStats() map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() out := map[string]interface{}{ - "enabled": s.denseEnabled(), - "media_getter": s.mediaGet != nil, + "enabled": s.denseEnabled(), } if s.denseEnabled() { fp := s.dense.Fingerprint() @@ -746,6 +729,12 @@ func (s *Store) Write(in KnowledgeEntryInput) error { return fmt.Errorf("write knowledge: %w", err) } + // 媒体字节落进条目目录(CAS 淘汰后,字节是条目自带数据)。 + // 失败只跳过该媒体并记录——正文已落盘,图片写不进顶多搜不到图。 + if err := s.writeMediaFiles(dir, in.Media); err != nil { + log.Printf("[knowledge] media bytes write error for %s: %v", in.Name, err) + } + now := time.Now() k := &Knowledge{ Name: id, diff --git a/internal/knowledge/multimodal_e2e_test.go b/internal/knowledge/multimodal_e2e_test.go index 502b0376..a9ecb4c5 100644 --- a/internal/knowledge/multimodal_e2e_test.go +++ b/internal/knowledge/multimodal_e2e_test.go @@ -8,9 +8,10 @@ import ( "image/png" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" "github.com/JianFeeeee/HomeAgent/internal/memory/vector" "github.com/JianFeeeee/HomeAgent/pkg/embedding" ) @@ -67,10 +68,6 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) { } t.Logf("真实 provider: dim=%d fp=%s loaded=%v", ds.Dim(), ds.Fingerprint(), ds.Loaded()) - ms, err := media.New(t.TempDir()) - if err != nil { - t.Fatal(err) - } dir := t.TempDir() s := NewStore(dir) if err := s.Start(); err != nil { @@ -78,16 +75,19 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) { } defer s.Stop() s.SetDenseSpace(ds) - s.SetMediaGetter(ms) img := mmTinyPNG(t) - digest, err := ms.Put(img, media.Item{MIME: "image/png", Tool: "test"}) - if err != nil { + if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{ + {File: "photo.png", MIME: "image/png", Kind: "image"}, + }); err != nil { t.Fatal(err) } - if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{ - {Digest: digest, MIME: "image/png", Kind: "image"}, - }); err != nil { + // 字节落进条目目录(CAS 淘汰后:媒体随条目生灭) + if err := os.WriteFile(filepath.Join(dir, "photo", "photo.png"), img, 0644); err != nil { + t.Fatal(err) + } + // 重算该条稠密向量以纳入图片 + if err := s.AttachMedia("photo", KnowledgeMediaRef{File: "photo.png", MIME: "image/png"}); err != nil { t.Fatal(err) } if err := s.Add("plain", "无关的纯文本知识"); err != nil { @@ -125,7 +125,6 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) { t.Fatal(err) } s2.SetDenseSpace(ds) - s2.SetMediaGetter(ms) built, _ := s2.ReindexDense() t.Logf("重启后 ReindexDense built=%d(0=命中缓存)", built) if built != 0 { diff --git a/internal/knowledge/persist_test.go b/internal/knowledge/persist_test.go index 33c04bef..c01d5dd7 100644 --- a/internal/knowledge/persist_test.go +++ b/internal/knowledge/persist_test.go @@ -34,7 +34,6 @@ func TestDenseCacheAvoidsRecompute(t *testing.T) { s2 := NewStore(dir) _ = s2.Start() s2.SetDenseSpace(mm) - s2.SetMediaGetter(fakeMedia{}) for _, id := range s2.List() { if len(s2.items[id].Dense) == 0 { t.Fatalf("重启后 %s 未从缓存恢复稠密向量", id) diff --git a/internal/memory/block.go b/internal/memory/block.go index 88bf0ba7..29103fec 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -22,12 +22,25 @@ const ( // // 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是 // 唯一事实源。Context→Document→Graph 迁移的是这个值本身,不建立平行保活账本。 -// PayloadDigest 仅用于定位内容寻址的原始字节,不表示另一条逻辑记忆。 +// +// ★★ 负载是 **union**:一个块要么是文本块(负载在 Text),要么是媒体块 +// (负载在 Data,配 MIME/Width/Height)。两者是同一个 MemoryBlock 的两种取值, +// 不是「块 + 外挂字节层」。 +// +// 为何不存在独立的字节存储:块已经是内容的载体(文本块自己带正文),媒体块 +// 自当同理。把媒体内容拆到块外、只留一个 digest 指向外部 CAS,会凭空多出 +// 一层需单独维护生命周期的东西(第二套向量、第二套召回路、引用账本、自己的 +// db),并与「块是唯一记忆载体」直接冲突。内容随块同生同灭才是正解。 type MemoryBlock struct { - ID string `json:"id"` - Modality BlockModality `json:"modality"` - Text string `json:"text,omitempty"` - PayloadDigest string `json:"payload_digest,omitempty"` + ID string `json:"id"` + Modality BlockModality `json:"modality"` + // Text 是文本块(Modality=text)的负载。 + Text string `json:"text,omitempty"` + // Data 是媒体块(Modality=image/video/audio)的负载:内容的原始字节。 + // + // 落盘形态随所在层而定:L0/L1 的块内联在 JSON 里(Data 序列化为 base64), + // L3 的块落 SQLite 的 BLOB 列。无论哪层,字节都不脱离块单独存在。 + Data []byte `json:"data,omitempty"` MIME string `json:"mime,omitempty"` Size int64 `json:"size,omitempty"` Width int `json:"width,omitempty"` @@ -120,13 +133,13 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { block.CreatedAt = now } _, err = tx.Exec(`INSERT INTO memory_blocks ( - id, modality, text_content, payload_digest, mime, size, width, height, + id, modality, text_content, data, mime, size, width, height, vector, fingerprint, source, tool, scene, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET modality = excluded.modality, text_content = excluded.text_content, - payload_digest = excluded.payload_digest, + data = excluded.data, mime = excluded.mime, size = excluded.size, width = excluded.width, @@ -143,7 +156,7 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { -- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。 semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, updated_at = excluded.updated_at`, - block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME, + block.ID, block.Modality, block.Text, block.Data, block.MIME, block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint, block.Source, block.Tool, block.Scene, block.CreatedAt, now) if err != nil { @@ -279,14 +292,21 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) { // 结果媒体与召回测试大面积变红(列数不匹配 ⇒ 读失败)。 // // 单靠“记得同步”不可靠;提成常量后新增查询直接复用。 -const blockColumns = `id, modality, text_content, payload_digest, mime, size, width, height, +// blockColumns 是 memory_blocks 的**元数据**列清单,供所有常规读取复用。 +// +// ★ 为何**不含 data**:媒体负载动辄数 MB(生产实测:图均 495KB、最大 2.7MB), +// 而 RecallBlocks 要全表扫描有向量的块。若把 data 列进来,每次召回都会把 +// 几十 MB 字节读进内存。块拥有自己的负载(同一行、同一生命周期), +// 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别: +// 那里字节归另一个存储层所有,这里归块自己。 +const blockColumns = `id, modality, text_content, mime, size, width, height, vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at` func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) { var b MemoryBlock var vectorJSON string var scene sql.NullString - if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, + if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, &b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt); err != nil { @@ -420,7 +440,7 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) { func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) { g.mu.RLock() defer g.mu.RUnlock() - rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest, + rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool, b.created_at, b.updated_at FROM memory_block_edges e @@ -436,7 +456,7 @@ func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) for rows.Next() { var block MemoryBlock var vectorJSON string - if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest, + if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, &block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt, &block.UpdatedAt); err != nil { diff --git a/internal/memory/block_recall.go b/internal/memory/block_recall.go index 53a9da7e..1501714b 100644 --- a/internal/memory/block_recall.go +++ b/internal/memory/block_recall.go @@ -97,21 +97,12 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) { var hits []BlockHit var skippedDim, skippedFP, scanned int for rows.Next() { - var b MemoryBlock - var vectorJSON string - if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, - &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, - &b.Fingerprint, &b.Source, &b.Tool, &b.Scene, &b.SemanticType, - &b.CreatedAt, &b.UpdatedAt); err != nil { + b, err := scanBlockRow(rows) + if err != nil { return nil, err } scanned++ - vec, err := decodeVector(vectorJSON) - if err != nil { - // 单条坏数据不该让整次召回失败 —— 但要记日志,否则会静默缺失。 - log.Printf("[graph] recall blocks: skip %s: %v", b.ID, err) - continue - } + vec := b.Vector // 维度不一致:跳过并计数。**不截断、不填充**——那会造出无意义的分数。 if len(vec) != dim { skippedDim++ diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 48a526c6..4b5f4ac9 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -309,7 +309,9 @@ func (g *GraphDB) initSchema() error { id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT DEFAULT '', - payload_digest TEXT DEFAULT '', + -- data 是媒体块的负载(union 的另一半):文本块用 text_content, + -- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。 + data BLOB, mime TEXT DEFAULT '', size INTEGER DEFAULT 0, width INTEGER DEFAULT 0, @@ -436,6 +438,12 @@ func (g *GraphDB) initSchema() error { // `type` 在 SQLite 里合法但与很多工具的保留字冲突 // (且 memory_blocks 已有 modality 列表达「模态」,语义类型是另一回事)。 tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN semantic_type TEXT DEFAULT ''`) + // ★★★ 迁移9:媒体负载内联进块(2026-10-11) + // + // 淘汰独立的字节层(media.Store/CAS):媒体内容成为块自己的负载, + // 与文本块的 text_content 对称——一个块要么带文本、要么带媒体字节。 + // 旧库的 payload_digest 列保留(迁移脚本据此回填 data),但不参与新写入。 + tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN data BLOB`) // ★★★ 迁移8:memory_block_edges 升格为「边是独立单位」 // @@ -1100,7 +1108,7 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d var rank int // rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。 if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality, - &bScratch.Text, &bScratch.PayloadDigest, &bScratch.MIME, + &bScratch.Text, &bScratch.MIME, &bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON, &bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool, &sceneNull, &bScratch.SemanticType, @@ -1642,7 +1650,7 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) { // ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了, // Scan 却没跟上)—— 与 semantic_type 是同一类错, // 报错形如「expected 16 destination arguments in Scan, not 14」。 - if err := brows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest, + if err := brows.Scan(&block.ID, &block.Modality, &block.Text, &block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, &block.Fingerprint, &block.Source, &block.Tool, &block.Scene, &block.SemanticType, &block.CreatedAt, diff --git a/internal/memory/graph_test.go b/internal/memory/graph_test.go index 932b72f6..6f883620 100644 --- a/internal/memory/graph_test.go +++ b/internal/memory/graph_test.go @@ -520,7 +520,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) { image := MemoryBlock{ ID: "block_image_1", Modality: BlockImage, - PayloadDigest: "0123456789abcdef", MIME: "image/png", Size: 1234, + Data: []byte("fake-png"), MIME: "image/png", Size: 1234, Width: 768, Height: 512, Vector: []float64{0.1, 0.2, 0.3}, Fingerprint: "qwen:test", Source: "qq", Tool: "upload", } @@ -545,7 +545,7 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) { gotImage = &blocks[i] } } - if gotImage == nil || gotImage.Modality != BlockImage || gotImage.PayloadDigest != image.PayloadDigest || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 { + if gotImage == nil || gotImage.Modality != BlockImage || string(gotImage.Data) != string(image.Data) || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 { t.Fatalf("image block not round-tripped: %+v", gotImage) } diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index 6cf8a026..548ad5a3 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -368,7 +368,7 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string { if blk.Text != "" { b.WriteString(truncateRunes(blk.Text, sceneSentenceMaxRunes)) } else { - b.WriteString(shortBlockDigest(blk.PayloadDigest)) + b.WriteString(shortBlockDigest(blk.ID)) } } b.WriteString("\n") @@ -419,6 +419,10 @@ const sceneSentenceMaxRunes = 60 const maxSceneRecallBlocks = 3 // shortBlockDigest 取 digest 前 12 位做展示(与 L3 里引用媒体的写法一致)。 +// shortBlockDigest 把块 ID 截短,供提示词里指认媒体块。 +// +// 参数曾是内容 digest;块自带负载后,指认单位改成**块 ID**(模型按块 ID +// 取回该块的负载,不再经内容寻址层中转)。 func shortBlockDigest(d string) string { if len(d) <= 12 { return d diff --git a/internal/memory/migrate.go b/internal/memory/migrate.go index d4036595..5b687849 100644 --- a/internal/memory/migrate.go +++ b/internal/memory/migrate.go @@ -87,20 +87,21 @@ func (g *GraphDB) MigrateLegacyMediaEntities(resolve LegacyMediaResolver) (block } defer tx.Rollback() - // 同一份字节可能被多个旧实体引用(重复注入的同一张图), - // 迁移后应指向同一个块:块的身份是内容,不是实体行。 - blockIDForDigest := make(map[string]string) + // 同一份内容可能被多个旧实体引用(重复注入的同一张图),迁移后应指向 + // 同一个块。旧实体名里的**短 digest 就是内容身份**(同内容⇒同短 digest), + // 因此以它为去重键——块自带负载后不再有独立的 payload_digest 字段可依赖。 + blockIDForShort := make(map[string]string) for _, e := range legacy { short := LegacyMediaEntityDigest(e.name) if short != "" { - if block, ok := resolve(short); ok && block.PayloadDigest != "" { - id, seen := blockIDForDigest[block.PayloadDigest] + if block, ok := resolve(short); ok { + id, seen := blockIDForShort[short] if !seen { if err := insertMigratedBlock(tx, block); err != nil { return 0, 0, fmt.Errorf("migrate legacy media %s: %w", short, err) } - blockIDForDigest[block.PayloadDigest] = block.ID + blockIDForShort[short] = block.ID id = block.ID blocks++ } @@ -145,11 +146,11 @@ func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error { created = time.Now() } _, err = tx.Exec(`INSERT INTO memory_blocks ( - id, modality, text_content, payload_digest, mime, size, width, height, + id, modality, text_content, data, mime, size, width, height, vector, fingerprint, source, tool, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`, - block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME, + block.ID, block.Modality, block.Text, block.Data, block.MIME, block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint, block.Source, block.Tool, created, time.Now()) return err diff --git a/internal/memory/migrate_entities.go b/internal/memory/migrate_entities.go index fafeae39..bd592751 100644 --- a/internal/memory/migrate_entities.go +++ b/internal/memory/migrate_entities.go @@ -487,7 +487,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) { } _, err := tx.Exec(`INSERT INTO memory_blocks - (id, modality, text_content, payload_digest, mime, size, width, height, + (id, modality, text_content, data, mime, size, width, height, vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -499,7 +499,7 @@ func putBlockTx(tx *sql.Tx, b MemoryBlock) (bool, error) { semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, `+tsUpdate, - b.ID, b.Modality, b.Text, b.PayloadDigest, b.MIME, b.Size, b.Width, b.Height, + b.ID, b.Modality, b.Text, b.Data, b.MIME, b.Size, b.Width, b.Height, vectorJSON, b.Fingerprint, b.Source, b.Tool, b.Scene, b.SemanticType, created, updated) if err != nil { diff --git a/internal/memory/scene.go b/internal/memory/scene.go index 990cb5a8..79b1a6f7 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -471,7 +471,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error } } - blockQuery := `SELECT b.id, b.modality, b.text_content, b.payload_digest, b.mime, + blockQuery := `SELECT b.id, b.modality, b.text_content, b.mime, b.size, b.width, b.height, b.fingerprint, b.source, b.tool, COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''), b.created_at, b.updated_at, MAX(sr.weight) AS w @@ -491,7 +491,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error for brows.Next() { var b MemoryBlock var w float64 - if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, &b.MIME, + if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.MIME, &b.Size, &b.Width, &b.Height, &b.Fingerprint, &b.Source, &b.Tool, &b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil { return nil, err diff --git a/internal/memory/scene_test.go b/internal/memory/scene_test.go index 45474ff0..393ef950 100644 --- a/internal/memory/scene_test.go +++ b/internal/memory/scene_test.go @@ -344,7 +344,7 @@ func TestSceneBlocks(t *testing.T) { defer g.Close() blocks := []MemoryBlock{ - {ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "aaaa1111bbbb2222", Scene: "chan:qq"}, + {ID: "blk_a", Modality: "image", Text: "老大发的排班表截图", Data: []byte("png-bytes"), Scene: "chan:qq"}, {ID: "blk_b", Modality: "text", Text: "无关场面的转写"}, } if err := g.PutMemoryBlocks(blocks); err != nil { @@ -375,7 +375,7 @@ func TestSceneBlocks(t *testing.T) { // 无场景重写不得抹掉已挂的场景(静默降级防护) if err := g.PutMemoryBlocks([]MemoryBlock{ - {ID: "blk_a", Modality: "image", Text: "重新描述", PayloadDigest: "aaaa1111bbbb2222"}, + {ID: "blk_a", Modality: "image", Text: "重新描述", Data: []byte("png-bytes")}, }); err != nil { t.Fatalf("重写块: %v", err) } @@ -468,7 +468,7 @@ func TestFormatContextSceneBlocks(t *testing.T) { t.Fatal(err) } if err := g.PutMemoryBlocks([]MemoryBlock{ - {ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", PayloadDigest: "cccc3333dddd4444", Scene: "chan:qq"}, + {ID: "blk_x", Modality: "image", Text: "老大发的排班表截图", Data: []byte("png-bytes"), Scene: "chan:qq"}, }); err != nil { t.Fatal(err) } diff --git a/internal/memory/sentence_block.go b/internal/memory/sentence_block.go index ddef4475..505e92b2 100644 --- a/internal/memory/sentence_block.go +++ b/internal/memory/sentence_block.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "encoding/json" "strings" "time" ) @@ -226,3 +227,126 @@ func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (stri edgeID, err := res.LastInsertId() return src.ID, dst.ID, edgeID, created, err } + +// ── 媒体块 ────────────────────────────────────────────── + +// MediaBlockID 由**媒体内容字节**派生媒体块的 ID。 +// +// ★ 为什么媒体块的 ID 也必须内容派生 +// --------------------------------- +// 块 ID 是本仓的记忆身份,既有约定一律内容派生: +// - 原句块 blk_src_ +// - 实体块 blk_ent_ +// - 蒸馏块 blk_ +// 而媒体块此前用时间戳+序号(blk__),是唯一的例外。 +// +// 淘汰 CAS 之后,块自己承载内容,于是「同一份内容只应有一个块」这条 +// 不再由外部的内容寻址层保证,而必须由**块 ID 本身**保证: +// 同内容 ⇒ 同 ID ⇒ PutMemoryBlocks 的 ON CONFLICT 天然去重。 +// 顺带让跨插件边界的引用(只带 ID 前缀)也能唯一定位。 +// +// ★ 为何把 MIME 也掺进去:同样字节在不同 MIME 下是不同的媒体 +// (同一段字节按 image/png 与 application/octet-stream 解读得到不同结果), +// 且 MIME 参与向量计算,掺入可避免「先入者定 MIME」的静默错配。 +func MediaBlockID(mime string, data []byte) string { + h := sha256.New() + h.Write([]byte(strings.TrimSpace(mime))) + h.Write([]byte{0}) + h.Write(data) + return "blk_media_" + hex.EncodeToString(h.Sum(nil)[:12]) +} + +// BlockByIDPrefix 按块 ID 的**前缀**取块(前缀唯一时命中)。 +// +// 用途:跨进程/跨层的轻量引用(如插件只带块 ID 的前 12 位)。 +// 前缀匹配到多条时返回 false —— 宁可不解析也不猜一个(猜错=挂错媒体)。 +func (g *GraphDB) BlockByIDPrefix(prefix string) (MemoryBlock, bool, error) { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return MemoryBlock{}, false, nil + } + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT `+blockColumns+` + FROM memory_blocks WHERE id LIKE ? LIMIT 2`, prefix+"%") + if err != nil { + return MemoryBlock{}, false, err + } + defer rows.Close() + var got []MemoryBlock + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + return MemoryBlock{}, false, err + } + got = append(got, b) + } + if err := rows.Err(); err != nil { + return MemoryBlock{}, false, err + } + if len(got) != 1 { + return MemoryBlock{}, false, nil // 0 条或歧义(≥2)都不解析 + } + return got[0], true, nil +} + +// BlockData 取单个块的负载字节(按需加载)。 +// +// ★ 为何单独一个方法:blockColumns 刻意不含 data(召回只比向量, +// 带上负载会让每次全表召回读入几十 MB)。需要字节的路径显式调本方法。 +func (g *GraphDB) BlockData(blockID string) ([]byte, error) { + g.mu.RLock() + defer g.mu.RUnlock() + var data []byte + err := g.db.QueryRow(`SELECT data FROM memory_blocks WHERE id = ?`, blockID).Scan(&data) + if err != nil { + return nil, err + } + return data, nil +} + +// StaleBlockVectors 返回向量为空、或向量 fingerprint 与当前空间不符的块。 +// +// 用途:启动时把历史媒体块/文档块的向量重算到当前多模态空间(换模型、 +// 换维度、首次接入嵌入服务、嵌入服务离线后重上线)。 +// +// 为何需要它:向量是召回的唯一依据,而历史块可能是在另一个空间里算的。 +// fingerprint 不一致的向量不能参与余弦比较(两个坐标系的值比大小无意义), +// 必须重算而不是留着当"看起来还行"的噪声。 +// +// 只取非文本块:文本块的向量由文本路径维护,不在多模态空间迁移范围内。 +func (g *GraphDB) StaleBlockVectors(currentFP string) ([]MemoryBlock, error) { + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT `+blockColumns+` + FROM memory_blocks + WHERE modality != ? + AND (vector IS NULL OR vector = '' OR vector = 'null' OR fingerprint != ?) + ORDER BY created_at`, string(BlockText), currentFP) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MemoryBlock + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// SetBlockVector 给单个块写入新向量与指纹(迁移用)。只改向量列,不碰负载。 +func (g *GraphDB) SetBlockVector(blockID string, vec []float64, fp string) error { + vectorJSON, err := json.Marshal(vec) + if err != nil { + return err + } + g.mu.Lock() + defer g.mu.Unlock() + _, err = g.db.Exec(`UPDATE memory_blocks SET vector = ?, fingerprint = ?, updated_at = ? + WHERE id = ?`, string(vectorJSON), fp, time.Now(), blockID) + return err +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 40abe36c..31fc69f7 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -89,10 +89,8 @@ type Registry struct { memDB *memory.GraphDB textMem *text.Memory docStore *doc.Store - // mediaStore 让插件写入的记忆也能带媒体。 - // 为 nil 时(配置关闭或初始化失败)插件侧记忆包装退化为纯文本行为, - // 与本特性上线前完全一致。 - mediaStore *media.Store + // mediaStore 已随 CAS 淘汰(块自带负载,见 plan.md)。 + // 不保留字段:留着会诱导后来者再接线,走回头路。 ks *knowledge.Store mgr *agentAPI.ProviderManager cfgReg *internalConfig.ConfigRegistry @@ -197,7 +195,7 @@ func (r *Registry) SetEventBus(evBus *events.Bus) { r. func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB } func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm } func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds } -func (r *Registry) SetMediaStore(ms *media.Store) { r.mediaStore = ms } +func (r *Registry) SetMediaStore(ms *media.Store) {} func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks } func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr } func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg } @@ -362,13 +360,12 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { return sdk.New(name, sdk.SDKConfig{ IOManager: r.iom, EventBus: r.evBus, - // 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS - // 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。 - Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore), - TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore), - DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore), + // 块自带负载(媒体字节在块的 Data),不再需要独立的媒体存储句柄。 + // 插件名仅用于日志溯源(哪个插件写的块)。 + Memory: sdk.NewGraphMemoryWithPlugin(name, r.memDB), + TextMemory: sdk.NewTextMemoryWithPlugin(name, r.textMem), + DocMemory: sdk.NewDocMemoryWithPlugin(name, r.docStore), 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/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/handler_graph_pulse_test.go index 02d7af7c..cb8fe6ac 100644 --- a/internal/plugins/webui/handler_graph_pulse_test.go +++ b/internal/plugins/webui/handler_graph_pulse_test.go @@ -44,7 +44,7 @@ func (f *fakeGraphMemory) BlocksChangedSince(since time.Time, limit int) ([]sdk. } out = append(out, sdk.MemoryBlockView{ ID: b.ID, Modality: string(b.Modality), Text: b.Text, - PayloadDigest: b.PayloadDigest, MIME: b.MIME, + HasData: len(b.Data) > 0, MIME: b.MIME, Source: b.Source, Tool: b.Tool, Scene: b.Scene, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, }) @@ -74,10 +74,10 @@ func graphFixture() map[string]interface{} { // 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。 "memory_blocks": []memory.MemoryBlock{ {ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块", - PayloadDigest: "d0", Vector: []float64{0.1, 0.2, 0.3}, + Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)}, {ID: "b_new", Modality: memory.BlockModality("text"), Text: "刚学到的东西", - PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3}, + Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: now, UpdatedAt: now}, }, } @@ -226,7 +226,7 @@ func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) { // 块表:有一条刚更新(活图谱的真实形态) "memory_blocks": []memory.MemoryBlock{ {ID: "b_live", Modality: memory.BlockModality("text"), Text: "刚写入的块", - PayloadDigest: "dx", CreatedAt: now, UpdatedAt: now}, + CreatedAt: now, UpdatedAt: now}, }, } diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go index b2feb955..af7ef794 100644 --- a/internal/plugins/webui/handler_memory.go +++ b/internal/plugins/webui/handler_memory.go @@ -127,20 +127,20 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) { // 所以这里显式裁掉 vector,而不是让 GraphData 返回值带个开关: // 本接口的语义就是「图谱的可视化数据」,让唯一调用方拿到它要的东西。 type graphBlockView struct { - ID string `json:"id"` - Modality string `json:"modality"` - Text string `json:"text,omitempty"` - PayloadDigest string `json:"payload_digest"` - MIME string `json:"mime,omitempty"` - Size int64 `json:"size"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - Source string `json:"source,omitempty"` - Tool string `json:"tool,omitempty"` - Scene string `json:"scene,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Modality string `json:"modality"` + Text string `json:"text,omitempty"` + HasData bool `json:"has_data"` + MIME string `json:"mime,omitempty"` + Size int64 `json:"size"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Source string `json:"source,omitempty"` + Tool string `json:"tool,omitempty"` + Scene string `json:"scene,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。 @@ -162,20 +162,20 @@ func graphDataForVisual(data map[string]interface{}) map[string]interface{} { for i := range blocks { b := blocks[i] views = append(views, graphBlockView{ - ID: b.ID, - Modality: string(b.Modality), - Text: b.Text, - PayloadDigest: b.PayloadDigest, - MIME: b.MIME, - Size: b.Size, - Width: b.Width, - Height: b.Height, - Fingerprint: b.Fingerprint, - Source: b.Source, - Tool: b.Tool, - Scene: b.Scene, - CreatedAt: b.CreatedAt, - UpdatedAt: b.UpdatedAt, + ID: b.ID, + Modality: string(b.Modality), + Text: b.Text, + HasData: len(b.Data) > 0, + MIME: b.MIME, + Size: b.Size, + Width: b.Width, + Height: b.Height, + Fingerprint: b.Fingerprint, + Source: b.Source, + Tool: b.Tool, + Scene: b.Scene, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, }) } out["memory_blocks"] = views @@ -243,16 +243,16 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) pulse := make([]map[string]interface{}, 0, len(blocks)) for _, b := range blocks { pulse = append(pulse, map[string]interface{}{ - "id": b.ID, - "name": b.Text, - "modality": string(b.Modality), - "payload_digest": b.PayloadDigest, - "mime": b.MIME, - "tool": b.Tool, - "scene": b.Scene, - "source": b.Source, - "updated_at": b.UpdatedAt, - "created_at": b.CreatedAt, + "id": b.ID, + "name": b.Text, + "modality": b.Modality, + "has_data": b.HasData, + "mime": b.MIME, + "tool": b.Tool, + "scene": b.Scene, + "source": b.Source, + "updated_at": b.UpdatedAt, + "created_at": b.CreatedAt, }) } writeJSON(w, http.StatusOK, map[string]interface{}{ diff --git a/internal/sdk/memory.go b/internal/sdk/memory.go index 61678119..cc078dd6 100644 --- a/internal/sdk/memory.go +++ b/internal/sdk/memory.go @@ -12,16 +12,16 @@ import ( // SDK 接口只暴露端点真正要用的字段(原来是先 GraphData 全量再转成 // graphBlockView 做同样的事,只是绕了一圈)。 type MemoryBlockView struct { - ID string `json:"id"` - Modality string `json:"modality"` - Text string `json:"text,omitempty"` - PayloadDigest string `json:"payload_digest,omitempty"` - MIME string `json:"mime,omitempty"` - Source string `json:"source,omitempty"` - Tool string `json:"tool,omitempty"` - Scene string `json:"scene,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Modality string `json:"modality"` + Text string `json:"text,omitempty"` + HasData bool `json:"has_data"` // 媒体负载在块内(CAS 淘汰后),只报有无不回显内容 + MIME string `json:"mime,omitempty"` + Source string `json:"source,omitempty"` + Tool string `json:"tool,omitempty"` + Scene string `json:"scene,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // MemoryAPI 是内置插件使用的全量图记忆接口。 diff --git a/internal/sdk/memory_impl.go b/internal/sdk/memory_impl.go index 5aa5a329..2d1becf5 100644 --- a/internal/sdk/memory_impl.go +++ b/internal/sdk/memory_impl.go @@ -1,6 +1,7 @@ package sdk import ( + "bytes" "fmt" "log" "sync/atomic" @@ -8,30 +9,21 @@ import ( "github.com/JianFeeeee/HomeAgent/internal/memory" doc "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" "github.com/JianFeeeee/HomeAgent/internal/memory/text" ) // 插件侧记忆接口的实现(SDK 桥接层)。 // // 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑 -// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招: -// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit -// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接; -// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向 -// 都把媒体元数据裁掉。 +// 自己认识的几个字段转成内部结构,其余丢弃且不报错。 // // 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。 -// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。 - -const sdkShortDigestLen = 12 - -func sdkShortDigest(d string) string { - if len(d) > sdkShortDigestLen { - return d[:sdkShortDigestLen] - } - return d -} +// +// ★ 2026-10-11:块自带负载(media.Store/CAS 已彻底淘汰)。 +// +// 一个 MemoryBlock 要么是文本块(负载在 Text),要么是媒体块(负载在 Data)。 +// 不再有块外的字节存储层,因此本层也不再需要 media.Store 句柄 —— +// 附件字节直接进块的 Data,随块同生同灭。 // sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键, // 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。 @@ -41,105 +33,70 @@ func sdkNewBlockID() string { return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1)) } -// sdkBlockModality 把 CAS 的媒体大类映射为一等记忆块的模态。 -func sdkBlockModality(k media.Kind) memory.BlockModality { - switch k { - case media.KindImage: +// sdkBlockModality 按 MIME 大类推断块的模态。 +func sdkBlockModality(mime string) memory.BlockModality { + switch { + case bytes.HasPrefix([]byte(mime), []byte("image/")): return memory.BlockImage - case media.KindVideo: + case bytes.HasPrefix([]byte(mime), []byte("video/")): return memory.BlockVideo - case media.KindAudio: + case bytes.HasPrefix([]byte(mime), []byte("audio/")): return memory.BlockAudio default: return memory.BlockText } } -// sdkBlockForDigest 把一份已入库的媒体变成一个一等记忆块。 -// 块自身携带 digest/向量/ fingerprint;CAS 只提供字节与元数据,不参与生命周期。 -func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool) { - it, err := ms.Stat(digest) - if err != nil || it == nil { - return memory.MemoryBlock{}, false - } - return memory.MemoryBlock{ - ID: sdkNewBlockID(), - Modality: sdkBlockModality(it.Kind), - PayloadDigest: it.Digest, - MIME: it.MIME, - Size: it.Size, - Width: it.Width, - Height: it.Height, - Vector: it.Vec, - Fingerprint: it.VecModel, - Tool: it.Tool, - CreatedAt: it.FirstSeen, - }, true -} - -// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。 -// digest 可以是短前缀,内部会先补全。 -func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock { - if ms == nil || len(digests) == 0 { +// sdkBlocksFromAttachments 把附件直接变成一等记忆块(字节进块的 Data)。 +// +// 去重按内容字节:同一份内容被多次提交时只留一个块(块的身份仍是内容, +// 只是这个"内容身份"不再需要一层独立的寻址存储来承载)。 +func sdkBlocksFromAttachments(atts []MediaAttachment, tool string) []memory.MemoryBlock { + if len(atts) == 0 { return nil } seen := map[string]bool{} var blocks []memory.MemoryBlock - for _, d := range digests { - if d == "" { + for _, a := range atts { + if len(a.Data) == 0 { continue } - full, err := ms.ResolvePrefix(d) - if err != nil || seen[full] { + key := string(a.Data) + if seen[key] { continue } - seen[full] = true - if b, ok := sdkBlockForDigest(ms, full); ok { - blocks = append(blocks, b) - } - } - return blocks -} - -// sdkPutAttachment 把一份附件解析成完整 digest。 -// -// 两种入口:带 Data 的是新内容(落进 CAS,相同字节自动去重); -// 只给 Digest 的是引用已有内容(补全前缀即可)。两者都不给则无效。 -func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string, error) { - if len(a.Data) > 0 { + seen[key] = true mime := a.MIME if mime == "" { mime = "application/octet-stream" } - return ms.Put(a.Data, media.Item{ - MIME: mime, - Tool: tool, - OriginPath: a.Name, + blocks = append(blocks, memory.MemoryBlock{ + ID: sdkNewBlockID(), + Modality: sdkBlockModality(mime), + Data: a.Data, + MIME: mime, + Size: int64(len(a.Data)), + Source: tool, + CreatedAt: time.Now(), }) } - 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 + return blocks } // ---------- 图记忆 ---------- 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} +// NewGraphMemoryWithPlugin 创建带上报名的图记忆包装。 +// +// 旧签名曾接 media.Store;块自带负载后不再需要它,故改名为 WithPlugin。 +func NewGraphMemoryWithPlugin(plugin string, db *memory.GraphDB) MemoryAPI { + return &graphMemory{db: db, plugin: plugin} } func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) { @@ -154,8 +111,6 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e 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{ @@ -168,8 +123,10 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e return entities, relations, nil } -// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。 +// Commit 把插件的三元组写入图库,并把结构化媒体变成 L3 一等块。 +// // 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。 +// 块自带字节负载,因此需要媒体元数据(Data/MIME)而不仅是一个 digest。 func (m *graphMemory) Commit(triples []Triple) error { if m.db == nil { return nil @@ -200,12 +157,11 @@ func (m *graphMemory) Commit(triples []Triple) error { // bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块, // 并以 sentence --contains--> block 结构边关联。 // -// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。 -// ★ sentenceIDs 现在是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。 -// -// sentences 表退场后行号不存在,媒体边改挂到原句块上。 +// ★ sentenceIDs 是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。 +// ★ 媒体负载:Triple.MediaDigests 只带短前缀(跨进程/跨层的轻量引用), +// 这里按前缀在**本进程已建的块**里找不到字节时如实记日志,不静默造空块。 func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []memory.Triple) { - if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 { + if m.db == nil || len(sentenceIDs) == 0 { return } bound := 0 @@ -217,13 +173,22 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []mem if sid == "" { continue } - for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) { + for _, ref := range t.MediaDigests { + // ref 可以是完整块 ID,也可以是前缀(插件跨进程只带短引用)。 + // 媒体块 ID 是内容派生的 blk_media_,故前缀足以唯一定位。 + b, ok, err := m.db.BlockByIDPrefix(ref) + if err != nil { + log.Printf("[sdk media] 插件 %s 查块 %s 失败: %v", m.plugin, ref, err) + continue + } + if !ok { + log.Printf("[sdk media] 插件 %s 的媒体引用 %s 无唯一对应块,跳过", m.plugin, ref) + continue + } if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err) continue } - // ★ 起点是**块**(原句块)而非 sentence 表行 —— - // 句子的承载者已从 sentences 表迁移到 blk_src_。 if err := m.db.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err) continue @@ -265,8 +230,6 @@ func (m *graphMemory) GraphData() (map[string]interface{}, error) { } // BlocksChangedSince 实现 MemoryAPI(星图轻量轮询)。 -// -// 直接走库侧 SQL 过滤,不取全量快照后再筛——见接口注释里的实测对比。 func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBlockView, error) { if m.db == nil { return nil, nil @@ -275,20 +238,19 @@ func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBl if err != nil { return nil, err } - // 映射到边界面视图:只带端点要用的字段,不把内部结构泄出去。 out := make([]MemoryBlockView, 0, len(blocks)) for _, b := range blocks { out = append(out, MemoryBlockView{ - ID: b.ID, - Modality: string(b.Modality), - Text: b.Text, - PayloadDigest: b.PayloadDigest, - MIME: b.MIME, - Source: b.Source, - Tool: b.Tool, - Scene: b.Scene, - CreatedAt: b.CreatedAt, - UpdatedAt: b.UpdatedAt, + ID: b.ID, + Modality: string(b.Modality), + Text: b.Text, + HasData: len(b.Data) > 0, + MIME: b.MIME, + Source: b.Source, + Tool: b.Tool, + Scene: b.Scene, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, }) } return out, nil @@ -298,15 +260,14 @@ func (m *graphMemory) BlocksChangedSince(since time.Time, limit int) ([]MemoryBl 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} +// NewTextMemoryWithPlugin 创建带上报名的文本记忆包装。 +func NewTextMemoryWithPlugin(plugin string, tm *text.Memory) TextMemoryAPI { + return &textMemoryImpl{tm: tm, plugin: plugin} } // Append 追加一条文本事件。 @@ -355,19 +316,20 @@ func (m *textMemoryImpl) Stats() map[string]interface{} { 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} +// NewDocMemoryWithPlugin 创建带上报名的文档记忆包装。 +func NewDocMemoryWithPlugin(plugin string, ds *doc.Store) DocMemoryAPI { + return &docMemoryImpl{ds: ds, plugin: plugin} } // Query 检索文档,并从文档持有的一等块补齐媒体元数据。 -// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。 +// +// 只返回块的 MIME/ID,不返回字节:字节留在这条文档的块里, +// 需要时按块 ID 单取(避免把整批媒体读进内存)。 func (m *docMemoryImpl) Query(text string, topK int) []*Doc { if m.ds == nil { return nil @@ -384,37 +346,37 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc { // fillMedia 填充文档的媒体字段。 // // 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、 -// 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。 +// 也不靠任何生成的描述文本。 func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) { - if m.ms == nil || d == nil { + if d == nil { return } for _, b := range d.Blocks { - if b.PayloadDigest == "" { + if b.Modality == memory.BlockText && len(b.Data) == 0 { continue } - out.MediaDigests = append(out.MediaDigests, b.PayloadDigest) - att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME} - if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil { - att.MIME = it.MIME - } - out.Attachments = append(out.Attachments, att) + out.MediaDigests = append(out.MediaDigests, b.ID) + out.Attachments = append(out.Attachments, MediaAttachment{ + Digest: b.ID, + MIME: b.MIME, + }) } } -// appendBlocks 把新的块追加到已有块之后(按 digest 去重)。 +// appendBlocks 把新的块追加到已有块之后(按内容去重)。 func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock { seen := make(map[string]bool, len(existing)) for _, b := range existing { - seen[b.PayloadDigest] = true + seen[string(b.Data)] = true } for _, b := range add { - if b.PayloadDigest != "" && seen[b.PayloadDigest] { + k := string(b.Data) + if k != "" && seen[k] { continue } existing = append(existing, b) - if b.PayloadDigest != "" { - seen[b.PayloadDigest] = true + if k != "" { + seen[k] = true } } return existing @@ -425,9 +387,9 @@ func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) // InsertWithMedia 写入文档并关联媒体。 // -// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest, -// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的 -// 媒体向量(同一统一空间),图片按自己的向量被召回。 +// 媒体直接成为文档持有的一等记忆块(字节在块的 Data)。 +// 不往正文写 marker——文档向量会融合这些块的媒体向量(同一统一空间), +// 图片按自己的向量被召回。 func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error { if m.ds == nil || d == nil { return nil @@ -437,75 +399,25 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e target.Source = "plugin:" + m.plugin } - digests := m.storeAttachments(attachments) - - // 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。 - target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests)) + target.Blocks = appendBlocks(target.Blocks, + sdkBlocksFromAttachments(attachments, "plugin_doc:"+m.plugin)) if err := m.ds.Insert(target); err != nil { return err } - // 回填给调用方:ID 是新建时内核生成的。 d.ID = target.ID return nil } -// storeAttachments 把附件落库,返回全部完整 digest。 -func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string { - if m.ms == nil || len(atts) == 0 { - return nil - } - seen := map[string]bool{} - var digests []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 - } - if seen[full] { - continue - } - seen[full] = true - digests = append(digests, full) - } - return digests -} - -// Remove 删除文档,并删除它持有的一等块所对应的内容(无其他块共享时)。 +// Remove 删除文档。 // -// 与文本块一致:删除块即删除内容。媒体字节是块的内容存储, -// 不单独做引用计数或 GC。 +// 块随文档一起消失,块里的字节也随之消失——无需引用计数或外部 GC: +// 字节就在块里,块没了字节就没了。 func (m *docMemoryImpl) Remove(id string) { if m.ds == nil { return } - var digests []string - if d := m.ds.Get(id); d != nil { - for _, b := range d.Blocks { - if b.PayloadDigest != "" { - digests = append(digests, b.PayloadDigest) - } - } - } m.ds.Remove(id) - if m.ms == nil { - return - } - // 仍被其它文档持有的 digest 不能删(同一份字节可能被多个块共享)。 - stillHeld := map[string]bool{} - for _, b := range m.ds.Blocks() { - stillHeld[b.PayloadDigest] = true - } - for _, d := range digests { - if stillHeld[d] { - continue - } - if err := m.ms.Delete(d); err != nil { - log.Printf("[sdk media] 删除文档 %s 的内容失败 %s: %v", id, sdkShortDigest(d), err) - } - } } func (m *docMemoryImpl) Stats() map[string]interface{} { diff --git a/internal/sdk/memory_impl_test.go b/internal/sdk/memory_impl_test.go index 83301751..b9584f23 100644 --- a/internal/sdk/memory_impl_test.go +++ b/internal/sdk/memory_impl_test.go @@ -65,8 +65,8 @@ func putMedia(t *testing.T, ms *media.Store, payload string) string { // 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃: // 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。 func TestGraphCommit_CarriesAllFields(t *testing.T) { - g, _, _, ms := newTestStores(t) - m := NewGraphMemoryWithMedia("tester", g, ms) + g, _, _, _ := newTestStores(t) + m := NewGraphMemory("tester", g) err := m.Commit([]Triple{{ Subject: "张三",