Files
yoyo/memory.md
titor 217db90cfa feat: 升级到 lipgloss/bubbletea v2,实现翻译卡片组件
- 升级 charm.land/lipgloss/v2 v1.1.0 -> v2.0.2
- 升级 charm.land/bubbletea/v2 v1.3.10 -> v2.0.2
- 升级 charm.land/bubbles/v2 -> v2.1.0
- 新增翻译卡片组件:元信息行(Tokens/耗时/模型)、用户输入(碳黑背景)、翻译结果
- 卡片组件间距 5px
- 重构 model.go 适配 v2 API
- 更新 keys.go, messages.go, styles.go
2026-04-07 04:47:58 +08:00

23 KiB
Raw Blame History

记忆纠正 (memory.md)

本文档记录开发过程中的重要定义、踩过的坑和经验总结用于AI内部知识纠正。

使用说明

  • 定期更新,特别是遇到问题后
  • 按类别组织,便于查找
  • 包含具体示例和解决方案

技术决策记录

Go语言选择

决策: 使用Go语言而非Node.js或Deno
原因:

  1. Go编译为单一二进制文件部署方便
  2. 性能优秀适合CLI工具
  3. 强大的标准库支持
  4. 用户愿意学习Go

影响: 项目结构、依赖管理、开发工具链


面向对象设计

决策: 在Go中实现面向对象设计模式
模式应用:

  1. 工厂模式: ProviderFactory创建厂商实例
  2. 策略模式: Provider接口定义不同厂商策略
  3. 依赖注入: Translator依赖Provider接口

注意事项:

  • Go中没有类使用结构体
  • 继承通过组合实现
  • 多态通过接口实现

术语定义

厂商 (Provider)

指提供大模型API的服务商如硅基流动、火山引擎等。每个厂商实现统一的Provider接口。

配置 (Config)

全局配置对象包含API密钥、模型选择、超时设置等。使用YAML格式。

核心翻译器 (Translator)

负责协调配置、厂商和Prompt管理执行翻译任务的核心类。

踩过的坑

环境变量加载问题

问题: 配置文件中的环境变量(如${API_KEY})没有正确解析
原因: 没有实现环境变量替换功能
解决方案:

  1. 使用os.Getenv读取环境变量
  2. 在配置加载时进行字符串替换
  3. 添加环境变量验证

预防措施:

  • 在配置加载后验证所有必需的环境变量
  • 提供清晰的错误信息

厂商API差异

问题: 不同厂商的API格式差异较大
原因: 每个厂商有自己的请求/响应格式
解决方案:

  1. 定义统一的TranslateRequestTranslateResponse
  2. 在每个厂商实现中进行格式转换
  3. 使用适配器模式

经验:

  • 优先设计统一接口
  • 将厂商特定逻辑封装在实现内部
  • 提供原始响应用于调试

版本号管理混乱

问题: 版本号递增规则不明确
原因: 没有明确的版本管理规范
解决方案:

  1. 采用语义化版本:主版本.次版本.修订版本
  2. 第三位限制为00-99
  3. 建立更新流程

规范:

  • 小修复:修订版本+1
  • 新功能:次版本+1修订版本重置为00
  • 重大变更:主版本+1次版本和修订版本重置

环境变量加载问题

问题: 配置文件中的环境变量没有正确加载
原因: Go程序不会自动加载.env文件需要使用第三方库
解决方案:

  1. 使用github.com/joho/godotenv
  2. 在程序启动时调用godotenv.Load()
  3. 将.env文件添加到.gitignore

代码示例:

import "github.com/joho/godotenv"

func main() {
    _ = godotenv.Load() // 加载.env文件
    // 然后加载配置文件
}

注意事项:

  • 不要提交真实的.env文件到版本控制
  • 提供.env.example模板
  • 在文档中说明环境变量配置方法

配置最佳实践

安全配置

  • API密钥使用环境变量
  • 不提交敏感信息到版本控制
  • 提供.env.example模板

配置验证

  • 启动时验证所有必需配置
  • 提供有意义的错误信息
  • 支持配置热重载(未来)

默认值策略

  • 为非必需配置提供合理的默认值
  • 默认值应在config.setDefaults()中设置
  • 记录默认值的作用

开发工作流

