Files
HomeAgent/internal/memory/media/media.go
JianFeeeee 28fc833a6f feat(memory): L3 图库媒体反查 + 修 L2→L3 引用泄漏
媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。

## 为何不把媒体建成图库实体

图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经
mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content,
蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。

若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成
(换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
多个语义模糊的节点。代价换不来能力。

所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6]
一张紫蓝红三色带图」,要能从这条句子取回那份字节。

## CommitWithMedia:新增方法而非改签名

Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要
的返回值改全部签名不划算。新增 CommitWithMedia 返回
map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。

## digest 靠正则从文本反解

三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/
Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进
结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身
是我们自己按固定格式写进文本的,反解是最省的可靠做法。

配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行
撑爆且无助人眼辨认),media_refs 主键要完整 digest。

**前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。
完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。

## 顺带修掉 L2→L3 的引用泄漏

这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移,
漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销
媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest,
media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被
GC 回收。

新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从
描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。

顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到
graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC
正在跑就会把内容当孤儿清掉。

## 顺带修 Pending 的排除逻辑遗漏(承上一提交)

## 测试

graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip:
写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0)
确认被引用的内容不被清。

其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿
假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路
静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全
要么唯一命中要么明确报歧义。

TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时
entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行
affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关,
该字段只用于日志,故记录现状不改行为。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
2026-09-04 22:52:30 +08:00

781 lines
24 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"
)
// digestHexLen 是 sha256 的十六进制串长度。
const digestHexLen = sha256.Size * 2
// 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 返回尚无描述的媒体,供后台描述任务消费。
// Pending 返回尚无描述的媒体,供后台描述任务消费。
//
// 不只看 description 为空,还要求 described_by 也为空。
// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失)
// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空——
// 若只看 description这些项每轮都会被取出来重试永远卡在队列头部
// 真正需要描述的新项永远轮不到LIMIT 只取前 N 条)。
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,'') = '' AND COALESCE(described_by,'') = ''
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] + "..."
}
// ResolvePrefix 把 digest 前缀补全为完整 digest。
//
// 日志、事件摘要与图库句子里出现的都是 shortDigest前 12 位),
// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。
// 反查时需要这个补全,否则那些短标记只能看不能用。
//
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容,
// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。
func (s *Store) ResolvePrefix(prefix string) (string, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if len(prefix) < 8 {
return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix)
}
if len(prefix) == digestHexLen {
// 已是完整 digest仍要确认存在否则调用方会挂一条孤儿引用
if _, err := s.Stat(prefix); err != nil {
return "", err
}
return prefix, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(
`SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix)
if err != nil {
return "", err
}
defer rows.Close()
var found []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
return "", err
}
found = append(found, d)
}
if err := rows.Err(); err != nil {
return "", err
}
switch len(found) {
case 0:
return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix)
case 1:
return found[0], nil
default:
return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s",
prefix, found[0][:16], found[1][:16])
}
}