diff --git a/AGENTS.md b/AGENTS.md index 4f4db99..5c83292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -761,7 +761,64 @@ A: 使用指数退避重试,并在`internal/api/`中实现限流器。 ### Q: 如何支持更多语言? A: 在配置文件中添加语言映射,并更新翻译逻辑。 +## 语言代码处理 + +### 支持的语言代码格式 +项目支持多种语言代码格式,通过 `internal/lang` 模块处理: + +1. **标准BCP47格式**: `zh-CN`, `zh-TW`, `en-US`, `en-GB`, `ja`, `ko` 等 +2. **简短别名**: `cn`(中文), `en`(英文), `jp`(日文), `kr`(韩文) 等 +3. **中文名称**: `chinese`(中文), `english`(英文), `japanese`(日文) 等 + +### 语言解析函数 +```go +// 解析语言代码 +lang.ParseLanguageCode("cn") // 返回 "zh-CN" +lang.ParseLanguageCode("en") // 返回 "en-US" +lang.ParseLanguageCode("zh-TW") // 返回 "zh-TW" + +// 获取语言名称(用于显示) +lang.GetLanguageName("zh-CN") // 返回 "中文(简体)" +lang.GetLanguageName("en-US") // 返回 "English (US)" +``` + +## Onboard配置向导 + +### 配置流程 +1. 选择主要翻译厂商 +2. 配置厂商API密钥、HOST、模型 +3. 设置全局配置(默认语言、超时) +4. 保存配置到 `configs/config.yaml` + +### 使用方法 +```bash +yoyo onboard # 启动配置向导 +yoyo onboard --force # 强制重新配置 +``` + +### 配置向导实现 +- 使用 `github.com/AlecAivazis/survey/v2` 实现交互式界面 +- 支持厂商选择、API配置、语言设置 +- 生成标准YAML配置文件 + +## 分阶段迁移策略 + +### 第一阶段:开发阶段(当前) +- API密钥存储在 `.env` 文件 +- 复杂配置存储在 `configs/config.yaml` +- 支持环境变量替换 + +### 第二阶段:上线前 +- 实现配置文件路径查找机制 +- 支持用户配置目录 `~/.config/yoo/yoo.yml` +- 提供配置迁移工具 + +### 第三阶段:最终优化 +- 移除对 `.env` 文件依赖 +- 完全使用配置文件 + ## 参考资源 - [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/) \ No newline at end of file +- [Go Style Guide](https://google.github.io/styleguide/go/) +- [Survey库文档](https://github.com/AlecAivazis/survey) \ No newline at end of file diff --git a/changelog.md b/changelog.md index df4fa36..0513c06 100644 --- a/changelog.md +++ b/changelog.md @@ -32,6 +32,53 @@ ## 版本历史 +### 0.2.0 (2026-03-29) - 语言支持和配置向导 +**类型**: 功能版本 +**状态**: 开发中 + +**变更内容**: +- ✅ 添加语言代码智能解析模块 (internal/lang) +- ✅ 支持 `--lang` 参数指定目标语言 +- ✅ 支持多种语言代码格式(标准BCP47、别名、中文名称) +- ✅ 实现 onboard 交互式配置向导 +- ✅ 更新配置结构添加语言字段 +- ✅ 添加 survey 库依赖用于交互式界面 +- ✅ 改进CLI命令行接口 +- ✅ 添加语言模块单元测试 + +**新增文件**: +- `internal/lang/lang.go` - 语言代码解析模块 +- `internal/lang/lang_test.go` - 语言模块测试 +- `internal/onboard/onboard.go` - 配置向导实现 + +**支持的语言代码**: +- 标准格式: zh-CN, zh-TW, en-US, en-GB, ja, ko, es, fr, de 等 +- 简短别名: cn(中文), en(英文), jp(日文), kr(韩文) 等 +- 中文名称: chinese(中文), english(英文), japanese(日文) 等 + +**使用示例**: +```bash +# 基本翻译 +yoyo "Hello world" +yoyo --lang=cn "Hello world" +yoyo --lang=en "你好世界" +yoyo --lang=zh-TW "Hello world" + +# 配置向导 +yoyo onboard +yoyo onboard --force +``` + +**讨论记录**: +- [语言代码解析设计](taolun.md#语言代码解析设计) +- [onboard配置向导](taolun.md#onboard配置向导) + +**下一步**: +- 实现更多厂商(火山引擎、国家超算、Qwen、OpenAI兼容) +- 添加配置文件路径查找机制 +- 实现配置文件迁移工具 +- 完善错误处理和用户体验 + ### 0.0.3 (2026-03-29) - 环境变量加载修复 **类型**: 修复版本 **状态**: 开发中 diff --git a/configs/config.yaml b/configs/config.yaml index 549a1aa..671ad5d 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -4,6 +4,8 @@ default_provider: "siliconflow" default_model: "gpt-3.5-turbo" timeout: 30 +default_source_lang: "auto" # 默认源语言(auto为自动检测) +default_target_lang: "zh-CN" # 默认目标语言(简体中文) providers: siliconflow: diff --git a/go.mod b/go.mod index 0af6743..e0cc7eb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,14 @@ module github.com/titor/fanyi go 1.26.1 require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/joho/godotenv v1.5.1 // indirect + 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/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 + golang.org/x/text v0.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0827d9a..16f548f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,52 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +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/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= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index d2bb179..b92ab52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,9 +12,11 @@ import ( // Config 全局配置结构 type Config struct { // 全局设置 - DefaultProvider string `yaml:"default_provider"` - DefaultModel string `yaml:"default_model"` - Timeout int `yaml:"timeout"` // 秒 + DefaultProvider string `yaml:"default_provider"` + DefaultModel string `yaml:"default_model"` + Timeout int `yaml:"timeout"` // 秒 + DefaultSourceLang string `yaml:"default_source_lang"` // 默认源语言(auto为自动检测) + DefaultTargetLang string `yaml:"default_target_lang"` // 默认目标语言 // 厂商配置 Providers map[string]ProviderConfig `yaml:"providers"` @@ -97,6 +99,12 @@ func (c *Config) setDefaults() { if c.DefaultModel == "" { c.DefaultModel = "gpt-3.5-turbo" } + if c.DefaultSourceLang == "" { + c.DefaultSourceLang = "auto" // 自动检测 + } + if c.DefaultTargetLang == "" { + c.DefaultTargetLang = "zh-CN" // 默认翻译为简体中文 + } // 为每个厂商设置默认值 for name, provider := range c.Providers { @@ -190,6 +198,8 @@ func (c *Config) String() string { builder.WriteString(fmt.Sprintf("DefaultProvider: %s\n", c.DefaultProvider)) builder.WriteString(fmt.Sprintf("DefaultModel: %s\n", c.DefaultModel)) builder.WriteString(fmt.Sprintf("Timeout: %d seconds\n", c.Timeout)) + builder.WriteString(fmt.Sprintf("DefaultSourceLang: %s\n", c.DefaultSourceLang)) + builder.WriteString(fmt.Sprintf("DefaultTargetLang: %s\n", c.DefaultTargetLang)) builder.WriteString("Providers:\n") for name, provider := range c.Providers { builder.WriteString(fmt.Sprintf(" %s: enabled=%v, model=%s\n", name, provider.Enabled, provider.Model)) diff --git a/internal/lang/lang.go b/internal/lang/lang.go new file mode 100644 index 0000000..f5792a5 --- /dev/null +++ b/internal/lang/lang.go @@ -0,0 +1,317 @@ +package lang + +import ( + "fmt" + "sort" + "strings" +) + +// 语言代码映射表 +var languageMap = map[string]string{ + // 中文变体 + "cn": "zh-CN", + "zh": "zh-CN", // 默认简体中文 + "zhcn": "zh-CN", + "zhtw": "zh-TW", + "zhhk": "zh-HK", + "zh-hans": "zh-CN", + "zh-hant": "zh-TW", + "chinese": "zh-CN", + "简体中文": "zh-CN", + "繁体中文": "zh-TW", + + // 英语变体 + "en": "en-US", // 默认美式英语 + "us": "en-US", + "uk": "en-GB", + "gb": "en-GB", + "english": "en-US", + "美式英语": "en-US", + "英式英语": "en-GB", + + // 日语 + "jp": "ja", + "ja": "ja", + "japanese": "ja", + "日语": "ja", + + // 韩语 + "kr": "ko", + "ko": "ko", + "korean": "ko", + "韩语": "ko", + + // 西班牙语 + "es": "es-ES", + "spanish": "es-ES", + "西班牙语": "es-ES", + + // 法语 + "fr": "fr-FR", + "french": "fr-FR", + "法语": "fr-FR", + + // 德语 + "de": "de-DE", + "german": "de-DE", + "德语": "de-DE", + + // 俄语 + "ru": "ru-RU", + "russian": "ru-RU", + "俄语": "ru-RU", + + // 葡萄牙语 + "pt": "pt-PT", + "portuguese": "pt-PT", + "葡萄牙语": "pt-PT", + "br": "pt-BR", // 巴西葡萄牙语 + + // 意大利语 + "it": "it-IT", + "italian": "it-IT", + "意大利语": "it-IT", + + // 阿拉伯语 + "ar": "ar-SA", + "arabic": "ar-SA", + "阿拉伯语": "ar-SA", + + // 印地语 + "hi": "hi-IN", + "hindi": "hi-IN", + "印地语": "hi-IN", + + // 其他语言 + "nl": "nl-NL", // 荷兰语 + "dutch": "nl-NL", + "sv": "sv-SE", // 瑞典语 + "swedish": "sv-SE", + "no": "nb-NO", // 挪威语 + "norwegian": "nb-NO", + "da": "da-DK", // 丹麦语 + "danish": "da-DK", + "fi": "fi-FI", // 芬兰语 + "finnish": "fi-FI", + "pl": "pl-PL", // 波兰语 + "polish": "pl-PL", + "tr": "tr-TR", // 土耳其语 + "turkish": "tr-TR", + "th": "th-TH", // 泰语 + "thai": "th-TH", + "vi": "vi-VN", // 越南语 + "vietnamese": "vi-VN", + "id": "id-ID", // 印尼语 + "indonesian": "id-ID", + "ms": "ms-MY", // 马来语 + "malay": "ms-MY", +} + +// 语言名称到代码的映射(用于显示) +var languageNames = map[string]string{ + "zh-CN": "中文(简体)", + "zh-TW": "中文(繁体)", + "zh-HK": "中文(香港)", + "en-US": "English (US)", + "en-GB": "English (UK)", + "ja": "日本語", + "ko": "한국어", + "es-ES": "Español", + "fr-FR": "Français", + "de-DE": "Deutsch", + "ru-RU": "Русский", + "pt-PT": "Português", + "pt-BR": "Português (Brasil)", + "it-IT": "Italiano", + "ar-SA": "العربية", + "hi-IN": "हिन्दी", + "nl-NL": "Nederlands", + "sv-SE": "Svenska", + "nb-NO": "Norsk", + "da-DK": "Dansk", + "fi-FI": "Suomi", + "pl-PL": "Polski", + "tr-TR": "Türkçe", + "th-TH": "ไทย", + "vi-VN": "Tiếng Việt", + "id-ID": "Bahasa Indonesia", + "ms-MY": "Bahasa Melayu", +} + +// ParseLanguageCode 解析语言代码 +// 支持多种格式:标准BCP47格式、别名、中文名称等 +func ParseLanguageCode(input string) string { + if input == "" { + return "" + } + + // 转换为小写进行匹配 + lower := strings.ToLower(strings.TrimSpace(input)) + + // 直接匹配 + if code, exists := languageMap[lower]; exists { + return code + } + + // 尝试解析BCP47格式(如 zh-CN, en-US) + if isValidLanguageTag(input) { + return normalizeLanguageTag(input) + } + + // 如果无法解析,返回原始输入 + return input +} + +// isValidLanguageTag 检查是否是有效的语言标签格式 +func isValidLanguageTag(tag string) bool { + // 简单的格式检查:语言代码-地区代码 + parts := strings.Split(tag, "-") + if len(parts) == 1 { + // 只有语言代码,如 "zh", "en" + return len(parts[0]) >= 2 && len(parts[0]) <= 3 + } + if len(parts) == 2 { + // 语言代码-地区代码,如 "zh-CN", "en-US" + return len(parts[0]) >= 2 && len(parts[0]) <= 3 && len(parts[1]) == 2 + } + return false +} + +// normalizeLanguageTag 标准化语言标签 +func normalizeLanguageTag(tag string) string { + parts := strings.Split(tag, "-") + if len(parts) == 1 { + // 只有语言代码,使用默认地区 + defaultRegions := map[string]string{ + "zh": "CN", + "en": "US", + "ja": "JP", + "ko": "KR", + "es": "ES", + "fr": "FR", + "de": "DE", + "ru": "RU", + "pt": "PT", + "it": "IT", + "ar": "SA", + "hi": "IN", + "nl": "NL", + "sv": "SE", + "no": "NO", + "da": "DK", + "fi": "FI", + "pl": "PL", + "tr": "TR", + "th": "TH", + "vi": "VN", + "id": "ID", + "ms": "MY", + } + if region, exists := defaultRegions[parts[0]]; exists { + return fmt.Sprintf("%s-%s", strings.ToLower(parts[0]), strings.ToUpper(region)) + } + return tag + } + if len(parts) == 2 { + // 标准化格式:语言小写,地区大写 + return fmt.Sprintf("%s-%s", strings.ToLower(parts[0]), strings.ToUpper(parts[1])) + } + return tag +} + +// GetLanguageName 获取语言名称(用于显示) +func GetLanguageName(code string) string { + if name, exists := languageNames[code]; exists { + return name + } + return code +} + +// GetLanguageNameOrDefault 获取语言名称,如果不存在则返回代码 +func GetLanguageNameOrDefault(code string, defaultName string) string { + if name, exists := languageNames[code]; exists { + return name + } + return defaultName +} + +// SupportedLanguages 获取支持的语言列表 +func SupportedLanguages() []string { + codes := make([]string, 0, len(languageNames)) + for code := range languageNames { + codes = append(codes, code) + } + sort.Strings(codes) + return codes +} + +// GetLanguageSuggestions 获取语言建议(用于模糊匹配) +func GetLanguageSuggestions(input string, limit int) []string { + if input == "" { + return []string{} + } + + lower := strings.ToLower(input) + suggestions := make([]string, 0) + + for alias, code := range languageMap { + if strings.Contains(alias, lower) || strings.Contains(strings.ToLower(code), lower) { + // 避免重复 + found := false + for _, s := range suggestions { + if s == code { + found = true + break + } + } + if !found { + suggestions = append(suggestions, code) + } + } + } + + // 限制数量 + if len(suggestions) > limit { + suggestions = suggestions[:limit] + } + + return suggestions +} + +// IsLanguageSupported 检查语言是否支持 +func IsLanguageSupported(code string) bool { + normalized := ParseLanguageCode(code) + _, exists := languageNames[normalized] + return exists +} + +// GetCommonLanguages 获取常用语言列表 +func GetCommonLanguages() []string { + return []string{ + "zh-CN", // 中文(简体) + "en-US", // 英语(美国) + "ja", // 日语 + "ko", // 韩语 + "es-ES", // 西班牙语 + "fr-FR", // 法语 + "de-DE", // 德语 + "ru-RU", // 俄语 + "pt-PT", // 葡萄牙语 + "it-IT", // 意大利语 + } +} + +// GetLanguageDirection 获取语言方向(从左到右或从右到左) +func GetLanguageDirection(code string) string { + rtlLanguages := map[string]bool{ + "ar-SA": true, // 阿拉伯语 + "he-IL": true, // 希伯来语 + "fa-IR": true, // 波斯语 + "ur-PK": true, // 乌尔都语 + } + + if rtlLanguages[code] { + return "rtl" + } + return "ltr" +} diff --git a/internal/lang/lang_test.go b/internal/lang/lang_test.go new file mode 100644 index 0000000..b302418 --- /dev/null +++ b/internal/lang/lang_test.go @@ -0,0 +1,224 @@ +package lang + +import ( + "testing" +) + +func TestParseLanguageCode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + // 中文变体 + {"cn", "zh-CN"}, + {"zh", "zh-CN"}, + {"zh-CN", "zh-CN"}, + {"zh-TW", "zh-TW"}, + {"zh-HK", "zh-HK"}, + {"chinese", "zh-CN"}, + {"简体中文", "zh-CN"}, + + // 英语变体 + {"en", "en-US"}, + {"en-US", "en-US"}, + {"en-GB", "en-GB"}, + {"us", "en-US"}, + {"uk", "en-GB"}, + {"english", "en-US"}, + + // 其他语言 + {"jp", "ja"}, + {"ja", "ja"}, + {"japanese", "ja"}, + {"kr", "ko"}, + {"ko", "ko"}, + {"korean", "ko"}, + {"es", "es-ES"}, + {"spanish", "es-ES"}, + {"fr", "fr-FR"}, + {"french", "fr-FR"}, + {"de", "de-DE"}, + {"german", "de-DE"}, + + // 空值 + {"", ""}, + + // 未知语言(应返回原始输入) + {"unknown", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ParseLanguageCode(tt.input) + if result != tt.expected { + t.Errorf("ParseLanguageCode(%q) = %q, 期望 %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGetLanguageName(t *testing.T) { + tests := []struct { + code string + expected string + }{ + {"zh-CN", "中文(简体)"}, + {"zh-TW", "中文(繁体)"}, + {"en-US", "English (US)"}, + {"en-GB", "English (UK)"}, + {"ja", "日本語"}, + {"ko", "한국어"}, + {"es-ES", "Español"}, + {"fr-FR", "Français"}, + {"de-DE", "Deutsch"}, + {"unknown", "unknown"}, // 未知代码返回原值 + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + result := GetLanguageName(tt.code) + if result != tt.expected { + t.Errorf("GetLanguageName(%q) = %q, 期望 %q", tt.code, result, tt.expected) + } + }) + } +} + +func TestSupportedLanguages(t *testing.T) { + languages := SupportedLanguages() + if len(languages) == 0 { + t.Error("SupportedLanguages() 不应返回空列表") + } + + // 检查一些关键语言是否在列表中 + expectedLanguages := []string{"zh-CN", "en-US", "ja", "ko"} + for _, expected := range expectedLanguages { + found := false + for _, lang := range languages { + if lang == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected language %q not found in supported languages", expected) + } + } +} + +func TestGetLanguageSuggestions(t *testing.T) { + tests := []struct { + input string + limit int + minCount int + }{ + {"zh", 5, 1}, + {"en", 5, 1}, + {"chinese", 5, 1}, + {"", 5, 0}, + {"unknown", 5, 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + suggestions := GetLanguageSuggestions(tt.input, tt.limit) + if len(suggestions) < tt.minCount { + t.Errorf("GetLanguageSuggestions(%q, %d) 返回 %d 个建议,至少需要 %d 个", + tt.input, tt.limit, len(suggestions), tt.minCount) + } + if len(suggestions) > tt.limit { + t.Errorf("GetLanguageSuggestions(%q, %d) 返回 %d 个建议,超过限制 %d 个", + tt.input, tt.limit, len(suggestions), tt.limit) + } + }) + } +} + +func TestIsLanguageSupported(t *testing.T) { + tests := []struct { + code string + expected bool + }{ + {"zh-CN", true}, + {"en-US", true}, + {"ja", true}, + {"unknown", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + result := IsLanguageSupported(tt.code) + if result != tt.expected { + t.Errorf("IsLanguageSupported(%q) = %v, 期望 %v", tt.code, result, tt.expected) + } + }) + } +} + +func TestGetCommonLanguages(t *testing.T) { + languages := GetCommonLanguages() + if len(languages) == 0 { + t.Error("GetCommonLanguages() 不应返回空列表") + } + + // 检查一些关键语言是否在列表中 + expectedLanguages := []string{"zh-CN", "en-US", "ja"} + for _, expected := range expectedLanguages { + found := false + for _, lang := range languages { + if lang == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected language %q not found in common languages", expected) + } + } +} + +func TestGetLanguageDirection(t *testing.T) { + tests := []struct { + code string + expected string + }{ + {"zh-CN", "ltr"}, + {"en-US", "ltr"}, + {"ja", "ltr"}, + {"ar-SA", "rtl"}, + {"he-IL", "rtl"}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + result := GetLanguageDirection(tt.code) + if result != tt.expected { + t.Errorf("GetLanguageDirection(%q) = %q, 期望 %q", tt.code, result, tt.expected) + } + }) + } +} + +func TestNormalizeLanguageTag(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"zh", "zh-CN"}, + {"en", "en-US"}, + {"ja", "ja-JP"}, + {"zh-CN", "zh-CN"}, + {"zh-tw", "zh-TW"}, + {"EN-us", "en-US"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeLanguageTag(tt.input) + if result != tt.expected { + t.Errorf("normalizeLanguageTag(%q) = %q, 期望 %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go new file mode 100644 index 0000000..bb286ba --- /dev/null +++ b/internal/onboard/onboard.go @@ -0,0 +1,305 @@ +package onboard + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/AlecAivazis/survey/v2" + "github.com/titor/fanyi/internal/config" + "github.com/titor/fanyi/internal/lang" +) + +// RunOnboard 启动配置向导 +func RunOnboard(force bool) error { + fmt.Println("欢迎使用YOYO翻译工具配置向导!") + fmt.Println("这个向导将帮助您配置翻译工具。") + fmt.Println() + + // 检查配置文件是否存在 + configPath := "configs/config.yaml" + if _, err := os.Stat(configPath); err == nil && !force { + overwrite := false + prompt := &survey.Confirm{ + Message: "检测到配置文件已存在,是否要重新配置?", + Default: false, + } + if err := survey.AskOne(prompt, &overwrite); err != nil { + return fmt.Errorf("用户输入错误: %w", err) + } + if !overwrite { + fmt.Println("配置已取消。") + return nil + } + } + + // 步骤1: 选择主要厂商 + fmt.Println("步骤1: 选择主要翻译服务提供商") + providerName, err := SelectProvider() + if err != nil { + return fmt.Errorf("选择厂商失败: %w", err) + } + + // 步骤2: 配置主要厂商 + fmt.Println("\n步骤2: 配置主要厂商") + providerConfig, err := ConfigureProvider(providerName) + if err != nil { + return fmt.Errorf("配置厂商失败: %w", err) + } + + // 步骤3: 全局设置 + fmt.Println("\n步骤3: 全局设置") + globalConfig, err := GlobalSettings() + if err != nil { + return fmt.Errorf("全局设置失败: %w", err) + } + + // 步骤4: 确认并保存配置 + fmt.Println("\n步骤4: 保存配置") + configData := BuildConfig(providerName, providerConfig, globalConfig) + + if err := SaveConfig(configData, configPath); err != nil { + return fmt.Errorf("保存配置失败: %w", err) + } + + fmt.Printf("\n配置完成! 配置文件已保存到: %s\n", configPath) + fmt.Println("\n您现在可以使用以下命令进行翻译:") + fmt.Println(" yoyo \"Hello world\"") + fmt.Println(" yoyo --lang=cn \"Hello world\"") + fmt.Println("\n更多帮助请运行: yoyo --help") + + return nil +} + +// SelectProvider 选择主要厂商 +func SelectProvider() (string, error) { + providers := []string{ + "siliconflow", + "volcano", + "national", + "qwen", + "openai", + } + + providerNames := map[string]string{ + "siliconflow": "硅基流动 (推荐,免费额度)", + "volcano": "火山引擎", + "national": "国家超算", + "qwen": "Qwen (通义千问)", + "openai": "OpenAI兼容格式", + } + + var selected string + prompt := &survey.Select{ + Message: "请选择要使用的翻译服务提供商:", + Options: func() []string { + var opts []string + for _, p := range providers { + opts = append(opts, providerNames[p]) + } + return opts + }(), + Default: providerNames["siliconflow"], + } + + if err := survey.AskOne(prompt, &selected); err != nil { + return "", err + } + + // 返回对应的厂商名称 + for name, displayName := range providerNames { + if displayName == selected { + return name, nil + } + } + + return "siliconflow", nil +} + +// ConfigureProvider 配置厂商 +func ConfigureProvider(providerName string) (config.ProviderConfig, error) { + // 厂商默认配置 + defaults := map[string]config.ProviderConfig{ + "siliconflow": { + APIHost: "https://api.siliconflow.cn/v1", + Model: "siliconflow-base", + Enabled: true, + }, + "volcano": { + APIHost: "https://api.volcengine.com/v1", + Model: "volcano-chat", + Enabled: true, + }, + "national": { + APIHost: "https://api.nsc.gov.cn/v1", + Model: "nsc-base", + Enabled: true, + }, + "qwen": { + APIHost: "https://dashscope.aliyuncs.com/compatible-mode/v1", + Model: "qwen-turbo", + Enabled: true, + }, + "openai": { + APIHost: "https://api.openai.com/v1", + Model: "gpt-3.5-turbo", + Enabled: true, + }, + } + + defaultConfig := defaults[providerName] + cfg := config.ProviderConfig{ + APIHost: defaultConfig.APIHost, + Model: defaultConfig.Model, + Enabled: defaultConfig.Enabled, + } + + // 输入API密钥 + apiKeyPrompt := &survey.Input{ + Message: fmt.Sprintf("请输入 %s 的API密钥:", providerName), + Help: "API密钥用于身份验证,将存储在配置文件中", + } + if err := survey.AskOne(apiKeyPrompt, &cfg.APIKey, survey.WithValidator(survey.Required)); err != nil { + return config.ProviderConfig{}, err + } + + // 确认API HOST + apiHostPrompt := &survey.Input{ + Message: "API HOST (直接回车使用默认值):", + Default: cfg.APIHost, + } + if err := survey.AskOne(apiHostPrompt, &cfg.APIHost); err != nil { + return config.ProviderConfig{}, err + } + + // 确认默认模型 + modelPrompt := &survey.Input{ + Message: "默认模型 (直接回车使用默认值):", + Default: cfg.Model, + } + if err := survey.AskOne(modelPrompt, &cfg.Model); err != nil { + return config.ProviderConfig{}, err + } + + return cfg, nil +} + +// GlobalSettings 全局设置 +type GlobalConfig struct { + DefaultProvider string + DefaultModel string + Timeout int + DefaultSourceLang string + DefaultTargetLang string +} + +// GlobalSettings 全局设置 +func GlobalSettings() (*GlobalConfig, error) { + cfg := &GlobalConfig{ + DefaultProvider: "siliconflow", + DefaultModel: "siliconflow-base", + Timeout: 30, + DefaultSourceLang: "auto", + DefaultTargetLang: "zh-CN", + } + + // 选择默认语言 + targetLangOptions := lang.GetCommonLanguages() + var targetLangDisplay []string + for _, code := range targetLangOptions { + targetLangDisplay = append(targetLangDisplay, fmt.Sprintf("%s (%s)", code, lang.GetLanguageName(code))) + } + + targetLangPrompt := &survey.Select{ + Message: "请选择默认目标语言:", + Options: targetLangDisplay, + Default: fmt.Sprintf("%s (%s)", "zh-CN", lang.GetLanguageName("zh-CN")), + } + + var selectedTarget string + if err := survey.AskOne(targetLangPrompt, &selectedTarget); err != nil { + return nil, err + } + + // 从选择中提取语言代码 + for i, display := range targetLangDisplay { + if display == selectedTarget { + cfg.DefaultTargetLang = targetLangOptions[i] + break + } + } + + // 设置超时时间 + timeoutPrompt := &survey.Input{ + Message: "API超时时间(秒):", + Default: fmt.Sprintf("%d", cfg.Timeout), + } + var timeoutStr string + if err := survey.AskOne(timeoutPrompt, &timeoutStr); err != nil { + return nil, err + } + + // 解析超时时间 + if timeout := parseIntOrDefault(timeoutStr, 30); timeout > 0 { + cfg.Timeout = timeout + } + + return cfg, nil +} + +// BuildConfig 构建配置对象 +func BuildConfig(providerName string, providerConfig config.ProviderConfig, globalConfig *GlobalConfig) *config.Config { + // 创建厂商配置 + providers := map[string]config.ProviderConfig{ + providerName: providerConfig, + } + + // 创建Prompt配置 + prompts := map[string]string{ + "technical": "你是一位专业的技术翻译,请准确翻译以下技术文档,保持专业术语的准确性。", + "creative": "你是一位富有创造力的翻译家,请用优美流畅的语言翻译以下内容。", + "academic": "你是一位学术翻译专家,请用严谨的学术语言翻译以下内容。", + "simple": "请用简单易懂的语言翻译以下内容。", + } + + return &config.Config{ + DefaultProvider: providerName, + DefaultModel: providerConfig.Model, + Timeout: globalConfig.Timeout, + DefaultSourceLang: globalConfig.DefaultSourceLang, + DefaultTargetLang: globalConfig.DefaultTargetLang, + Providers: providers, + Prompts: prompts, + } +} + +// SaveConfig 保存配置文件 +func SaveConfig(cfg *config.Config, path string) error { + // 确保目录存在 + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建配置目录失败: %w", err) + } + + // 使用config包的Save方法 + loader := &config.YAMLConfigLoader{} + return loader.Save(cfg, path) +} + +// parseIntOrDefault 解析整数,失败时返回默认值 +func parseIntOrDefault(s string, defaultValue int) int { + if s == "" { + return defaultValue + } + + var result int + if _, err := fmt.Sscanf(s, "%d", &result); err != nil { + return defaultValue + } + + if result <= 0 { + return defaultValue + } + + return result +} diff --git a/memory.md b/memory.md index f5aa42d..74b9ea4 100644 --- a/memory.md +++ b/memory.md @@ -188,4 +188,69 @@ func main() { 1. 用户编辑why.md记录初衷 2. AI编辑taolun.md记录讨论 3. AI更新changelog.md记录版本 -4. AI更新memory.md记录经验 \ No newline at end of file +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` 库 +- 注意缩进和格式 +- 提供配置验证 \ No newline at end of file diff --git a/taolun.md b/taolun.md index df292ad..9a4b063 100644 --- a/taolun.md +++ b/taolun.md @@ -148,4 +148,80 @@ **关联文档**: - [memory.md#环境变量加载问题](memory.md#环境变量加载问题) -- [changelog.md#0.0.3](changelog.md#003) \ No newline at end of file +- [changelog.md#0.0.3](changelog.md#003) + +--- + +### [2026-03-29 10:00] 版本 0.2.0 - 语言代码解析设计 +**原因**: 用户需要通过 `--lang` 参数指定目标语言,支持多种语言代码格式 +**分析**: +- 需要支持标准BCP47格式(如 `zh-CN`、`en-US`) +- 需要支持简短别名(如 `cn`、`en`) +- 需要支持中文名称(如 `chinese`、`english`) +- 需要智能解析和错误提示 + +**解决方案**: +1. 创建 `internal/lang/lang.go` 模块 +2. 实现语言代码映射表和解析函数 +3. 支持大小写不敏感和模糊匹配 +4. 提供语言名称获取和建议功能 + +**技术细节**: +- 使用 `map[string]string` 存储语言代码映射 +- 实现 `ParseLanguageCode()` 函数进行智能解析 +- 支持30+种语言和变体 +- 添加完整的单元测试 + +**关联文档**: +- [AGENTS.md#语言代码处理](AGENTS.md#语言代码处理) +- [changelog.md#0.2.0](changelog.md#020) + +--- + +### [2026-03-29 10:30] 版本 0.2.0 - onboard配置向导 +**原因**: 用户需要友好的配置界面,特别是第一次使用时 +**分析**: +- 需要交互式配置向导 +- 需要支持选择厂商、输入API密钥、设置默认值 +- 需要生成标准的YAML配置文件 +- 需要支持强制重新配置 + +**解决方案**: +1. 使用 `github.com/AlecAivazis/survey/v2` 库 +2. 实现分步配置流程:选择厂商 → 配置厂商 → 全局设置 → 保存 +3. 提供友好的错误处理和用户提示 +4. 支持 `--force` 参数强制重新配置 + +**技术细节**: +- 使用 `survey.Select`、`survey.Input`、`survey.Confirm` 组件 +- 实现厂商默认配置和自定义选项 +- 生成完整的配置文件包含所有必要字段 +- 支持配置文件存在性检查 + +**关联文档**: +- [AGENTS.md#Onboard配置向导](AGENTS.md#onboard配置向导) +- [changelog.md#0.2.0](changelog.md#020) + +--- + +### [2026-03-29 11:00] 版本 0.2.0 - 分阶段迁移策略 +**原因**: 需要平衡开发便利性和最终上线需求 +**分析**: +- 开发阶段需要简单配置方式(`.env` + `configs/config.yaml`) +- 上线前需要迁移到用户配置目录(`~/.config/yoo/yoo.yml`) +- 需要平滑的迁移路径和向后兼容性 + +**解决方案**: +1. **第一阶段(当前)**: 继续使用 `.env` + `configs/config.yaml` +2. **第二阶段(上线前)**: 实现配置文件路径查找和迁移工具 +3. **第三阶段(最终)**: 移除 `.env` 依赖,完全使用配置文件 + +**技术细节**: +- 配置文件路径优先级:命令行 > 环境变量 > 用户目录 > 当前目录 +- 保持向后兼容性,支持旧配置格式 +- 提供配置验证和错误提示 +- 实现配置迁移工具(计划) + +**关联文档**: +- [AGENTS.md#分阶段迁移策略](AGENTS.md#分阶段迁移策略) +- [changelog.md#0.2.0](changelog.md#020) \ No newline at end of file