日常开发流程

  1. dev分支创建功能分支
  2. 开发并测试功能
  3. 更新相关文档taolun.md、changelog.md
  4. 提交到功能分支
  5. 合并到dev分支
  6. 测试通过后合并到main

版本发布流程

  1. 确保dev分支稳定
  2. 更新版本号
  3. 更新changelog.md
  4. 创建版本标签
  5. 合并到main分支

文档维护

  • 每次重要讨论后更新taolun.md
  • 每个版本更新changelog.md
  • 遇到问题后更新memory.md

文档管理规范

why.md (项目初衷文档)

用途: 记录项目初衷、愿景、目标和个人笔记
编辑权限: 只能由项目所有者(用户)编辑
位置: 项目根目录
内容建议:

  • 项目愿景
  • 核心问题
  • 目标用户
  • 期望功能
  • 个人笔记

注意事项:

  • AI不应修改此文件
  • 文件内容反映创始人的个人想法
  • 可以自由格式,不强制结构

文档协作规范

taolun.md: AI与用户共同维护的讨论记录
changelog.md: AI与用户共同维护的版本记录
memory.md: AI主导的知识纠正和经验总结
why.md: 用户专属的项目初衷文档

编辑流程:

  1. 用户编辑why.md记录初衷
  2. AI编辑taolun.md记录讨论
  3. AI更新changelog.md记录版本
  4. AI更新memory.md记录经验

语言代码处理经验

语言代码标准化

