llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
292 lines
6.3 KiB
Go
292 lines
6.3 KiB
Go
// Package imagecache 实现磁盘图片缓存:历史消息中的图片只保留文件名引用,
|
||
// 最新消息的图片才加载 base64 交给大模型处理。
|
||
package imagecache
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Cache 是一个磁盘图片缓存。
|
||
type Cache struct {
|
||
dir string
|
||
maxAge time.Duration
|
||
maxSize int64
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
// New 创建图片缓存目录并返回 Cache 实例。
|
||
// dir: 缓存目录(如 ~/.autogo/images/)
|
||
// maxAge: 图片最大存活时间(0 = 永不过期)
|
||
// maxSize: 缓存目录最大字节数(0 = 不限制)
|
||
func New(dir string, maxAge time.Duration, maxSize int64) (*Cache, error) {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("imagecache: create dir: %w", err)
|
||
}
|
||
c := &Cache{dir: dir, maxAge: maxAge, maxSize: maxSize}
|
||
// 启动时清理过期图片
|
||
go c.cleanup()
|
||
return c, nil
|
||
}
|
||
|
||
// DefaultCache 创建默认缓存(~/.autogo/images/,30天过期,1GB限制)。
|
||
func DefaultCache() (*Cache, error) {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
home = "."
|
||
}
|
||
dir := filepath.Join(home, ".autogo", "images")
|
||
return New(dir, 30*24*time.Hour, 1<<30)
|
||
}
|
||
|
||
// Store 存储图片数据到缓存,返回文件名(不含路径)。
|
||
// ext: 文件扩展名(如 ".jpg", ".png")
|
||
func (c *Cache) Store(data []byte, ext string) (string, error) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
if ext == "" {
|
||
ext = ".jpg"
|
||
}
|
||
if !strings.HasPrefix(ext, ".") {
|
||
ext = "." + ext
|
||
}
|
||
|
||
// 生成唯一文件名
|
||
filename, err := c.generateFilename(ext)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
path := filepath.Join(c.dir, filename)
|
||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||
return "", fmt.Errorf("imagecache: write file: %w", err)
|
||
}
|
||
|
||
return filename, nil
|
||
}
|
||
|
||
// StoreReader 从 reader 存储图片,返回文件名。
|
||
func (c *Cache) StoreReader(r io.Reader, ext string) (string, error) {
|
||
data, err := io.ReadAll(r)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return c.Store(data, ext)
|
||
}
|
||
|
||
// Load 从缓存加载图片数据。
|
||
func (c *Cache) Load(filename string) ([]byte, error) {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
|
||
path := c.safePath(filename)
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("imagecache: file not found: %s", filename)
|
||
}
|
||
|
||
return os.ReadFile(path)
|
||
}
|
||
|
||
// LoadBase64 从缓存加载图片并返回 base64 编码字符串。
|
||
func (c *Cache) LoadBase64(filename string) (string, error) {
|
||
data, err := c.Load(filename)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return encodeBase64(data), nil
|
||
}
|
||
|
||
// LoadWithMIME 从缓存加载图片,返回 base64 和 MIME 类型。
|
||
func (c *Cache) LoadWithMIME(filename string) (string, string, error) {
|
||
data, err := c.Load(filename)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
mime := detectMIME(filename)
|
||
return encodeBase64(data), mime, nil
|
||
}
|
||
|
||
// Exists 检查文件是否存在。
|
||
func (c *Cache) Exists(filename string) bool {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
|
||
path := c.safePath(filename)
|
||
_, err := os.Stat(path)
|
||
return err == nil
|
||
}
|
||
|
||
// Delete 删除缓存中的图片。
|
||
func (c *Cache) Delete(filename string) error {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
path := c.safePath(filename)
|
||
return os.Remove(path)
|
||
}
|
||
|
||
// List 列出所有缓存的图片文件名。
|
||
func (c *Cache) List() ([]string, error) {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
|
||
entries, err := os.ReadDir(c.dir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var files []string
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
files = append(files, e.Name())
|
||
}
|
||
}
|
||
return files, nil
|
||
}
|
||
|
||
// Stats 返回缓存统计信息。
|
||
func (c *Cache) Stats() (count int, totalSize int64, err error) {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
|
||
entries, err := os.ReadDir(c.dir)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
info, err := e.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
count++
|
||
totalSize += info.Size()
|
||
}
|
||
return count, totalSize, nil
|
||
}
|
||
|
||
// Cleanup 手动触发清理。
|
||
func (c *Cache) Cleanup() error {
|
||
return c.cleanup()
|
||
}
|
||
|
||
// generateFilename 生成唯一文件名。
|
||
func (c *Cache) generateFilename(ext string) (string, error) {
|
||
b := make([]byte, 16)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "", err
|
||
}
|
||
timestamp := time.Now().Format("20060102-150405")
|
||
return fmt.Sprintf("%s-%s%s", timestamp, hex.EncodeToString(b), ext), nil
|
||
}
|
||
|
||
// safePath 安全地拼接路径,防止路径遍历攻击。
|
||
func (c *Cache) safePath(filename string) string {
|
||
// 清理文件名,移除路径分隔符
|
||
filename = filepath.Base(filename)
|
||
return filepath.Join(c.dir, filename)
|
||
}
|
||
|
||
// cleanup 清理过期和超限的图片。
|
||
func (c *Cache) cleanup() error {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
entries, err := os.ReadDir(c.dir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
now := time.Now()
|
||
var totalSize int64
|
||
type fileEntry struct {
|
||
name string
|
||
modTime time.Time
|
||
size int64
|
||
}
|
||
var files []fileEntry
|
||
|
||
// 收集文件信息
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
info, err := e.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
totalSize += info.Size()
|
||
files = append(files, fileEntry{
|
||
name: e.Name(),
|
||
modTime: info.ModTime(),
|
||
size: info.Size(),
|
||
})
|
||
}
|
||
|
||
// 删除过期文件
|
||
if c.maxAge > 0 {
|
||
for _, f := range files {
|
||
if now.Sub(f.modTime) > c.maxAge {
|
||
os.Remove(filepath.Join(c.dir, f.name))
|
||
totalSize -= f.size
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果超过大小限制,删除最旧的文件
|
||
if c.maxSize > 0 && totalSize > c.maxSize {
|
||
// 按修改时间排序(最旧在前)
|
||
for i := 0; i < len(files)-1; i++ {
|
||
for j := i + 1; j < len(files); j++ {
|
||
if files[i].modTime.After(files[j].modTime) {
|
||
files[i], files[j] = files[j], files[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, f := range files {
|
||
if totalSize <= c.maxSize {
|
||
break
|
||
}
|
||
os.Remove(filepath.Join(c.dir, f.name))
|
||
totalSize -= f.size
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// encodeBase64 编码 base64。
|
||
func encodeBase64(data []byte) string {
|
||
return base64.StdEncoding.EncodeToString(data)
|
||
}
|
||
|
||
// detectMIME 根据扩展名检测 MIME 类型。
|
||
func detectMIME(filename string) string {
|
||
ext := strings.ToLower(filepath.Ext(filename))
|
||
switch ext {
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".gif":
|
||
return "image/gif"
|
||
case ".webp":
|
||
return "image/webp"
|
||
default:
|
||
return "image/jpeg"
|
||
}
|
||
}
|