Files
yoyo/cmd/yoyo/main.go
titor c0156a88d6
All checks were successful
Release / build (push) Successful in 12m14s
feat: 修复配置路径BUG并迁移onboard到huh
- 新增路径解析工具 internal/config/path.go
- 配置查找优先级: --config > ~/.config/yoyo/config.yaml > ./configs/config.yaml
- onboard配置保存到 ~/.config/yoyo/config.yaml (符合XDG规范)
- .env文件从 ~/.config/yoyo/.env 加载
- onboard使用huh替代survey库,更现代的交互体验
- 添加Ctrl+C取消支持,打印'你已取消本次配置'
- 保存前增加确认步骤
- 版本号 v0.5.1 -> v1.1.0
2026-04-07 23:51:33 +08:00

560 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/joho/godotenv"
"github.com/titor/fanyi/internal/cache"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/lang"
"github.com/titor/fanyi/internal/onboard"
"github.com/titor/fanyi/internal/provider"
"github.com/titor/fanyi/internal/translator"
"github.com/titor/fanyi/internal/tui"
)
var (
// 命令行参数
version = flag.Bool("version", false, "显示版本信息")
help = flag.Bool("help", false, "显示帮助信息")
h = flag.Bool("h", false, "显示帮助信息")
langFlag = flag.String("lang", "", "目标语言代码(如 zh-CN, en-US, cn, en 等)")
langLong = flag.String("language", "", "目标语言代码(--lang的长格式")
configFile = flag.String("config", "", "配置文件路径")
providerFlag = flag.String("provider", "", "指定翻译厂商")
promptFlag = flag.String("prompt", "", "指定Prompt模式")
onboardFlag = flag.Bool("onboard", false, "启动交互式配置向导")
onboardForce = flag.Bool("onboard-force", false, "强制重新配置")
quietFlag = flag.Bool("quiet", false, "静默模式,不显示统计信息")
quietShort = flag.Bool("q", false, "静默模式,不显示统计信息(-q的短格式")
interactive = flag.Bool("interactive", false, "启动交互式翻译界面")
interactiveShort = flag.Bool("i", false, "启动交互式翻译界面(-i的短格式")
)
const versionString = "YOYO翻译工具 v1.1.0"
// isPipeInput 检测是否有管道输入
func isPipeInput() bool {
fileInfo, err := os.Stdin.Stat()
if err != nil {
return false
}
// 检查是否是管道(字符设备且不是普通文件)
return (fileInfo.Mode() & os.ModeCharDevice) == 0
}
// readFromStdin 从标准输入读取所有内容
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
}
// isQuiet 检查是否处于静默模式
func isQuiet() bool {
return *quietFlag || *quietShort
}
// isTTY 检查是否在TTY环境中
func isTTY() bool {
// 检查标准输出是否是TTY
if fi, err := os.Stdout.Stat(); err == nil {
if (fi.Mode() & os.ModeCharDevice) != 0 {
return true
}
}
// 检查标准错误是否是TTY
if fi, err := os.Stderr.Stat(); err == nil {
if (fi.Mode() & os.ModeCharDevice) != 0 {
return true
}
}
// 检查标准输入是否是TTY
if fi, err := os.Stdin.Stat(); err == nil {
if (fi.Mode() & os.ModeCharDevice) != 0 {
return true
}
}
return false
}
func main() {
// 解析命令行参数
flag.Parse()
// 处理版本和帮助
if *version {
printVersion()
return
}
// 处理交互式模式
if *interactive || *interactiveShort || shouldStartInteractive() {
startInteractiveMode()
return
}
// 处理管道输入情况
if isPipeInput() {
// 管道模式下,即使没有参数也继续执行
if *help || *h {
printHelp()
return
}
} else {
// 非管道模式下,没有参数则显示帮助
if *help || *h || flag.NArg() == 0 {
printHelp()
return
}
}
// 获取第一个参数作为命令或文本
firstArg := flag.Arg(0)
// 处理子命令
if firstArg == "onboard" {
force := false
// 检查是否有--force参数
for _, arg := range os.Args[1:] {
if arg == "--force" || arg == "-f" {
force = true
break
}
}
runOnboard(force)
return
}
// 处理缓存命令
if firstArg == "cache" {
if flag.NArg() < 2 {
fmt.Fprintln(os.Stderr, "错误: cache命令需要子命令")
fmt.Fprintln(os.Stderr, "可用子命令: clear, stats, cleanup")
os.Exit(1)
}
cacheSubcommand := flag.Arg(1)
runCacheCommand(cacheSubcommand)
return
}
// 创建上下文支持Ctrl+C中断
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 处理中断信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Println("\n收到中断信号正在退出...")
cancel()
}()
// 获取要翻译的文本
var text string
var err error
if isPipeInput() {
// 从stdin读取
text, err = readFromStdin()
if err != nil {
fmt.Fprintf(os.Stderr, "读取管道输入失败: %v\n", err)
os.Exit(1)
}
// 如果没有输入内容,显示错误
if strings.TrimSpace(text) == "" {
fmt.Fprintln(os.Stderr, "错误: 管道输入为空")
os.Exit(1)
}
} else {
// 从命令行参数读取
text = firstArg
if text == "" {
printHelp()
os.Exit(1)
}
}
// 加载环境变量文件
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath, err := config.ResolveConfigPath(*configFile)
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
fmt.Println("提示: 运行 'yoyo onboard' 进行配置")
os.Exit(1)
}
// 解析语言参数
targetLang := parseLanguageFlag()
if targetLang == "" {
targetLang = cfg.DefaultTargetLang
}
// 解析Prompt参数
promptName := *promptFlag
if promptName == "" {
promptName = "simple"
}
// 获取厂商配置
providerName := *providerFlag
if providerName == "" {
providerName = cfg.DefaultProvider
}
providerConfig, err := cfg.GetProviderConfig(providerName)
if err != nil {
fmt.Printf("获取厂商配置失败: %v\n", err)
os.Exit(1)
}
// 创建厂商实例
providerInstance, err := provider.CreateProvider(providerName, provider.ProviderConfig{
APIHost: providerConfig.APIHost,
APIKey: providerConfig.APIKey,
Model: providerConfig.Model,
})
if err != nil {
fmt.Printf("创建厂商实例失败: %v\n", err)
os.Exit(1)
}
// 创建翻译器
translatorInstance := translator.NewTranslator(cfg, providerInstance)
// 设置翻译选项
options := &translator.TranslateOptions{
ToLang: targetLang,
PromptName: promptName,
}
// 执行翻译
result, err := translatorInstance.Translate(ctx, text, options)
if err != nil {
fmt.Printf("翻译失败: %v\n", err)
os.Exit(1)
}
// 输出结果
fmt.Println(result.Translated)
// 根据quiet参数决定是否显示统计信息
if !isQuiet() && result.Usage != nil {
fmt.Fprintf(os.Stderr, "\n--- 用量统计 ---\n")
fmt.Fprintf(os.Stderr, "模型: %s\n", result.Model)
fmt.Fprintf(os.Stderr, "提示词: %d tokens\n", result.Usage.PromptTokens)
fmt.Fprintf(os.Stderr, "完成: %d tokens\n", result.Usage.CompletionTokens)
fmt.Fprintf(os.Stderr, "总计: %d tokens\n", result.Usage.TotalTokens)
}
}
// parseLanguageFlag 解析语言参数
func parseLanguageFlag() string {
// 优先使用--lang参数然后是--language参数
language := *langFlag
if language == "" {
language = *langLong
}
// 如果没有指定语言参数,返回空字符串(将使用配置文件中的默认值)
if language == "" {
return ""
}
// 使用语言解析模块处理语言代码
return lang.ParseLanguageCode(language)
}
// runOnboard 运行配置向导
func runOnboard(force bool) {
if err := onboard.RunOnboard(force); err != nil {
fmt.Printf("配置向导失败: %v\n", err)
os.Exit(1)
}
}
// runCacheCommand 运行缓存命令
func runCacheCommand(subcommand string) {
// 加载环境变量文件
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath, err := config.ResolveConfigPath("")
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
os.Exit(1)
}
// 创建缓存实例
if !cfg.Cache.Enabled {
fmt.Fprintln(os.Stderr, "缓存功能未启用")
os.Exit(1)
}
cacheConfig := &cache.CacheConfig{
Enabled: cfg.Cache.Enabled,
MaxRecords: cfg.Cache.MaxRecords,
ExpireDays: cfg.Cache.ExpireDays,
DBPath: cfg.Cache.DBPath,
}
cacheInstance, err := cache.NewSQLiteCache(cacheConfig)
if err != nil {
fmt.Printf("创建缓存实例失败: %v\n", err)
os.Exit(1)
}
defer cacheInstance.Close()
cleanupManager := cache.NewCleanupManager(cacheInstance)
ctx := context.Background()
switch subcommand {
case "clear":
if err := cleanupManager.ClearAll(ctx); err != nil {
fmt.Printf("清空缓存失败: %v\n", err)
os.Exit(1)
}
fmt.Println("缓存已清空")
case "stats":
stats, err := cleanupManager.GetStats(ctx)
if err != nil {
fmt.Printf("获取缓存统计失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("缓存统计:\n")
fmt.Printf(" 总记录数: %d\n", stats.TotalRecords)
fmt.Printf(" 总大小: %.2f MB\n", float64(stats.TotalSizeBytes)/1024/1024)
fmt.Printf(" 最早记录: %v\n", stats.OldestRecord)
fmt.Printf(" 最新记录: %v\n", stats.NewestRecord)
fmt.Printf(" 平均tokens/记录: %.2f\n", stats.AvgTokensPerRecord)
case "cleanup":
if err := cleanupManager.CleanupManual(ctx); err != nil {
fmt.Printf("清理缓存失败: %v\n", err)
os.Exit(1)
}
fmt.Println("缓存清理完成")
default:
fmt.Fprintf(os.Stderr, "未知的缓存子命令: %s\n", subcommand)
fmt.Fprintln(os.Stderr, "可用子命令: clear, stats, cleanup")
os.Exit(1)
}
}
// printVersion 显示版本信息
func printVersion() {
fmt.Println(versionString)
}
// printHelp 显示帮助信息
func printHelp() {
fmt.Printf(`%s
使用方法:
yoyo [选项] <文本>
yoyo onboard [选项]
基本翻译:
yoyo "Hello world" # 翻译为中文(默认)
yoyo --lang=cn "Hello world" # 指定翻译为简体中文
yoyo --lang=en "你好" # 翻译为英文
yoyo --lang=zh-TW "Hello world" # 翻译为繁体中文
管道使用:
cat file.txt | yoyo # 翻译文件内容
cat file.txt | yoyo --lang=en # 翻译为英文
cat file.txt | yoyo -q # 静默模式,只输出翻译结果
echo "Hello" | yoyo --lang=cn # 翻译命令输出
yoyo "Hello" | grep "你好" # 与其他命令组合使用
语言代码支持:
- 标准格式: zh-CN, zh-TW, en-US, en-GB, ja, ko, es, fr, de 等
- 简短别名: cn(中文), en(英文), jp(日文), kr(韩文) 等
- 中文名称: chinese(中文), english(英文), japanese(日文) 等
命令:
onboard 启动交互式配置向导
onboard --force 强制重新配置
cache clear 清空翻译缓存
cache stats 查看缓存统计信息
cache cleanup 清理过期缓存
翻译选项:
--lang=<语言代码> 指定目标语言
--language=<语言代码> 指定目标语言(长格式)
--config=<路径> 指定配置文件路径
--provider=<厂商> 指定翻译厂商
--prompt=<模式> 指定Prompt模式
--quiet, -q 静默模式,不显示统计信息
--interactive, -i 启动交互式翻译界面
通用选项:
-h, --help 显示帮助信息
--version 显示版本信息
示例:
yoyo "Hello world"
yoyo --lang=cn "Hello world"
yoyo --lang=en "你好世界"
yoyo --lang=jp "Hello world"
yoyo --lang=ko "Hello world"
yoyo --provider=siliconflow --lang=cn "Hello"
yoyo --prompt=technical --lang=zh-CN "API documentation"
echo "Hello" | yoyo --lang=cn -q
cat file.txt | yoyo --lang=en
yoyo onboard
yoyo onboard --force
yoyo cache clear # 清空翻译缓存
yoyo cache stats # 查看缓存统计信息
yoyo cache cleanup # 清理过期缓存
yoyo --interactive # 启动交互式翻译界面
yoyo -i # 启动交互式翻译界面(短格式)
配置:
- 配置文件: ~/.config/yoyo/config.yaml
- 环境变量: ~/.config/yoyo/.env
- 默认厂商: siliconflow
- 默认目标语言: zh-CN (简体中文)
更多信息请访问: https://github.com/titor/fanyi
`, versionString)
}
// shouldStartInteractive 判断是否应该启动交互式模式
func shouldStartInteractive() bool {
// 如果没有参数,且不是管道输入,则启动交互模式
if flag.NArg() == 0 && !isPipeInput() {
return true
}
return false
}
// startInteractiveMode 启动交互式模式
func startInteractiveMode() {
// 检查是否在TTY环境中
if !isTTY() {
fmt.Fprintln(os.Stderr, "错误: 交互式模式需要在TTY环境中运行")
fmt.Fprintln(os.Stderr, "请直接使用传统模式: yoyo \"要翻译的文本\"")
os.Exit(1)
}
// 加载环境变量文件
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath, err := config.ResolveConfigPath(*configFile)
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
fmt.Println("提示: 运行 'yoyo onboard' 进行配置")
fmt.Println("提示: 使用默认配置启动交互模式...")
// 使用默认配置
cfg = &config.Config{
DefaultProvider: "siliconflow",
DefaultModel: "gpt-3.5-turbo",
Timeout: 30,
DefaultTargetLang: "zh-CN",
Providers: make(map[string]config.ProviderConfig),
Prompts: make(map[string]string),
}
}
// 解析语言参数
targetLang := parseLanguageFlag()
if targetLang == "" {
targetLang = cfg.DefaultTargetLang
}
// 解析Prompt参数
promptName := *promptFlag
if promptName == "" {
promptName = "simple"
}
// 获取厂商配置
providerName := *providerFlag
if providerName == "" {
providerName = cfg.DefaultProvider
}
providerConfig, err := cfg.GetProviderConfig(providerName)
if err != nil {
fmt.Printf("获取厂商配置失败: %v\n", err)
fmt.Println("使用默认厂商配置...")
// 使用默认配置
providerConfig = config.ProviderConfig{
APIHost: "https://api.siliconflow.cn/v1",
APIKey: "",
Model: "gpt-3.5-turbo",
Enabled: true,
}
}
// 创建厂商实例
providerInstance, err := provider.CreateProvider(providerName, provider.ProviderConfig{
APIHost: providerConfig.APIHost,
APIKey: providerConfig.APIKey,
Model: providerConfig.Model,
})
if err != nil {
fmt.Printf("创建厂商实例失败: %v\n", err)
fmt.Println("使用模拟翻译功能...")
// 这里可以创建一个模拟厂商实例
// 暂时直接返回错误
fmt.Println("交互式模式需要有效的厂商配置")
return
}
// 创建翻译器
translatorInstance := translator.NewTranslator(cfg, providerInstance)
// 启动TUI界面
fmt.Println("正在启动交互式翻译界面...")
fmt.Println("使用 Ctrl+C 退出")
// 创建并运行TUI应用程序
app := tui.NewApp(cfg, translatorInstance)
if _, err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "运行TUI界面失败: %v\n", err)
os.Exit(1)
}
}