问题: 需要支持多种语言代码格式,但内部应使用标准格式
解决方案:

  1. 使用BCP 47语言标签作为标准格式zh-CNen-US
  2. 实现智能解析函数 ParseLanguageCode()
  3. 支持别名映射(如 cnzh-CNenen-US

最佳实践:

  • 语言代码小写,地区代码大写(如 zh-CN,不是 zh-cn
  • 提供语言名称映射用于显示(如 zh-CN → "中文(简体)"
  • 支持模糊匹配和建议功能

交互式配置经验

问题: 命令行工具需要友好的配置界面
解决方案:

  1. 使用 github.com/AlecAivazis/survey/v2
  2. 实现分步配置流程
  3. 提供默认值和确认选项

注意事项:

  • 交互式库需要终端支持
  • 提供非交互式模式(如配置文件模板)
  • 错误处理要友好,避免程序崩溃

命令行参数解析经验

问题: Go标准库 flag 包功能有限,需要支持子命令
解决方案:

  1. 使用 flag 包解析选项参数
  2. 手动处理子命令(如 onboard
  3. 提供清晰的帮助信息

命名冲突处理:

  • 避免变量名与包名冲突(如 onboard 变量与 onboard 包)
  • 使用后缀区分(如 onboardFlag

配置文件管理经验

开发阶段配置策略

决策: 开发阶段使用 .env + configs/config.yaml
原因:

  1. 简化开发环境配置
  2. 符合12-factor应用原则
  3. 避免过早优化

实施:

  • .env 文件存储API密钥等敏感信息
  • configs/config.yaml 存储复杂配置结构
  • 使用环境变量替换 ${VAR}

配置文件格式选择

决策: 使用YAML格式
原因:

  1. 人类可读性好
  2. 支持复杂数据结构
  3. Go生态支持良好

注意事项:

  • 使用 gopkg.in/yaml.v3
  • 注意缩进和格式
  • 提供配置验证

管道功能实现经验

管道输入检测

问题: 如何检测是否有管道输入?
解决方案:

  1. 使用 os.Stdin.Stat() 获取标准输入的状态
  2. 检查 fileInfo.Mode() & os.ModeCharDevice 是否为0
  3. 如果为0表示是管道或文件重定向

代码示例:

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. 处理读取错误

代码示例:

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. 避免手动拼接正则表达式

代码示例:

// 错误的方式
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. 创建适当的索引提高查询性能

表结构:

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
);

索引设计:

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. 规范化输入:移除多余空白字符,统一语言代码格式

代码示例:

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. 只在有记录时才查询时间范围

代码示例:

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 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配置:

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. 测试错误情况的正确处理

TUI界面开发策略

分模块实现策略

决策: TUI界面分6个小模块逐步实现每次只做一个模块 原因:

  1. 减少Token消耗和上下文负担
  2. 便于逐步测试和验证
  3. 适应free模式的限流约束

模块列表:

  1. TUI框架搭建 - bubbletea基础App结构
  2. 输入组件 - 文本输入框
  3. 翻译显示区 - 结果展示、滚动
  4. 状态栏/主题 - 底部状态栏、配色
  5. 快捷键系统 - 操作快捷键
  6. 集成翻译 - 对接Translator、加载动画

技术选型

决策: 使用charmbracelet生态

  • bubbletea - Elm架构的TUI框架
  • lipgloss - 样式和主题
  • bubbles/textinput - 输入框组件

原因:

  1. Go生态最流行的TUI框架
  2. Elm架构清晰易于分模块实现
  3. 组件化设计,便于复用

文档管理规范

决策: 不再创建新的md文件 原因: 保持项目文档简洁,避免碎片化 规则:

  • 讨论内容 → taolun.md
  • 版本更新 → changelog.md
  • 经验教训 → memory.md
  • 项目初衷 → why.md (仅用户编辑)

Bubble Tea TUI框架经验

版本信息

当前版本: v1.3.10 API风格: v1版本风格Init返回tea.Cmd

基础结构

type model struct {
    // 状态字段
}

func (m model) Init() tea.Cmd {
    return nil // 初始化命令
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    return m, nil // 更新逻辑
}

func (m model) View() string {
    return "视图内容" // 返回渲染内容
}

func NewApp(cfg, translator) *tea.Program {
    return tea.NewProgram(model{...})
}

关键点

  • tea.NewProgram() 创建程序实例
  • program.Run() 返回 (model, error)
  • model的字段可以是config、translator等依赖
  • View方法返回string使用lipgloss样式

main.go集成注意

  • 版本检查(--version)需要在interactive模式检查之前
  • 避免interactive模式在非TTY环境启动
  • Run()需要两个返回值: _, err := app.Run()

TextInput组件使用

import "github.com/charmbracelet/bubbles/textinput"

// model中添加字段
type model struct {
    textInput textinput.Model
}

// 初始化
ti := textinput.New()
ti.Placeholder = "输入文本..."
ti.Focus()           // 获取焦点
ti.Prompt = "> "     // 提示符

// Update中处理
m.textInput, cmd = m.textInput.Update(msg)

// View中显示
m.textInput.View()

Lipgloss样式定义

var (
    headerStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#00D9FF")).
        Bold(true)
    resultStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#98FB98")).
        Background(lipgloss.Color("#0D1B2A"))
    helpStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#888888"))
    keyStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#60A5FA"))
)

多区域View渲染

func (m model) View() string {
    return "\n" +
        "  " + headerStyle.Render("YOYO翻译") + "\n" +
        "  " + divider + "\n\n" +
        "  " + m.textInput.View() + "\n\n" +
        m.renderResult() +
        helpText
}

func (m model) renderResult() string {
    if m.result == "" {
        return "  " + helpStyle.Render("翻译结果将显示在这里...") + "\n"
    }
    return "  " + resultStyle.Render(m.result) + "\n"
}

### 异步命令和消息模式
```go
// 定义自定义消息类型
type translateMsg struct {
    result string
    err    error
}

// 异步执行函数
func (m model) doTranslate(text string) tea.Cmd {
    return func() tea.Msg {
        result, err := translate(text)
        if err != nil {
            return translateMsg{err: err}
        }
        return translateMsg{result: result}
    }
}

// Update中处理消息
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case translateMsg:
        if msg.err != nil {
            m.errMsg = msg.err.Error()
        } else {
            m.result = msg.result
        }
        return m, nil
    }
    return m, nil
}

加载状态处理

type model struct {
    loading bool
    errMsg  string
}

// View中显示loading
func (m model) View() string {
    if m.loading {
        return "正在翻译..."
    }
    if m.errMsg != "" {
        return "错误: " + m.errMsg
    }
    return m.result
}

TUI界面改进知识

TextInput vs Textarea

  • textinput: 单行输入,适合短文本
  • textarea: 多行输入,适合长段落
  • 切换时需要调整布局和样式

Modal/弹出框设计

type model struct {
    showModal bool
    modalType string  // "help", "command", "info"
}

// View中渲染modal
func (m model) View() string {
    s := "主界面..."
    if m.showModal {
        s += m.renderModal()
    }
    return s
}

斜杠命令菜单

type command struct {
    name    string
    desc    string
    handler func()
}

var commands = []command{
    {"help", "显示帮助", handleHelp},
    {"clear", "清空内容", handleClear},
    {"copy", "复制结果", handleCopy},
}

// 模糊匹配
func matchCommand(input string) []command {
    // 过滤匹配的命令
}

Viewport组件

用于长文本滚动显示配合scrollbar展示滚动位置。


---

## TUI输入框踩坑记录 (v0.8.0)

### 问题1Ctrl+J换行后第一行被遮住

**现象**
- 按Ctrl+J换行后第一行内容往上滚动被遮住
- 光标在新行,但下方显示一个空行
- 实际渲染了3行但只显示2行

**尝试过的方案**
1. 移除 lipgloss Width() 限制 - 无效
2. 设置 SetWidth() 后再 SetHeight() - 无效
3. 动态计算行数后调用 SetHeight() - 无效
4. 移除 updateInputHeight() 调用 - 无效

**根因分析**
- textarea 内部使用 viewport 组件管理滚动
- 每次按键后动态调用 `m.input.SetHeight(lines)` 调整高度
- 导致 textarea 内部 viewport 滚动位置与渲染不同步

**最终解决方案**
- 放弃动态调整高度的方案
- 固定 textarea 高度为5行
- 超过5行时textarea 内部自动滚动,光标始终可见

**关键代码** (`internal/tui/model.go`):
```go
ta.SetWidth(60)
ta.SetHeight(5)  // 固定高度,不动态调整

问题2输入框背景颜色

解决方案

  • 使用 textarea.DefaultStyles() 获取默认样式
  • 修改 Style.Base 设置背景色

关键代码:

focusedStyle, blurredStyle := textarea.DefaultStyles()
bgStyle := lipgloss.NewStyle().
    Background(lipgloss.Color("#1F2937")).
    Foreground(lipgloss.Color("#FAFAFA"))
focusedStyle.Base = bgStyle
blurredStyle.Base = bgStyle
ta.FocusedStyle = focusedStyle
ta.BlurredStyle = blurredStyle

经验总结

  1. Bubble Tea的textarea组件内部包含viewport不适合频繁动态调整高度
  2. 固定高度方案:更稳定,让组件内部控制滚动
  3. 样式设置:使用 FocusedStyle/BlurredStyle + Style.Base 而非直接设置 Style

Bubble Tea/Lipgloss v2 升级经验 (v0.8.1)

模块路径变更

v2 版本全部迁移到 charm.land 域名:

v1 v2
github.com/charmbracelet/lipgloss charm.land/lipgloss/v2
github.com/charmbracelet/bubbletea charm.land/bubbletea/v2
github.com/charmbracelet/bubbles charm.land/bubbles/v2

View() 方法变更

v1:

func (m model) View() string {
    return "内容"
}

v2:

func (m model) View() tea.View {
    v := tea.NewView("内容")
    v.AltScreen = true  // 进入备用屏幕
    return v
}

KeyMsg 变更

v1:

case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyCtrlC:
        return m, tea.Quit
    }

v2:

case tea.KeyPressMsg:
    switch msg.String() {
    case "ctrl+c":
        return m, tea.Quit
    case "space":  // 注意:空格键改为"space"
    }

快捷键对比

功能 v1 v2
Ctrl+C tea.KeyCtrlC "ctrl+c"
Alt修饰键 msg.Alt msg.Mod.Contains(tea.ModAlt)
空格键 " " "space"

viewport API变更

功能 v1 v2
滚动上 m.viewport.LineUp(n) m.viewport.ScrollUp(n)
滚动下 m.viewport.LineDown(n) m.viewport.ScrollDown(n)
宽度 m.viewport.Width m.viewport.Width()
高度 m.viewport.Height m.viewport.Height()
创建 viewport.New(w, h) viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))

textarea API变更

功能 v1 v2
默认样式 textarea.DefaultStyles() textarea.DefaultStyles(isDark bool)
重置内容 m.input.SetValue("") m.input.Reset()

Program启动

v1:

p := tea.NewProgram(model{})
p.Start()

v2:

p := tea.NewProgram(model{})
p.Run()