Files
HomeAgent/internal/memory/media/media.go
JianFeeeee f855893d1c feat(memory): 媒体接入 L0/L2——digest 挂到对话事件,归档时引用随之转移
a822674 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的
存储包,没有任何写入方。

## 媒体进入对话有两条路,两条都只把文字留给记忆

  1. 用户直接发图 → processMediaInput → mediaToBlocks
     ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
     base64 随 message 数组发给模型后就丢了。
  2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
     ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。

于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖,
或者本来就是 /tmp 下的临时产物,连线索都断了。

现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL
取出字节存进 CAS,digest 挂到当轮 ContextEvent。

## 改动

internal/agent/core/mediaref.go(新)
  - captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL
    拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络
    请求(超时、鉴权、SSRF 全来了),不在本层解决。
  - stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的
    ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id,
    故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。
  - bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随
    context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。
    只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。
  - mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的
    落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量
    GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」
    几个月后仍可被检索。

ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty:
  - ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成
    会让每条事件都多一个字段进 context.json。
  - 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。

RelevanceContext.Prune 归档时转移引用(transferMediaRefs):
  **先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用
  计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。
  为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法
  出现在方法签名上)。

media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量:
  owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用——
  AddRef 不报错,DropOwner 也永远匹配不到。

## 配置

core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、
.gc_interval(6h)、.gc_min_age(1h)。

关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。
mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来
只记一条 warning 不阻止启动。

## 测试(11 例)

入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、
stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容
始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json
向后兼容往返。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。

## 尚未接入

L3 图库的 graph_sentence owner(常量已备好,无写入方)、
描述生成的后台任务(Pending() 已就绪,尚无消费者)、
媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。
2026-09-04 20:53:32 +08:00

