Files
yoyo/memory.md

988 lines
25 KiB
Markdown
Raw Normal View 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. 定义统一的`TranslateRequest``TranslateResponse`
2. 在每个厂商实现中进行格式转换
3. 使用适配器模式
**经验**:
- 优先设计统一接口
- 将厂商特定逻辑封装在实现内部
- 提供原始响应用于调试
---
### 版本号管理混乱
**问题**: 版本号递增规则不明确
**原因**: 没有明确的版本管理规范
**解决方案**:
1. 采用语义化版本:主版本.次版本.修订版本
2. 第三位限制为00-99
3. 建立更新流程
**规范**:
- 小修复:修订版本+1
- 新功能:次版本+1修订版本重置为00
- 重大变更:主版本+1次版本和修订版本重置
---
### 环境变量加载问题
**问题**: 配置文件中的环境变量没有正确加载
**原因**: Go程序不会自动加载.env文件需要使用第三方库
**解决方案**:
1. 使用`github.com/joho/godotenv`
2. 在程序启动时调用`godotenv.Load()`
3. 将.env文件添加到.gitignore
**代码示例**:
```go
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-CN``en-US`
2. 实现智能解析函数 `ParseLanguageCode()`
3. 支持别名映射(如 `cn``zh-CN``en``en-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表示是管道或文件重定向
**代码示例**:
```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. 测试错误情况的正确处理
---
## 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
### 基础结构
```go
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组件使用
```go
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样式定义
```go
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渲染
```go
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
}
```
### 加载状态处理
```go
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/弹出框设计
```go
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
}
```
### 斜杠命令菜单
```go
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 设置背景色
**关键代码**:
```go
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:
```go
func (m model) View() string {
return "内容"
}
```
v2:
```go
func (m model) View() tea.View {
v := tea.NewView("内容")
v.AltScreen = true // 进入备用屏幕
return v
}
```
### KeyMsg 变更
v1:
```go
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
}
```
v2:
```go
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:
```go
p := tea.NewProgram(model{})
p.Start()
```
v2:
```go
p := tea.NewProgram(model{})
p.Run()
```
---
### Logo模块化设计
**决策**: 创建 `internal/logo/logo.go` 统一管理logoTUI和CLI共享
**原因**:
1. 避免代码重复TUI和CLI都需要显示logo
2. 统一渐变色方案:紫→青 (`#B413DC``#00C8C8`)
3. 统一版本号格式:` ( v1.x.x )`` ( )`
**实现**:
```go
// 导出函数供外部调用
func GradientText(text string, startColor, endColor string) string
func GetLogoPattern() string // ASCII art图案
func GetVersionSuffix() string // " (v1.x.x )" 或 " ( )"
func PrintLogoWithVersion() // 打印完整logo
```
**版本注入**:
```go
// 编译时通过 ldflags 注入
// -X packagepath.variable=value
go build -ldflags "-X github.com/titor/fanyi/internal/logo.version=${VERSION}" -o yoyo ./cmd/yoyo
```
---
### CI构建环境问题
**问题1**: `./build.sh: not found
**原因**:
1. 远程CI使用 `golang:1.26-alpine` 镜像,默认没有 bash
2. build.sh 脚本 shebang 是 `#!/bin/bash`
**解决方案**:
```yaml
# .gitea/workflows/release.yaml
- name: Checkout
run: |
apk add git bash # 添加 bash
git clone ...
```
---
**问题2**: `error obtaining VCS status: exit status 128`
**原因**: CI 中 git 仓库信息不完整,导致 Go 获取 VCS 状态失败
**解决方案**:
```bash
# build.sh 中添加 -buildvcs=false
go build -buildvcs=false -ldflags "-s -w -X ..." -o yoyo ./cmd/yoyo
```
---
**问题3**: Release 说明只有 "Automated release"
**原因**: 创建 release 时 body 写死了固定文本
**解决方案**:
使用 annotated tag 的注释内容作为 release 说明:
```bash
git tag -a v1.2.0 -m "版本说明\n- 功能1\n- 功能2"
```
CI 中获取:
```bash
RELEASE_BODY=$(git tag -l --format='%(contents)' "$TAG_NAME")
```
---
### 终端颜色输出问题
**问题**: 在非TTY环境如管道ANSI转义序列可能显示为明文
**观察**:
- 使用 `script` 命令可以正确显示颜色
- `od -c` 检查输出包含正确的 `\033` 转义字符
- zsh 可能对某些颜色转义处理不同
**解决方案**:
- 使用 `GradientText` 函数逐字符应用渐变
- 每个字符后使用 `\033[0m` 重置
- 渐变使用 24-bit 颜色 `\033[38;2;R;G;Bm`