refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)

块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。

- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
  MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
  captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
This commit is contained in:
JianFeeeee
2026-10-12 10:13:36 +08:00
parent b15994d964
commit 86572d134d
30 changed files with 806 additions and 864 deletions

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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_<sha256(text[:12])>,
// 媒体边因此改挂「原句块 --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_<hash>。
//
// 原因:Commit 块化后原句由块承载,sentences 表退场,
// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。
//
// 这条链(mediaContextForSentences / mediaContextForRelations)
// 完全靠这个 ID 找回媒体块,所以改错了 = 媒体上下文整体失效。
// blk_src_<hash>。原因: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
}

View File

@ -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 为可读日志格式。

View File

@ -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:<mime>;base64,<data> 解出 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
}

View File

@ -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 承载媒体,并补一句来源说明。

View File

@ -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)
}
}