Files
HomeAgent/internal/agent/core/mediaref.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

204 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package core
import (
"encoding/base64"
"fmt"
"log"
"strings"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// 媒体记忆接线:把对话里出现的图片/音频变成一等记忆块。
//
// ★ 2026-10-11:块自带负载(CAS 已淘汰)。
//
// 此前这里把字节存进独立的内容寻址存储(CAS),块只留一个 digest 指向它。
// 那是把「媒体的内容」从块里拆出去另建一层——现在媒体字节直接进块的 Data,
// 与文本块的 Text 对称:一个块要么带文本、要么带字节。
//
// 媒体进入对话有两条路:用户直接发图(ContentBlock data URL)、插件注入
// (SetToolBlocks)。两条都在这里收口成块,挂到当轮 ContextEvent;
// 事件被 Prune 时块随之迁移到 L2 文档(块本身不变,只换持有层)。
//
// 不再生成任何描述文本,也不再往正文写 media marker:图片只按自己的
// 统一空间向量被检索,描述式索引是将就方案。
// blockModalityFromMIME 按 MIME 大类推断块的模态。
func blockModalityFromMIME(mime string) memory.BlockModality {
switch {
case strings.HasPrefix(mime, "image/"):
return memory.BlockImage
case strings.HasPrefix(mime, "video/"):
return memory.BlockVideo
case strings.HasPrefix(mime, "audio/"):
return memory.BlockAudio
default:
return memory.BlockText
}
}
// captureBlockMedia 把 blocks 里的 data URL 媒体变成一等记忆块,返回块列表。
//
// 只处理 data URL:http(s) URL 拿不到字节,而"下载它再存"会把一次对话变成
// 一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
//
// 块 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 out []memory.MemoryBlock
for _, b := range blocks {
var url string
switch {
case b.ImageURL != nil && b.ImageURL.URL != "":
url = b.ImageURL.URL
case b.AudioURL != nil && b.AudioURL.URL != "":
url = b.AudioURL.URL
default:
continue
}
mime, data, ok := parseDataURL(url)
if !ok {
continue // http(s) URL 或格式不认,跳过
}
block := memory.MemoryBlock{
ID: memory.MediaBlockID(mime, data),
Modality: blockModalityFromMIME(mime),
Data: data,
MIME: mime,
Size: int64(len(data)),
Tool: tool,
CreatedAt: time.Now(),
}
// 入库即算一次多模态坐标(多模态空间可用且为图像时)。
// 之后块召回直接用它;换模型由启动时的 reembedStaleMedia 补算。
a.embedBlockOnIngest(&block)
out = append(out, block)
}
return out
}
// 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] ingest embedding failed %s: %v", shortID(b.ID), err)
return
}
b.Vector = vec
b.Fingerprint = a.multimodalSpace.Fingerprint()
}
// stageMediaBlocks 累积本轮捕获的块,等 ContextEvent 建好后一起挂上。
//
// 为何要缓存而不是当场挂:块在 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()
}
evt.Blocks = append(evt.Blocks, blocks...)
}
// blockLabel 渲染一行媒体块标签,供提示词告知"这条记忆带着哪份媒体"。
//
// 不含任何生成的描述文本:图片按自己的向量被检索,标签仅提供 MIME 与
// 短块 ID,让模型知道有这份媒体、并可据此取回(块 ID 是它的唯一身份)。
func blockLabel(b *memory.MemoryBlock) string {
if b == nil {
return ""
}
label := string(b.Modality)
if b.MIME != "" {
label = b.MIME
}
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 的稳定标识。
//
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
// schedulerLoop 单 goroutine 串行 Append,不存在同纳秒两条。
func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
}
// shortID 把块 ID 截短供日志/提示词使用。
func shortID(id string) string {
if len(id) > 20 {
return id[:20]
}
return id
}