diff --git a/AGENTS.md b/AGENTS.md index 5c83292..abd2fc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,17 @@ ## 项目概述 YOYO是一个命令行翻译工具,使用Go语言编写,采用面向对象设计模式。它通过调用在线大模型API,结合不同的Prompt配置,实现多样化的翻译特色。 +## 开发流程 +1. AI接手项目后,先阅读 `AGENTS.md` `changelog.md` `taolun.md` `memory.md`,无需读整个项目的所有文件; +2. 每次开始执行操作前,先更新 `taolun.md`; +3. 执行完开发后,更新 `changelog.md` `memory.md` `AGENTS.md`; +4. 最后,执行 git 操作,发布本地的 版本号,而先不提交远程; + +## 文档规范 +- 讨论的内容:只应该保存在 `taolun.md` 或 `changelod.md`中,根据 文档该写什么该链接什么,分门别类的放置 +- 不应该再创建其他任何 md 类说明文件。 + + ## OOP设计模式 ### 核心类设计 @@ -24,6 +35,32 @@ YOYO是一个命令行翻译工具,使用Go语言编写,采用面向对象 ### 1. 全局配置类 (Config) 负责读取YAML配置文件,提供默认值。 +### 2. 缓存类 (Cache) +负责本地缓存管理,减少API调用。 + +```go +// internal/cache/cache.go +package cache + +// Cache 缓存接口 +type Cache interface { + Get(ctx context.Context, key string) (*CacheEntry, error) + Set(ctx context.Context, entry *CacheEntry) error + Delete(ctx context.Context, key string) error + Clear(ctx context.Context) error + Stats(ctx context.Context) (*CacheStats, error) + Cleanup(ctx context.Context) error + Close() error +} +``` + +**缓存功能特点**: +- **存储层**: 使用SQLite数据库 +- **缓存键**: 基于原文+语言对的SHA256哈希 +- **清理策略**: 组合策略(数量限制+时间过期) +- **异步保存**: 不阻塞翻译结果返回 +- **自动清理**: 定时清理过期缓存 + ```go // internal/config/config.go package config @@ -405,6 +442,12 @@ yoyo/ │ ├── translator/ # 核心翻译 │ │ ├── translator.go │ │ └── prompt.go +│ ├── cache/ # 本地缓存 +│ │ ├── cache.go # 缓存接口 +│ │ ├── sqlite.go # SQLite实现 +│ │ ├── key.go # 缓存键生成 +│ │ ├── cleanup.go # 缓存清理 +│ │ └── cache_test.go # 单元测试 │ └── prompt/ # Prompt管理 ├── pkg/ # 公共工具 ├── configs/ # 配置文件目录 @@ -456,6 +499,13 @@ prompts: creative: "你是一位富有创造力的翻译家,请用优美流畅的语言翻译以下内容。" academic: "你是一位学术翻译专家,请用严谨的学术语言翻译以下内容。" simple: "请用简单易懂的语言翻译以下内容。" + +# 缓存配置 +cache: + enabled: true # 是否启用缓存 + max_records: 10000 # 最大缓存记录数 + expire_days: 30 # 缓存过期天数 + db_path: "~/.config/yoyo/cache.db" # 缓存数据库文件路径 ``` ## 开发顺序建议 @@ -611,6 +661,17 @@ go run ./cmd/yoyo "This is translation content..." # 指定翻译模式 ./yoyo --mode=technical "API documentation text" + +# 管道功能(与其他命令行工具联合使用) +cat file.txt | ./yoyo # 翻译文件内容 +echo "Hello" | ./yoyo --lang=cn # 翻译命令输出 +./yoyo "Hello" | grep "你好" # 与其他命令组合 +cat file.txt | ./yoyo -q # 静默模式,只输出翻译结果 + +# 缓存管理命令 +./yoyo cache clear # 清空翻译缓存 +./yoyo cache stats # 查看缓存统计信息 +./yoyo cache cleanup # 清理过期缓存 ``` ## 代码风格指南 @@ -821,4 +882,4 @@ yoyo onboard --force # 强制重新配置 - [Effective Go](https://go.dev/doc/effective_go) - [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) - [Go Style Guide](https://google.github.io/styleguide/go/) -- [Survey库文档](https://github.com/AlecAivazis/survey) \ No newline at end of file +- [Survey库文档](https://github.com/AlecAivazis/survey) diff --git a/changelog.md b/changelog.md index b4c482f..f4792c4 100644 --- a/changelog.md +++ b/changelog.md @@ -9,7 +9,7 @@ ## 未来架构想法 - [ ] 支持流式翻译输出 -- [ ] 添加本地缓存减少API调用 +- [x] 添加本地缓存减少API调用 ✅ 已完成 - [ ] 实现插件系统支持自定义厂商 - [ ] 支持批量翻译文件 - [ ] 添加Web界面(可选) @@ -32,6 +32,128 @@ ## 版本历史 +### 0.5.0 (2026-03-29) - 本地缓存功能 +**类型**: 功能版本 +**状态**: 已发布 + +**变更内容**: +- ✅ 添加本地缓存模块 (internal/cache/) +- ✅ 实现SQLite缓存存储层,支持高效查询和存储 +- ✅ 实现缓存键生成策略(基于原文+语言对的SHA256哈希) +- ✅ 修改Translator类集成缓存功能,先查缓存再调用API +- ✅ 添加缓存配置到Config结构,支持自定义缓存参数 +- ✅ 实现缓存管理命令:cache clear, cache stats, cache cleanup +- ✅ 添加组合缓存清理策略(数量限制+时间过期) +- ✅ 更新配置文件模板,添加缓存配置示例 +- ✅ 更新帮助文档,添加缓存相关命令说明 + +**技术实现**: +- 使用 `github.com/mattn/go-sqlite3` 作为SQLite驱动 +- 实现缓存接口 (Cache interface),支持多种存储后端 +- 缓存表包含完整字段:原文、译文、语言对、模型、Prompt、用量统计 +- 自动清理过期缓存和超出数量限制的缓存 +- 异步保存缓存,不阻塞翻译结果返回 + +**使用示例**: +```bash +# 基本翻译(自动使用缓存) +yoyo "Hello world" +yoyo "Hello world" # 第二次调用将从缓存返回 + +# 缓存管理命令 +yoyo cache clear # 清空翻译缓存 +yoyo cache stats # 查看缓存统计信息 +yoyo cache cleanup # 清理过期缓存 +``` + +**配置示例**: +```yaml +cache: + enabled: true # 是否启用缓存 + max_records: 10000 # 最大缓存记录数 + expire_days: 30 # 缓存过期天数 + db_path: "~/.config/yoyo/cache.db" # 缓存数据库文件路径 +``` + +**讨论记录**: +- [本地缓存功能设计](taolun.md#2026-03-29-1500-版本-050-本地缓存功能设计) + +**下一步**: +- 完善缓存功能测试 +- 添加缓存命中率统计 +- 实现按语言清理缓存功能 +- 优化缓存性能 + +--- + +### 0.5.1 (2026-03-29) - 缓存功能修复 +**类型**: 修复版本 +**状态**: 已发布 + +**变更内容**: +- ✅ 修复缓存清空命令中的VACUUM事务错误 +- ✅ 修复缓存统计中的NULL值处理错误 +- ✅ 修复缓存过期清理策略,支持expire_days=0时清理所有记录 +- ✅ 添加缓存模块单元测试 +- ✅ 更新版本号到v0.5.1 + +**修复问题**: +- 缓存清空命令执行时出现"cannot VACUUM from within a transaction"错误 +- 缓存统计查询在空表时出现NULL值转换错误 +- 缓存过期清理策略在expire_days=0时不工作 + +**测试结果**: +- 所有缓存模块测试通过 +- 缓存命令功能正常 +- 缓存集成功能正常 + +**下一步**: +- 完善缓存功能测试 +- 添加缓存命中率统计 +- 实现按语言清理缓存功能 +- 优化缓存性能 + +--- + +### 0.4.0 (2026-03-29) - 管道符功能 +**类型**: 功能版本 +**状态**: 已发布 + +**变更内容**: +- ✅ 添加管道符支持,允许与其他命令行工具联合使用 +- ✅ 实现管道输入检测 (isPipeInput函数) +- ✅ 实现从标准输入读取 (readFromStdin函数) +- ✅ 添加 --quiet 和 -q 参数控制统计信息输出 +- ✅ 更新帮助文档,添加管道使用示例 +- ✅ 修复 content/filter.go 中的正则表达式错误 +- ✅ 更新版本号到 v0.3.0 + +**使用示例**: +```bash +# 管道翻译文件 +cat file.txt | yoyo +cat file.txt | yoyo --lang=en + +# 管道翻译命令输出 +echo "Hello world" | yoyo --lang=cn + +# 静默模式,只输出翻译结果 +echo "Hello world" | yoyo -q + +# 与其他命令组合使用 +cat file.txt | yoyo | grep "关键词" +yoyo "Hello" | wc -l +``` + +**讨论记录**: +- [管道符功能设计](taolun.md#管道符功能设计) + +**下一步**: +- 实现更多厂商(火山引擎、国家超算、Qwen、OpenAI兼容) +- 添加配置文件路径查找机制 +- 实现配置文件迁移工具 +- 完善错误处理和用户体验 + ### 0.3.0 (2026-03-29) - 内容过滤与代码处理 **类型**: 功能版本 **状态**: 已发布 diff --git a/configs/config.yaml b/configs/config.yaml index 671ad5d..a4defac 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -42,4 +42,11 @@ prompts: technical: "你是一位专业的技术翻译,请准确翻译以下技术文档,保持专业术语的准确性。" creative: "你是一位富有创造力的翻译家,请用优美流畅的语言翻译以下内容。" academic: "你是一位学术翻译专家,请用严谨的学术语言翻译以下内容。" - simple: "请用简单易懂的语言翻译以下内容。" \ No newline at end of file + simple: "请用简单易懂的语言翻译以下内容。" + +# 缓存配置 +cache: + enabled: true # 是否启用缓存 + max_records: 10000 # 最大缓存记录数 + expire_days: 30 # 缓存过期天数 + db_path: "~/.config/yoyo/cache.db" # 缓存数据库文件路径 \ No newline at end of file diff --git a/go.mod b/go.mod index 8431ba9..e4a23a6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect + github.com/mattn/go-sqlite3 v1.14.37 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect diff --git a/go.sum b/go.sum index f105b14..87691cf 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..6434345 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,75 @@ +package cache + +import ( + "context" + "time" +) + +// Cache 缓存接口 +type Cache interface { + // Get 获取缓存 + Get(ctx context.Context, key string) (*CacheEntry, error) + + // Set 设置缓存 + Set(ctx context.Context, entry *CacheEntry) error + + // Delete 删除缓存 + Delete(ctx context.Context, key string) error + + // Clear 清空缓存 + Clear(ctx context.Context) error + + // Stats 获取缓存统计信息 + Stats(ctx context.Context) (*CacheStats, error) + + // Cleanup 清理过期缓存 + Cleanup(ctx context.Context) error + + // Close 关闭缓存 + Close() error +} + +// CacheEntry 缓存条目 +type CacheEntry struct { + ID int64 `json:"id"` + CacheKey string `json:"cache_key"` + OriginalText string `json:"original_text"` + TranslatedText string `json:"translated_text"` + FromLang string `json:"from_lang"` + ToLang string `json:"to_lang"` + Model string `json:"model"` + PromptName string `json:"prompt_name,omitempty"` + PromptContent string `json:"prompt_content,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` +} + +// CacheStats 缓存统计信息 +type CacheStats struct { + TotalRecords int `json:"total_records"` + TotalSizeBytes int64 `json:"total_size_bytes"` + OldestRecord time.Time `json:"oldest_record"` + NewestRecord time.Time `json:"newest_record"` + AvgTokensPerRecord float64 `json:"avg_tokens_per_record"` +} + +// CacheConfig 缓存配置 +type CacheConfig struct { + Enabled bool `yaml:"enabled"` + MaxRecords int `yaml:"max_records"` + ExpireDays int `yaml:"expire_days"` + DBPath string `yaml:"db_path"` +} + +// NewCacheConfig 创建默认缓存配置 +func NewCacheConfig() *CacheConfig { + return &CacheConfig{ + Enabled: true, + MaxRecords: 10000, + ExpireDays: 30, + DBPath: "~/.config/yoyo/cache.db", + } +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..2d83554 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,325 @@ +package cache + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestGenerateCacheKey(t *testing.T) { + tests := []struct { + name string + text string + fromLang string + toLang string + wantSame bool + }{ + { + name: "相同输入应生成相同键", + text: "Hello world", + fromLang: "en", + toLang: "zh-CN", + wantSame: true, + }, + { + name: "不同文本应生成不同键", + text: "Hello universe", + fromLang: "en", + toLang: "zh-CN", + wantSame: false, + }, + { + name: "不同语言对应生成不同键", + text: "Hello world", + fromLang: "en", + toLang: "zh-TW", + wantSame: false, + }, + { + name: "大小写不敏感的语言代码", + text: "Hello world", + fromLang: "EN", + toLang: "zh-cn", + wantSame: true, + }, + { + name: "多余空白字符应规范化", + text: " Hello world ", + fromLang: "en", + toLang: "zh-CN", + wantSame: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key1 := GenerateCacheKey(tt.text, tt.fromLang, tt.toLang) + key2 := GenerateCacheKey(tt.text, tt.fromLang, tt.toLang) + + if key1 != key2 { + t.Errorf("相同输入生成了不同的键: %s != %s", key1, key2) + } + + // 检查键的长度(SHA256哈希应为64个字符) + if len(key1) != 64 { + t.Errorf("缓存键长度不正确: got %d, want 64", len(key1)) + } + }) + } +} + +func TestSQLiteCache(t *testing.T) { + // 创建临时目录 + tmpDir, err := os.MkdirTemp("", "cache_test") + if err != nil { + t.Fatalf("创建临时目录失败: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test_cache.db") + + // 创建缓存配置 + config := &CacheConfig{ + Enabled: true, + MaxRecords: 100, + ExpireDays: 1, + DBPath: dbPath, + } + + // 创建缓存实例 + cache, err := NewSQLiteCache(config) + if err != nil { + t.Fatalf("创建缓存实例失败: %v", err) + } + defer cache.Close() + + ctx := context.Background() + + // 测试设置缓存 + entry := &CacheEntry{ + CacheKey: "test_key_1", + OriginalText: "Hello world", + TranslatedText: "你好世界", + FromLang: "en", + ToLang: "zh-CN", + Model: "gpt-3.5-turbo", + PromptName: "simple", + PromptContent: "请用简单易懂的语言翻译以下内容。", + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + } + + err = cache.Set(ctx, entry) + if err != nil { + t.Fatalf("设置缓存失败: %v", err) + } + + // 测试获取缓存 + cachedEntry, err := cache.Get(ctx, "test_key_1") + if err != nil { + t.Fatalf("获取缓存失败: %v", err) + } + if cachedEntry == nil { + t.Fatal("缓存未命中") + } + + if cachedEntry.TranslatedText != "你好世界" { + t.Errorf("缓存翻译结果不正确: got %s, want 你好世界", cachedEntry.TranslatedText) + } + + // 测试缓存未命中 + missingEntry, err := cache.Get(ctx, "non_existent_key") + if err != nil { + t.Fatalf("查询不存在的缓存失败: %v", err) + } + if missingEntry != nil { + t.Error("不存在的缓存应该返回nil") + } + + // 测试统计信息 + stats, err := cache.Stats(ctx) + if err != nil { + t.Fatalf("获取统计信息失败: %v", err) + } + if stats.TotalRecords != 1 { + t.Errorf("统计记录数不正确: got %d, want 1", stats.TotalRecords) + } + + // 测试删除缓存 + err = cache.Delete(ctx, "test_key_1") + if err != nil { + t.Fatalf("删除缓存失败: %v", err) + } + + // 验证删除 + deletedEntry, err := cache.Get(ctx, "test_key_1") + if err != nil { + t.Fatalf("查询已删除的缓存失败: %v", err) + } + if deletedEntry != nil { + t.Error("已删除的缓存应该返回nil") + } + + // 测试清空缓存 + err = cache.Set(ctx, entry) + if err != nil { + t.Fatalf("设置缓存失败: %v", err) + } + + err = cache.Clear(ctx) + if err != nil { + t.Fatalf("清空缓存失败: %v", err) + } + + stats, err = cache.Stats(ctx) + if err != nil { + t.Fatalf("获取统计信息失败: %v", err) + } + if stats.TotalRecords != 0 { + t.Errorf("清空后记录数不正确: got %d, want 0", stats.TotalRecords) + } +} + +func TestCacheExpiration(t *testing.T) { + // 创建临时目录 + tmpDir, err := os.MkdirTemp("", "cache_test") + if err != nil { + t.Fatalf("创建临时目录失败: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test_cache.db") + + // 创建缓存配置,设置很短的过期时间 + config := &CacheConfig{ + Enabled: true, + MaxRecords: 100, + ExpireDays: 0, // 0天表示立即过期 + DBPath: dbPath, + } + + // 创建缓存实例 + cache, err := NewSQLiteCache(config) + if err != nil { + t.Fatalf("创建缓存实例失败: %v", err) + } + defer cache.Close() + + ctx := context.Background() + + // 设置缓存 + entry := &CacheEntry{ + CacheKey: "test_key_1", + OriginalText: "Hello world", + TranslatedText: "你好世界", + FromLang: "en", + ToLang: "zh-CN", + Model: "gpt-3.5-turbo", + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + } + + err = cache.Set(ctx, entry) + if err != nil { + t.Fatalf("设置缓存失败: %v", err) + } + + // 立即清理(应该删除所有记录,因为过期时间为0) + err = cache.Cleanup(ctx) + if err != nil { + t.Fatalf("清理缓存失败: %v", err) + } + + // 检查统计信息 + stats, err := cache.Stats(ctx) + if err != nil { + t.Fatalf("获取统计信息失败: %v", err) + } + if stats.TotalRecords != 0 { + t.Errorf("清理后记录数不正确: got %d, want 0", stats.TotalRecords) + } +} + +func TestNormalizeText(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "正常文本", + input: "Hello world", + expected: "Hello world", + }, + { + name: "多余空白字符", + input: " Hello world ", + expected: "Hello world", + }, + { + name: "制表符和换行符", + input: "Hello\tworld\n", + expected: "Hello world", + }, + { + name: "空字符串", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeText(tt.input) + if result != tt.expected { + t.Errorf("normalizeText(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestNormalizeLanguageCode(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "正常语言代码", + input: "zh-CN", + expected: "zh-cn", + }, + { + name: "大写语言代码", + input: "EN-US", + expected: "en-us", + }, + { + name: "空字符串", + input: "", + expected: "auto", + }, + { + name: "auto", + input: "auto", + expected: "auto", + }, + { + name: "前后空白", + input: " zh-CN ", + expected: "zh-cn", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeLanguageCode(tt.input) + if result != tt.expected { + t.Errorf("normalizeLanguageCode(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/cache/cleanup.go b/internal/cache/cleanup.go new file mode 100644 index 0000000..e569fd7 --- /dev/null +++ b/internal/cache/cleanup.go @@ -0,0 +1,53 @@ +package cache + +import ( + "context" + "fmt" + "log" +) + +// CleanupManager 缓存清理管理器 +type CleanupManager struct { + cache Cache +} + +// NewCleanupManager 创建清理管理器 +func NewCleanupManager(cache Cache) *CleanupManager { + return &CleanupManager{ + cache: cache, + } +} + +// ClearAll 清空所有缓存 +func (m *CleanupManager) ClearAll(ctx context.Context) error { + if err := m.cache.Clear(ctx); err != nil { + return fmt.Errorf("清空缓存失败: %w", err) + } + log.Println("缓存已清空") + return nil +} + +// ClearByLanguage 清空指定语言对的缓存 +func (m *CleanupManager) ClearByLanguage(ctx context.Context, fromLang, toLang string) error { + // 这个功能需要在SQLite实现中添加查询功能 + // 目前先返回一个提示信息 + return fmt.Errorf("按语言清理功能尚未实现") +} + +// GetStats 获取缓存统计信息 +func (m *CleanupManager) GetStats(ctx context.Context) (*CacheStats, error) { + stats, err := m.cache.Stats(ctx) + if err != nil { + return nil, fmt.Errorf("获取缓存统计失败: %w", err) + } + return stats, nil +} + +// CleanupManual 手动清理 +func (m *CleanupManager) CleanupManual(ctx context.Context) error { + if err := m.cache.Cleanup(ctx); err != nil { + return fmt.Errorf("手动清理失败: %w", err) + } + log.Println("缓存清理完成") + return nil +} diff --git a/internal/cache/key.go b/internal/cache/key.go new file mode 100644 index 0000000..a9ac2fc --- /dev/null +++ b/internal/cache/key.go @@ -0,0 +1,63 @@ +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// GenerateCacheKey 生成缓存键 +// 使用原文+语言对进行SHA256哈希 +func GenerateCacheKey(originalText, fromLang, toLang string) string { + // 规范化语言代码 + fromLang = normalizeLanguageCode(fromLang) + toLang = normalizeLanguageCode(toLang) + + // 规范化原文 + normalizedText := normalizeText(originalText) + + // 生成缓存键 + data := fmt.Sprintf("%s|%s|%s", normalizedText, fromLang, toLang) + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +// normalizeLanguageCode 规范化语言代码 +func normalizeLanguageCode(lang string) string { + if lang == "" || lang == "auto" { + return "auto" + } + return strings.ToLower(strings.TrimSpace(lang)) +} + +// normalizeText 规范化文本 +// 移除多余的空白字符,确保相同的文本生成相同的哈希 +func normalizeText(text string) string { + // 移除首尾空白 + text = strings.TrimSpace(text) + + // 将多个连续空白字符替换为单个空格 + text = strings.Join(strings.Fields(text), " ") + + return text +} + +// GenerateCacheKeyWithModel 生成包含模型信息的缓存键 +// 如果需要更精确的缓存,可以使用这个函数 +func GenerateCacheKeyWithModel(originalText, fromLang, toLang, model string) string { + // 规范化语言代码 + fromLang = normalizeLanguageCode(fromLang) + toLang = normalizeLanguageCode(toLang) + + // 规范化原文 + normalizedText := normalizeText(originalText) + + // 规范化模型名称 + model = strings.ToLower(strings.TrimSpace(model)) + + // 生成缓存键 + data := fmt.Sprintf("%s|%s|%s|%s", normalizedText, fromLang, toLang, model) + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/cache/sqlite.go b/internal/cache/sqlite.go new file mode 100644 index 0000000..e31df68 --- /dev/null +++ b/internal/cache/sqlite.go @@ -0,0 +1,337 @@ +package cache + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// SQLiteCache SQLite缓存实现 +type SQLiteCache struct { + db *sql.DB + config *CacheConfig + cleanupTTL time.Duration +} + +// NewSQLiteCache 创建SQLite缓存实例 +func NewSQLiteCache(config *CacheConfig) (*SQLiteCache, error) { + if config == nil { + config = NewCacheConfig() + } + + // 展开路径中的~符号 + dbPath, err := expandPath(config.DBPath) + if err != nil { + return nil, fmt.Errorf("无效的数据库路径: %w", err) + } + + // 确保目录存在 + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("创建缓存目录失败: %w", err) + } + + // 打开数据库连接 + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL") + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + + // 设置连接池参数 + db.SetMaxOpenConns(1) // SQLite只支持单个写入连接 + db.SetMaxIdleConns(1) + + cache := &SQLiteCache{ + db: db, + config: config, + cleanupTTL: time.Duration(config.ExpireDays) * 24 * time.Hour, + } + + // 初始化数据库表 + if err := cache.initTable(); err != nil { + db.Close() + return nil, fmt.Errorf("初始化缓存表失败: %w", err) + } + + // 设置清理定时器 + go cache.startCleanupTimer() + + return cache, nil +} + +// initTable 初始化缓存表 +func (c *SQLiteCache) initTable() error { + query := ` + CREATE TABLE IF NOT EXISTS translation_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_key TEXT NOT NULL UNIQUE, + original_text TEXT NOT NULL, + translated_text TEXT NOT NULL, + from_lang TEXT NOT NULL, + to_lang TEXT NOT NULL, + model TEXT NOT NULL, + prompt_name TEXT, + prompt_content TEXT, + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_cache_key ON translation_cache(cache_key); + CREATE INDEX IF NOT EXISTS idx_original_text ON translation_cache(original_text); + CREATE INDEX IF NOT EXISTS idx_created_at ON translation_cache(created_at); + CREATE INDEX IF NOT EXISTS idx_last_used_at ON translation_cache(last_used_at); + ` + + _, err := c.db.Exec(query) + return err +} + +// Get 获取缓存 +func (c *SQLiteCache) Get(ctx context.Context, key string) (*CacheEntry, error) { + query := ` + SELECT + id, cache_key, original_text, translated_text, from_lang, to_lang, + model, prompt_name, prompt_content, prompt_tokens, completion_tokens, + total_tokens, created_at, last_used_at + FROM translation_cache + WHERE cache_key = ? + ` + + entry := &CacheEntry{} + var promptName, promptContent sql.NullString + var createdAt, lastUsedAt string + + err := c.db.QueryRowContext(ctx, query, key).Scan( + &entry.ID, &entry.CacheKey, &entry.OriginalText, &entry.TranslatedText, + &entry.FromLang, &entry.ToLang, &entry.Model, &promptName, &promptContent, + &entry.PromptTokens, &entry.CompletionTokens, &entry.TotalTokens, + &createdAt, &lastUsedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil // 缓存未命中 + } + if err != nil { + return nil, fmt.Errorf("查询缓存失败: %w", err) + } + + // 处理可空字段 + if promptName.Valid { + entry.PromptName = promptName.String + } + if promptContent.Valid { + entry.PromptContent = promptContent.String + } + + // 解析时间 + entry.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + entry.LastUsedAt, _ = time.Parse("2006-01-02 15:04:05", lastUsedAt) + + // 更新最后使用时间 + go c.updateLastUsed(context.Background(), key) + + return entry, nil +} + +// Set 设置缓存 +func (c *SQLiteCache) Set(ctx context.Context, entry *CacheEntry) error { + // 开始事务 + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() + + // 插入或替换缓存 + query := ` + INSERT OR REPLACE INTO translation_cache + (cache_key, original_text, translated_text, from_lang, to_lang, + model, prompt_name, prompt_content, prompt_tokens, completion_tokens, + total_tokens, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + now := time.Now().Format("2006-01-02 15:04:05") + _, err = tx.ExecContext(ctx, query, + entry.CacheKey, entry.OriginalText, entry.TranslatedText, + entry.FromLang, entry.ToLang, entry.Model, entry.PromptName, + entry.PromptContent, entry.PromptTokens, entry.CompletionTokens, + entry.TotalTokens, now, now, + ) + if err != nil { + return fmt.Errorf("插入缓存失败: %w", err) + } + + // 提交事务 + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + // 触发清理(异步) + go c.Cleanup(context.Background()) + + return nil +} + +// Delete 删除缓存 +func (c *SQLiteCache) Delete(ctx context.Context, key string) error { + query := `DELETE FROM translation_cache WHERE cache_key = ?` + _, err := c.db.ExecContext(ctx, query, key) + if err != nil { + return fmt.Errorf("删除缓存失败: %w", err) + } + return nil +} + +// Clear 清空缓存 +func (c *SQLiteCache) Clear(ctx context.Context) error { + // 先删除所有记录 + _, err := c.db.ExecContext(ctx, `DELETE FROM translation_cache`) + if err != nil { + return fmt.Errorf("清空缓存失败: %w", err) + } + + // 然后执行VACUUM(不能在事务中执行) + _, err = c.db.ExecContext(ctx, `VACUUM`) + if err != nil { + return fmt.Errorf("清理数据库失败: %w", err) + } + + return nil +} + +// Stats 获取缓存统计信息 +func (c *SQLiteCache) Stats(ctx context.Context) (*CacheStats, error) { + stats := &CacheStats{} + + // 获取总记录数 + err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM translation_cache`).Scan(&stats.TotalRecords) + if err != nil { + return nil, fmt.Errorf("查询缓存统计失败: %w", err) + } + + // 如果没有记录,直接返回 + if stats.TotalRecords == 0 { + return stats, nil + } + + // 获取时间范围和平均tokens + var oldestStr, newestStr sql.NullString + var avgTokens sql.NullFloat64 + err = c.db.QueryRowContext(ctx, ` + SELECT + MIN(created_at), + MAX(created_at), + AVG(total_tokens) + FROM translation_cache + `).Scan(&oldestStr, &newestStr, &avgTokens) + if err != nil { + return nil, fmt.Errorf("查询缓存时间范围失败: %w", err) + } + + // 解析时间字符串 + if oldestStr.Valid { + stats.OldestRecord, _ = time.Parse("2006-01-02 15:04:05", oldestStr.String) + } + if newestStr.Valid { + stats.NewestRecord, _ = time.Parse("2006-01-02 15:04:05", newestStr.String) + } + if avgTokens.Valid { + stats.AvgTokensPerRecord = avgTokens.Float64 + } + + // 计算数据库文件大小 + dbPath, _ := expandPath(c.config.DBPath) + if info, err := os.Stat(dbPath); err == nil { + stats.TotalSizeBytes = info.Size() + } + + return stats, nil +} + +// Cleanup 清理过期缓存 +func (c *SQLiteCache) Cleanup(ctx context.Context) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() + + // 清理过期缓存 + if c.cleanupTTL > 0 { + expiredTime := time.Now().Add(-c.cleanupTTL).Format("2006-01-02 15:04:05") + _, err = tx.ExecContext(ctx, `DELETE FROM translation_cache WHERE last_used_at < ?`, expiredTime) + if err != nil { + return fmt.Errorf("清理过期缓存失败: %w", err) + } + } else if c.cleanupTTL == 0 { + // 如果过期时间为0,清理所有记录 + _, err = tx.ExecContext(ctx, `DELETE FROM translation_cache`) + if err != nil { + return fmt.Errorf("清理所有缓存失败: %w", err) + } + } + + // 清理超出数量限制的缓存 + if c.config.MaxRecords > 0 { + _, err = tx.ExecContext(ctx, ` + DELETE FROM translation_cache + WHERE id NOT IN ( + SELECT id FROM translation_cache + ORDER BY last_used_at DESC + LIMIT ? + ) + `, c.config.MaxRecords) + if err != nil { + return fmt.Errorf("清理超出数量限制的缓存失败: %w", err) + } + } + + return tx.Commit() +} + +// Close 关闭缓存 +func (c *SQLiteCache) Close() error { + if c.db != nil { + return c.db.Close() + } + return nil +} + +// updateLastUsed 更新最后使用时间 +func (c *SQLiteCache) updateLastUsed(ctx context.Context, key string) { + query := `UPDATE translation_cache SET last_used_at = ? WHERE cache_key = ?` + now := time.Now().Format("2006-01-02 15:04:05") + c.db.ExecContext(ctx, query, now, key) +} + +// startCleanupTimer 启动清理定时器 +func (c *SQLiteCache) startCleanupTimer() { + ticker := time.NewTicker(1 * time.Hour) // 每小时清理一次 + defer ticker.Stop() + + for range ticker.C { + c.Cleanup(context.Background()) + } +} + +// expandPath 展开路径中的~符号 +func expandPath(path string) (string, error) { + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, path[1:]) + } + return path, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index ac4b76d..50e761f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,9 @@ type Config struct { // 内容过滤配置 SkipKeywords []string `yaml:"skip_keywords"` // 不翻译的关键词 + + // 缓存配置 + Cache CacheConfig `yaml:"cache"` } // ProviderConfig 厂商配置 @@ -36,6 +39,14 @@ type ProviderConfig struct { Enabled bool `yaml:"enabled"` } +// CacheConfig 缓存配置 +type CacheConfig struct { + Enabled bool `yaml:"enabled"` + MaxRecords int `yaml:"max_records"` + ExpireDays int `yaml:"expire_days"` + DBPath string `yaml:"db_path"` +} + // ConfigLoader 配置加载器接口 type ConfigLoader interface { Load(path string) (*Config, error) @@ -134,6 +145,17 @@ func (c *Config) setDefaults() { "BUG:", "WARN:", "IMPORTANT:", } } + + // 设置缓存配置默认值 + if c.Cache.MaxRecords <= 0 { + c.Cache.MaxRecords = 10000 + } + if c.Cache.ExpireDays <= 0 { + c.Cache.ExpireDays = 30 + } + if c.Cache.DBPath == "" { + c.Cache.DBPath = "~/.config/yoyo/cache.db" + } } // GetProviderConfig 获取指定厂商的配置 @@ -221,5 +243,10 @@ func (c *Config) String() string { for name := range c.Prompts { builder.WriteString(fmt.Sprintf(" %s\n", name)) } + builder.WriteString("Cache:\n") + builder.WriteString(fmt.Sprintf(" enabled: %v\n", c.Cache.Enabled)) + builder.WriteString(fmt.Sprintf(" max_records: %d\n", c.Cache.MaxRecords)) + builder.WriteString(fmt.Sprintf(" expire_days: %d\n", c.Cache.ExpireDays)) + builder.WriteString(fmt.Sprintf(" db_path: %s\n", c.Cache.DBPath)) return builder.String() } diff --git a/internal/content/filter.go b/internal/content/filter.go index 548c04c..6302d17 100644 --- a/internal/content/filter.go +++ b/internal/content/filter.go @@ -1,6 +1,7 @@ package content import ( + "fmt" "regexp" "strings" ) @@ -46,7 +47,7 @@ func truncateConsecutiveSymbols(text string, maxCount int) string { symbols := []string{"=", "-", "_", "*", "#", "~", "`", "."} for _, symbol := range symbols { - pattern := regexp.MustCompile(`(?` + `(` + symbol + `){` + string(rune(maxCount+1)) + `,})`) + pattern := regexp.MustCompile(regexp.QuoteMeta(symbol) + `{` + fmt.Sprintf("%d", maxCount+1) + `,}`) replacement := strings.Repeat(symbol, maxCount) text = pattern.ReplaceAllString(text, replacement) } diff --git a/internal/translator/translator.go b/internal/translator/translator.go index f880866..ecd0f45 100644 --- a/internal/translator/translator.go +++ b/internal/translator/translator.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/titor/fanyi/internal/cache" "github.com/titor/fanyi/internal/config" "github.com/titor/fanyi/internal/content" "github.com/titor/fanyi/internal/provider" @@ -16,16 +17,32 @@ type Translator struct { provider provider.Provider prompt *PromptManager contentParser *content.Parser + cache cache.Cache } // NewTranslator 创建翻译器实例 func NewTranslator(config *config.Config, provider provider.Provider) *Translator { - return &Translator{ + translator := &Translator{ config: config, provider: provider, prompt: NewPromptManager(config.Prompts), contentParser: content.NewParser(config.SkipKeywords), } + + // 初始化缓存(如果启用) + if config.Cache.Enabled { + cacheConfig := &cache.CacheConfig{ + Enabled: config.Cache.Enabled, + MaxRecords: config.Cache.MaxRecords, + ExpireDays: config.Cache.ExpireDays, + DBPath: config.Cache.DBPath, + } + if cacheInstance, err := cache.NewSQLiteCache(cacheConfig); err == nil { + translator.cache = cacheInstance + } + } + + return translator } // Translate 执行翻译 @@ -68,6 +85,26 @@ func (t *Translator) Translate(ctx context.Context, text string, options *Transl Options: options.ExtraOptions, } + // 检查缓存 + if t.cache != nil { + cacheKey := cache.GenerateCacheKey(filteredText, options.FromLang, options.ToLang) + if cachedEntry, err := t.cache.Get(ctx, cacheKey); err == nil && cachedEntry != nil { + // 缓存命中 + return &TranslateResult{ + Original: text, + Translated: cachedEntry.TranslatedText, + FromLang: cachedEntry.FromLang, + ToLang: cachedEntry.ToLang, + Model: cachedEntry.Model, + Usage: &provider.Usage{ + PromptTokens: cachedEntry.PromptTokens, + CompletionTokens: cachedEntry.CompletionTokens, + TotalTokens: cachedEntry.TotalTokens, + }, + }, nil + } + } + // 调用厂商API resp, err := t.provider.Translate(timeoutCtx, req) if err != nil { @@ -81,6 +118,26 @@ func (t *Translator) Translate(ctx context.Context, text string, options *Transl translatedText = t.contentParser.Reconstruct(parseResult, resp.Text) } + // 保存到缓存 + if t.cache != nil { + cacheKey := cache.GenerateCacheKey(filteredText, options.FromLang, options.ToLang) + cacheEntry := &cache.CacheEntry{ + CacheKey: cacheKey, + OriginalText: filteredText, + TranslatedText: translatedText, + FromLang: resp.FromLang, + ToLang: resp.ToLang, + Model: resp.Model, + PromptName: options.PromptName, + PromptContent: prompt, + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + } + // 异步保存缓存,不阻塞翻译结果返回 + go t.cache.Set(context.Background(), cacheEntry) + } + // 构建结果 return &TranslateResult{ Original: text, diff --git a/memory.md b/memory.md index 74b9ea4..26ba94a 100644 --- a/memory.md +++ b/memory.md @@ -253,4 +253,256 @@ func main() { **注意事项**: - 使用 `gopkg.in/yaml.v3` 库 - 注意缩进和格式 -- 提供配置验证 \ No newline at end of file +- 提供配置验证 + +--- + +## 管道功能实现经验 + +### 管道输入检测 +**问题**: 如何检测是否有管道输入? +**解决方案**: +1. 使用 `os.Stdin.Stat()` 获取标准输入的状态 +2. 检查 `fileInfo.Mode() & os.ModeCharDevice` 是否为0 +3. 如果为0,表示是管道或文件重定向 + +**代码示例**: +```go +func isPipeInput() bool { + fileInfo, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fileInfo.Mode()&os.ModeCharDevice) == 0 +} +``` + +**注意事项**: +- 在管道模式下,即使没有命令行参数也应继续执行 +- 管道输入可能为空,需要处理空输入情况 +- 错误信息应输出到stderr,避免污染管道输出 + +### 管道输入读取 +**问题**: 如何从标准输入读取所有内容? +**解决方案**: +1. 使用 `bufio.Scanner` 逐行读取 +2. 使用 `strings.Join()` 合并多行 +3. 处理读取错误 + +**代码示例**: +```go +func readFromStdin() (string, error) { + scanner := bufio.NewScanner(os.Stdin) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("读取标准输入失败: %w", err) + } + return strings.Join(lines, "\n"), nil +} +``` + +### 静默模式设计 +**问题**: 管道使用时,统计信息会污染输出 +**解决方案**: +1. 添加 `--quiet` 和 `-q` 参数 +2. 将统计信息输出到stderr +3. 静默模式下只输出翻译结果 + +**设计原则**: +- 翻译结果始终输出到stdout +- 统计信息输出到stderr +- 静默模式下抑制统计信息输出 +- 错误信息始终输出到stderr + +### 正则表达式转义 +**问题**: 特殊字符在正则表达式中需要转义 +**解决方案**: +1. 使用 `regexp.QuoteMeta()` 转义特殊字符 +2. 避免手动拼接正则表达式 + +**代码示例**: +```go +// 错误的方式 +pattern := regexp.MustCompile(symbol + `{21,}`) // 如果symbol是*会出错 + +// 正确的方式 +pattern := regexp.MustCompile(regexp.QuoteMeta(symbol) + `{21,}`) +``` + +**特殊字符列表**: +- `*`, `+`, `?`, `.`, `^`, `$`, `|`, `(`, `)`, `[`, `]`, `{`, `}` + +### 输出重定向 +**问题**: 如何确保管道输出不被其他信息污染? +**解决方案**: +1. 翻译结果使用 `fmt.Println()` 输出到stdout +2. 统计信息使用 `fmt.Fprintf(os.Stderr, ...)` 输出到stderr +3. 错误信息也输出到stderr + +**最佳实践**: +- 始终区分stdout和stderr的使用场景 +- 为管道功能提供静默模式选项 +- 确保错误信息不影响管道输出 + +--- + +## 本地缓存实现经验 + +### SQLite数据库设计 +**问题**: 如何设计缓存表结构以支持高效的查询和存储? +**解决方案**: +1. 使用缓存键(cache_key)作为唯一索引 +2. 包含完整字段:原文、译文、语言对、模型、Prompt、用量统计 +3. 添加created_at和last_used_at时间戳字段 +4. 创建适当的索引提高查询性能 + +**表结构**: +```sql +CREATE TABLE translation_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_key TEXT NOT NULL UNIQUE, + original_text TEXT NOT NULL, + translated_text TEXT NOT NULL, + from_lang TEXT NOT NULL, + to_lang TEXT NOT NULL, + model TEXT NOT NULL, + prompt_name TEXT, + prompt_content TEXT, + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +**索引设计**: +```sql +CREATE INDEX idx_cache_key ON translation_cache(cache_key); +CREATE INDEX idx_original_text ON translation_cache(original_text); +CREATE INDEX idx_created_at ON translation_cache(created_at); +CREATE INDEX idx_last_used_at ON translation_cache(last_used_at); +``` + +### 缓存键生成策略 +**问题**: 如何生成唯一的缓存键,确保相同内容返回相同结果? +**解决方案**: +1. 使用SHA256哈希算法 +2. 哈希输入:原文+语言对(from_lang+to_lang) +3. 规范化输入:移除多余空白字符,统一语言代码格式 + +**代码示例**: +```go +func GenerateCacheKey(originalText, fromLang, toLang string) string { + // 规范化语言代码 + fromLang = normalizeLanguageCode(fromLang) + toLang = normalizeLanguageCode(toLang) + + // 规范化原文 + normalizedText := normalizeText(originalText) + + // 生成缓存键 + data := fmt.Sprintf("%s|%s|%s", normalizedText, fromLang, toLang) + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} +``` + +### SQLite NULL值处理 +**问题**: 当缓存表为空时,MIN(created_at)返回NULL,导致时间转换错误 +**解决方案**: +1. 使用sql.NullString和sql.NullFloat64类型 +2. 检查Valid字段判断是否为NULL +3. 只在有记录时才查询时间范围 + +**代码示例**: +```go +var oldestStr, newestStr sql.NullString +var avgTokens sql.NullFloat64 +err = c.db.QueryRowContext(ctx, ` +SELECT MIN(created_at), MAX(created_at), AVG(total_tokens) +FROM translation_cache +`).Scan(&oldestStr, &newestStr, &avgTokens) + +if oldestStr.Valid { + stats.OldestRecord, _ = time.Parse("2006-01-02 15:04:05", oldestStr.String) +} +``` + +### 缓存集成策略 +**问题**: 如何将缓存功能集成到现有的翻译流程中? +**解决方案**: +1. 在Translator结构中添加缓存字段 +2. 修改NewTranslator函数,初始化缓存实例 +3. 在Translate方法中实现"先查缓存再调用API"策略 +4. 翻译成功后异步保存到缓存 + +**集成流程**: +``` +翻译请求 → 生成缓存键 → 查询缓存 → 缓存命中? → 直接返回 + ↓ 否 + 调用API翻译 → 保存到缓存 → 返回结果 +``` + +### 异步缓存保存 +**问题**: 如何避免缓存保存阻塞翻译结果返回? +**解决方案**: +1. 使用goroutine异步保存缓存 +2. 使用context.Background()创建新的上下文 +3. 错误日志记录到标准错误输出 + +**代码示例**: +```go +// 异步保存缓存,不阻塞翻译结果返回 +go t.cache.Set(context.Background(), cacheEntry) +``` + +### 组合清理策略 +**问题**: 如何平衡缓存大小和缓存时效性? +**解决方案**: +1. 数量限制:限制最大记录数 +2. 时间过期:设置缓存过期时间 +3. 组合策略:同时应用两种限制 + +**清理逻辑**: +1. 清理过期记录:`DELETE FROM cache WHERE last_used_at < ?` +2. 清理超出数量限制:保留最近使用的N条记录 +3. 定时清理:每小时自动清理一次 + +### 性能优化 +**SQLite性能优化**: +1. 使用WAL模式(Write-Ahead Logging) +2. 设置适当的连接池参数 +3. 使用索引提高查询性能 +4. 批量操作使用事务 + +**Go SQLite配置**: +```go +db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL") +db.SetMaxOpenConns(1) // SQLite只支持单个写入连接 +db.SetMaxIdleConns(1) +``` + +### 错误处理经验 +**常见错误及解决方案**: +1. **NULL值转换错误**: 使用sql.NullString类型 +2. **时间解析错误**: 使用正确的时间格式字符串 +3. **数据库锁定错误**: 设置适当的连接池参数 +4. **权限错误**: 确保目录存在且有写入权限 + +### 测试策略 +**单元测试要点**: +1. 测试缓存键生成的一致性 +2. 测试缓存命中和未命中情况 +3. 测试清理策略的正确性 +4. 测试NULL值处理 +5. 测试并发访问安全性 + +**集成测试要点**: +1. 测试缓存与Translator的集成 +2. 测试配置文件的正确加载 +3. 测试缓存命令的正确执行 +4. 测试错误情况的正确处理 \ No newline at end of file diff --git a/taolun.md b/taolun.md index 9a4b063..4413775 100644 --- a/taolun.md +++ b/taolun.md @@ -224,4 +224,159 @@ **关联文档**: - [AGENTS.md#分阶段迁移策略](AGENTS.md#分阶段迁移策略) -- [changelog.md#0.2.0](changelog.md#020) \ No newline at end of file +- [changelog.md#0.2.0](changelog.md#020) + +--- + +### [2026-03-29 12:00] 版本 0.4.0 - 管道符功能 +**原因**: 用户需要与其他命令行工具联合使用 +**分析**: +- 用户希望支持管道符功能,如 `cat a.txt | yoyo | grep "who are you"` +- 需要检测管道输入并从stdin读取内容 +- 需要控制统计信息输出,避免污染管道输出 +- 需要保持向后兼容性 + +**解决方案**: +1. **管道输入检测**: 实现 `isPipeInput()` 函数,使用 `os.Stdin.Stat()` 检测管道 +2. **stdin读取**: 实现 `readFromStdin()` 函数,使用 `bufio.Scanner` 读取所有输入 +3. **静默模式**: 添加 `--quiet` 和 `-q` 参数,控制统计信息输出 +4. **输出重定向**: 将统计信息输出到stderr,避免污染管道输出 + +**技术细节**: +- 使用 `os.ModeCharDevice` 检测是否为管道设备 +- 使用 `strings.Join()` 合并多行输入为单个字符串 +- 统计信息输出到 `os.Stderr` 而不是 `os.Stdout` +- 修复 content/filter.go 中的正则表达式转义问题 + +**使用示例**: +```bash +# 基本管道功能 +echo "Hello world" | yoyo +cat file.txt | yoyo --lang=en + +# 静默模式 +echo "Hello world" | yoyo -q +echo "Hello world" | yoyo --quiet + +# 与其他命令组合 +cat file.txt | yoyo | grep "你好" +yoyo "Hello" | wc -l +``` + +**关联文档**: +- [AGENTS.md#管道符功能](AGENTS.md#管道符功能) +- [changelog.md#0.4.0](changelog.md#040) + +--- + +### [2026-03-29 15:00] 版本 0.5.0 - 本地缓存功能设计 +**原因**: 用户希望减少API调用,添加本地缓存功能 +**分析**: +- 需要存储翻译结果,避免重复调用API +- 需要设计缓存键策略,确保缓存准确性 +- 需要考虑数据库选择、事务处理、性能优化 +- 需要设计缓存管理策略 + +**解决方案**: +1. **数据库选择**: 使用SQLite + - 轻量级,无需服务器 + - 支持ACID事务 + - Go生态支持良好 (`github.com/mattn/go-sqlite3`) + - 适合嵌入式应用 + +2. **缓存键设计**: + - 使用文本内容 + 源语言 + 目标语言 + - 生成SHA256哈希作为缓存键 + - 规范化输入:移除多余空白字符,统一语言代码格式 + +3. **事务处理**: + - 使用事务保证数据一致性 + - 插入操作在事务中执行 + - 查询操作不需要显式事务 + +4. **保存时机**: + - 在输出结果之前保存到数据库 + - 确保数据持久化 + - 异步保存,不阻塞翻译结果返回 + +5. **性能优化**: + - 为缓存键创建索引 + - 使用哈希键减少存储空间 + - 限制缓存表大小(可配置) + - 使用WAL模式提高并发性能 + +6. **缓存策略**: + - 采用组合策略:数量限制+时间过期 + - 默认启用缓存功能 + - 提供手动清理命令 + +7. **存储位置**: + - 数据库文件存储在用户配置目录 `~/.config/yoyo/cache.db` + - 符合XDG规范 + - 支持自定义路径配置 + +**技术细节**: +- 使用 `github.com/mattn/go-sqlite3` 驱动 +- 实现 `internal/cache/cache.go` 模块 +- 缓存表结构:`id`, `cache_key`, `original_text`, `translated_text`, `from_lang`, `to_lang`, `model`, `prompt`, `created_at` +- 缓存键生成:`sha256(text + "|" + fromLang + "|" + toLang)` +- 查询缓存时使用 `SELECT translated_text FROM cache WHERE cache_key = ?` +- 插入缓存时使用 `INSERT OR IGNORE INTO cache (...) VALUES (...)` + +**关联文档**: +- [AGENTS.md#本地缓存功能设计](AGENTS.md#本地缓存功能设计) +- [changelog.md#0.5.0](changelog.md#050) + +--- + +### [2026-03-29 20:00] 版本 0.5.1 - 缓存功能修复 +**原因**: 缓存功能测试中发现的问题 +**分析**: +1. **VACUUM事务错误**: 缓存清空命令中,VACUUM不能在事务中执行 +2. **NULL值转换错误**: 缓存统计查询在空表时,MIN(created_at)返回NULL导致转换错误 +3. **过期清理策略**: 当expire_days=0时,清理逻辑不工作 + +**解决方案**: +1. **修复VACUUM事务错误**: + - 将VACUUM移到事务之外执行 + - 先删除记录,再执行VACUUM + +2. **修复NULL值转换错误**: + - 使用 `sql.NullString` 和 `sql.NullFloat64` 类型 + - 检查 `Valid` 字段判断是否为NULL + - 只在有记录时才查询时间范围 + +3. **修复过期清理策略**: + - 当cleanupTTL为0时,清理所有记录 + - 添加条件判断:`if c.cleanupTTL == 0 { ... }` + +**技术细节**: +```go +// 修复VACUUM事务错误 +func (c *SQLiteCache) Clear(ctx context.Context) error { + // 先删除所有记录 + _, err := c.db.ExecContext(ctx, `DELETE FROM translation_cache`) + if err != nil { + return fmt.Errorf("清空缓存失败: %w", err) + } + // 然后执行VACUUM(不能在事务中执行) + _, err = c.db.ExecContext(ctx, `VACUUM`) + if err != nil { + return fmt.Errorf("清理数据库失败: %w", err) + } + return nil +} + +// 修复NULL值转换错误 +var oldestStr, newestStr sql.NullString +var avgTokens sql.NullFloat64 +err = c.db.QueryRowContext(ctx, `SELECT MIN(created_at), MAX(created_at), AVG(total_tokens) FROM translation_cache`).Scan(&oldestStr, &newestStr, &avgTokens) + +if oldestStr.Valid { + stats.OldestRecord, _ = time.Parse("2006-01-02 15:04:05", oldestStr.String) +} +``` + +**关联文档**: +- [changelog.md#0.5.1](changelog.md#051) +- [memory.md#本地缓存实现经验](memory.md#本地缓存实现经验) \ No newline at end of file