717 lines
22 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 media 是记忆系统的内容寻址媒体存储CAS
//
// 为何需要它此前四层记忆全是纯文本载体——L0 `ContextEvent`、L1 `text.Event`、
// L2 `document.Doc`、L3 图库的 `sentences.text TEXT UNIQUE`——没有任何一层能
// 存二进制。multimodal 插件注入的图片在本轮对话内可见(走 message 数组,不经
// 记忆),下一轮起就只剩 `ToolResultItem.Output` 里那句
// "[已将图片注入后续对话] /tmp/x.png",即一条路径字符串。那个文件被删或被
// 覆盖之后连线索都断了。
//
// 为何是内容寻址而不是存路径:
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
// 路径等于留个悬空指针。
// - 同一张图往往被反复注入用户连问几轮同一张截图、see_video 相邻帧高度
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
// 文本本身做身份,媒体节点用内容摘要做身份。
package media
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
//
// 定义为常量而不是让调用方写字符串owner_kind 进了主键,
// 拼错一个字符就是一条永远对不上的孤立引用AddRef 不会报错,
// DropOwner 也永远匹配不到)。
const (
// OwnerContext 是 L0 对话上下文事件ContextEvent.ID
OwnerContext = "context"
// OwnerDocument 是 L2 文档记忆Doc.ID
OwnerDocument = "document"
// OwnerGraphSentence 是 L3 图库句子节点sentences.id
OwnerGraphSentence = "graph_sentence"
)
// Kind 是媒体大类。刻意只分三类而不细分具体格式:
// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。
type Kind string
const (
KindImage Kind = "image"
KindAudio Kind = "audio"
KindVideo Kind = "video"
KindOther Kind = "other"
)
// Item 是一条媒体记录。
//
// Digest 既是主键也是磁盘文件名,所以没有单独的 Path 字段——路径可由
// Store 根据 Digest 推导,不落库(落了就又是个会失效的引用)。
type Item struct {
// Digest 是内容 sha256 的十六进制串64 字符),媒体的唯一身份。
Digest string `json:"digest"`
// Kind 是大类,供检索时按模态筛选。
Kind Kind `json:"kind"`
// MIME 是原始 MIME 类型,如 image/png。
MIME string `json:"mime"`
// Size 是字节数。
Size int64 `json:"size"`
// Width/Height 是像素尺寸,未知或不适用时为 0。
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
// OriginPath 是首次入库时的来源路径,仅供人类溯源与调试。
// **不可用于读取内容**——它随时可能失效,这正是本包存在的理由。
OriginPath string `json:"origin_path,omitempty"`
// Tool 是注入这条媒体的工具名(如 multimodal_see_picture
Tool string `json:"tool,omitempty"`
// Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。
// 空表示未描述(未开启描述、模型不可用或描述失败)。
Description string `json:"description,omitempty"`
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
DescribedBy string `json:"described_by,omitempty"`
// RefCount 是引用计数。GC 只清理归零的项。
RefCount int `json:"ref_count"`
// FirstSeen/LastSeen 是首末次入库时间。
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
// Store 管理媒体的元数据SQLite与内容磁盘 CAS 目录)。
//
// 元数据与内容分离而不是把 blob 塞进 SQLite单张图动辄几 MB塞进库会让
// 每次 VACUUM/备份都拖着几百 MB 走,也让 WAL 迅速膨胀。CAS 目录用两级
// 前缀分桶ab/cdef...)避免单目录几万文件。
type Store struct {
mu sync.RWMutex
db *sql.DB
blobDir string
// maxBytes 是内容目录的容量上限0 表示不限。
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
maxBytes int64
}
// New 打开(或初始化)媒体存储。
// dir 下会建 media.db 与 blobs/ 两个条目。
func New(dir string, maxBytes int64) (*Store, error) {
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
return nil, fmt.Errorf("media: create blob dir: %w", err)
}
dbPath := filepath.Join(dir, "media.db")
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("media: open db: %w", err)
}
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes}
if err := s.initSchema(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) initSchema() error {
stmts := []string{
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。
`CREATE TABLE IF NOT EXISTS media (
digest TEXT PRIMARY KEY,
kind TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
origin_path TEXT,
tool TEXT,
description TEXT,
described_by TEXT,
ref_count INTEGER DEFAULT 0,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`,
`CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`,
`CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`,
// 反向索引:哪条记忆引用了哪个媒体。
// owner_kind 取 context / document / graph_sentenceowner_id 是各层自己的标识。
// 主键含三列,同一 owner 重复挂同一媒体是幂等的。
`CREATE TABLE IF NOT EXISTS media_refs (
digest TEXT NOT NULL,
owner_kind TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (digest, owner_kind, owner_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`,
`CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("media: schema %q: %w", truncate(q, 60), err)
}
}
return nil
}
// blobPath 按两级前缀分桶推导内容路径。
func (s *Store) blobPath(digest string) string {
if len(digest) < 4 {
return filepath.Join(s.blobDir, digest)
}
return filepath.Join(s.blobDir, digest[:2], digest[2:])
}
// Put 落盘并登记一段媒体内容,返回其 digest。
//
// 幂等:同一内容重复 Put 不重复落盘,只更新 last_seen 与可选的新元数据
// (描述、尺寸等——后来者可能带着前一次没有的信息)。
func (s *Store) Put(data []byte, meta Item) (string, error) {
if len(data) == 0 {
return "", fmt.Errorf("media: empty content")
}
sum := sha256.Sum256(data)
digest := hex.EncodeToString(sum[:])
s.mu.Lock()
defer s.mu.Unlock()
path := s.blobPath(digest)
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return "", fmt.Errorf("media: mkdir: %w", err)
}
// 先写临时文件再 rename中途崩溃不会留下半个 blob 被后续
// 当成完整内容读走digest 校验能发现,但那时已经把坏数据喂给模型了)。
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return "", fmt.Errorf("media: write blob: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return "", fmt.Errorf("media: commit blob: %w", err)
}
}
now := time.Now()
if meta.Kind == "" {
meta.Kind = KindFromMIME(meta.MIME)
}
_, err := s.db.Exec(`
INSERT INTO media (digest, kind, mime, size, width, height,
origin_path, tool, description, described_by,
ref_count, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(digest) DO UPDATE SET
last_seen = excluded.last_seen,
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
-- 后到的空值不该把它冲掉。
description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END,
described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END,
width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END,
height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END,
tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END
`, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height,
meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now)
if err != nil {
return "", fmt.Errorf("media: upsert meta: %w", err)
}
return digest, nil
}
// Get 读取内容并校验 digest。
//
// 校验不是多余的CAS 的全部保证建立在"文件名 == 内容摘要"上,磁盘位翻转
// 或外部误改会让这条保证失效,而把损坏的图喂给模型只会得到无从追溯的幻觉。
func (s *Store) Get(digest string) ([]byte, error) {
s.mu.RLock()
path := s.blobPath(digest)
s.mu.RUnlock()
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("media: read %s: %w", shortDigest(digest), err)
}
sum := sha256.Sum256(data)
if got := hex.EncodeToString(sum[:]); got != digest {
return nil, fmt.Errorf("media: digest mismatch for %s (content corrupted)", shortDigest(digest))
}
return data, nil
}
// Stat 返回元数据,不读内容。
func (s *Store) Stat(digest string) (*Item, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.scanOne(s.db.QueryRow(`
SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen
FROM media WHERE digest = ?`, digest))
}
// Describe 写入(或覆盖)文字描述。
//
// 与 Put 的"只在空时补写"不同Describe 是显式操作,调用方明确想要这份
// 描述生效(例如换了更强的视觉模型重新描述)。
func (s *Store) Describe(digest, description, describedBy string) error {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`,
description, describedBy, digest)
if err != nil {
return fmt.Errorf("media: describe: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest))
}
return nil
}
// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。
func (s *Store) AddRef(digest, ownerKind, ownerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`,
digest, ownerKind, ownerID)
if err != nil {
return fmt.Errorf("media: add ref: %w", err)
}
// 只有真的插进去才递增否则重复调用会让计数虚高GC 永远不敢清。
if n, _ := res.RowsAffected(); n > 0 {
if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil {
return fmt.Errorf("media: bump refcount: %w", err)
}
}
return tx.Commit()
}
// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。
func (s *Store) DropRef(digest, ownerKind, ownerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`,
digest, ownerKind, ownerID)
if err != nil {
return fmt.Errorf("media: drop ref: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
// MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时,
// 不让它掉成负数(负数会让容量 GC 的排序失去意义)。
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil {
return fmt.Errorf("media: lower refcount: %w", err)
}
}
return tx.Commit()
}
// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。
func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`,
ownerKind, ownerID)
if err != nil {
return 0, err
}
var digests []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err == nil {
digests = append(digests, d)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(digests) == 0 {
return 0, nil
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil {
return 0, err
}
for _, d := range digests {
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(digests), nil
}
// Refs 返回某个 owner 引用的全部 digest。
func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`,
ownerKind, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err == nil {
out = append(out, d)
}
}
return out, rows.Err()
}
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
//
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
// (描述文字随记忆条目一起进 Doc.Content复用那套 TF-IDF/embedding
// 本方法只是"按关键词直接翻媒体库"的补充入口。
func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
if limit <= 0 {
limit = 20
}
s.mu.RLock()
defer s.mu.RUnlock()
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen
FROM media WHERE COALESCE(description,'') != ''`
args := []interface{}{}
if strings.TrimSpace(query) != "" {
q += ` AND description LIKE ?`
args = append(args, "%"+query+"%")
}
if kind != "" {
q += ` AND kind = ?`
args = append(args, string(kind))
}
q += ` ORDER BY last_seen DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Item
for rows.Next() {
it, err := s.scanRows(rows)
if err != nil {
continue
}
out = append(out, it)
}
return out, rows.Err()
}
// Pending 返回尚无描述的媒体,供后台描述任务消费。
func (s *Store) Pending(limit int) ([]*Item, error) {
if limit <= 0 {
limit = 10
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen
FROM media WHERE COALESCE(description,'') = ''
ORDER BY last_seen DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Item
for rows.Next() {
it, err := s.scanRows(rows)
if err != nil {
continue
}
out = append(out, it)
}
return out, rows.Err()
}
// GC 清理无人引用的内容。
//
// 两段策略:
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及
// AddRef 的项 refcount 也是 0minAge 保护它们不被立刻清掉。
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。
//
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-minAge)
rows, err := s.db.Query(`
SELECT digest, size FROM media
WHERE ref_count <= 0 AND last_seen < ?
ORDER BY last_seen`, cutoff)
if err != nil {
return 0, 0, err
}
type cand struct {
digest string
size int64
}
var cands []cand
for rows.Next() {
var c cand
if err := rows.Scan(&c.digest, &c.size); err == nil {
cands = append(cands, c)
}
}
rows.Close()
for _, c := range cands {
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿
}
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
continue
}
removed++
freed += c.size
}
if s.maxBytes > 0 {
r2, f2 := s.enforceCapacityLocked()
removed += r2
freed += f2
}
return removed, freed, nil
}
// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。
func (s *Store) enforceCapacityLocked() (removed int, freed int64) {
var total int64
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil {
return 0, 0
}
if total <= s.maxBytes {
return 0, 0
}
need := total - s.maxBytes
rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`)
if err != nil {
return 0, 0
}
type cand struct {
digest string
size int64
}
var cands []cand
for rows.Next() {
var c cand
if err := rows.Scan(&c.digest, &c.size); err == nil {
cands = append(cands, c)
}
}
rows.Close()
for _, c := range cands {
if freed >= need {
break
}
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
continue
}
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
continue
}
removed++
freed += c.size
}
return removed, freed
}
// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。
func (s *Store) Stats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes}
var count, described, orphan int
var total int64
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
out["count"] = count
out["total_bytes"] = total
out["described"] = described
out["unreferenced"] = orphan
byKind := map[string]int{}
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
if err == nil {
defer rows.Close()
for rows.Next() {
var k string
var n int
if rows.Scan(&k, &n) == nil {
byKind[k] = n
}
}
}
out["by_kind"] = byKind
return out
}
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.db.Close()
}
// ---- 扫描辅助 ----
type rowScanner interface {
Scan(dest ...interface{}) error
}
func (s *Store) scanOne(r rowScanner) (*Item, error) {
it, err := scanItem(r)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("media: unknown digest")
}
return it, err
}
func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) }
func scanItem(r rowScanner) (*Item, error) {
var it Item
var kind string
var origin, tool, desc, by sql.NullString
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen); err != nil {
return nil, err
}
it.Kind = Kind(kind)
it.OriginPath = origin.String
it.Tool = tool.String
it.Description = desc.String
it.DescribedBy = by.String
return &it, nil
}
// ---- 工具函数 ----
// KindFromMIME 把 MIME 归到大类。
func KindFromMIME(mime string) Kind {
m := strings.ToLower(strings.TrimSpace(mime))
switch {
case strings.HasPrefix(m, "image/"):
return KindImage
case strings.HasPrefix(m, "audio/"):
return KindAudio
case strings.HasPrefix(m, "video/"):
return KindVideo
default:
return KindOther
}
}
// ParseDataURL 从 data:<mime>;base64,<data> 提取 MIME 与原始字节。
//
// 与 agent/api 里的 parseAudioDataURL 分开实现:那个只认音频且只回 base64
// 串(它要把串塞回 OpenAI 的 input_audio 字段),这里要的是解码后的字节。
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 := base64Decode(payload)
if err != nil {
return "", nil, false
}
return mime, decoded, true
}
// DataURL 把内容编回 data URL供重新注入模型对话。
func DataURL(mime string, data []byte) string {
return "data:" + mime + ";base64," + base64Encode(data)
}
// CopyFrom 从 reader 读全部内容后 Put用于大文件不便一次性构造 []byte 的场合。
func (s *Store) CopyFrom(r io.Reader, meta Item) (string, error) {
data, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("media: read source: %w", err)
}
return s.Put(data, meta)
}
// MarshalItems 序列化条目列表,供工具返回给模型。
func MarshalItems(items []*Item) string {
b, err := json.Marshal(items)
if err != nil {
return "[]"
}
return string(b)
}
func shortDigest(d string) string {
if len(d) > 12 {
return d[:12]
}
return d
}
func truncate(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
return s[:n] + "..."
}