feat: 重构为 Go Web 脚手架——在线图片识别 Web 服务 + vision-cli 独立工具
- Gin + config.yaml + SQLite 后端骨架,JWT 登录 + 管理员初始化
- web/ 原生单页:拖拽多图上传 → Agnes 免费识别 → 结果复制
- HTTP API:/api/v1/vision/analyze 免鉴权 + 令牌桶限流,支持程序直调
- cmd/vision-cli 独立 CLI 工具(复用 service 层,不依赖 Web 服务)
- build.bat / build.sh 构建脚本,移除 mcp-server
- 统一响应格式 {code, message, data},CORS/JWT/限流中间件
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
154
main.go
154
main.go
@@ -1,123 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vision-tool/internal/config"
|
||||
"vision-tool/internal/database"
|
||||
"vision-tool/internal/router"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
// Command-line flags
|
||||
var (
|
||||
apiKey string
|
||||
configPath string
|
||||
prompt string
|
||||
quiet bool
|
||||
noThink bool
|
||||
saveConfig bool
|
||||
)
|
||||
|
||||
flag.StringVar(&apiKey, "apikey", "", "API key (or set AGNES_API_KEY env var)")
|
||||
flag.StringVar(&configPath, "config", "", "Path to config.json file")
|
||||
flag.StringVar(&prompt, "prompt", "", "Custom prompt for image analysis")
|
||||
flag.BoolVar(&quiet, "q", false, "Quiet mode — only output the model's response text")
|
||||
flag.BoolVar(&noThink, "no-think", false, "Disable thinking mode (faster, lower server load)")
|
||||
flag.BoolVar(&saveConfig, "save-config", false, "Save the API key to config.json and exit")
|
||||
provider := flag.String("provider", "agnes", "AI provider: agnes (default)")
|
||||
flag.Parse()
|
||||
|
||||
// Handle --save-config: persist the key and exit
|
||||
if saveConfig {
|
||||
if apiKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --apikey is required with --save-config")
|
||||
os.Exit(1)
|
||||
}
|
||||
if configPath == "" {
|
||||
configPath = filepath.Join(getToolsDir(), "config.json")
|
||||
}
|
||||
if err := SaveConfigFile(configPath, apiKey); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to save config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("API key saved to %s\n", configPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := LoadConfig(apiKey, configPath)
|
||||
// 1. 加载配置(config.yaml,可被环境变量覆盖)
|
||||
cfg, err := config.Load("config.yaml")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply provider preset (respects user's custom model/baseURL in config)
|
||||
cfg.ApplyProvider(*provider)
|
||||
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "Provider: %s | Model: %s | API key: %s\n",
|
||||
cfg.Provider, cfg.Model, MaskKey(cfg.APIKey))
|
||||
}
|
||||
|
||||
// Collect image paths from remaining args
|
||||
imagePaths := flag.Args()
|
||||
if len(imagePaths) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: vision-tool [flags] <image-file> [image-file2 ...]")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Flags:")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Examples:")
|
||||
fmt.Fprintln(os.Stderr, " vision-tool photo.jpg")
|
||||
fmt.Fprintln(os.Stderr, ` vision-tool -prompt "这张图里有什么文字?" screenshot.png`)
|
||||
fmt.Fprintln(os.Stderr, " vision-tool -q img1.png img2.png")
|
||||
fmt.Fprintln(os.Stderr, " vision-tool --apikey YOUR_KEY photo.jpg")
|
||||
fmt.Fprintln(os.Stderr, " vision-tool --save-config --apikey YOUR_KEY")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate image files exist
|
||||
for _, p := range imagePaths {
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error: file not found: %s\n", p)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
client := NewVisionClient(cfg, noThink)
|
||||
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "Analyzing %d image(s)...\n\n", len(imagePaths))
|
||||
}
|
||||
|
||||
// Call the API
|
||||
var resp *ChatResponse
|
||||
if len(imagePaths) == 1 {
|
||||
resp, err = client.AnalyzeImage(imagePaths[0], prompt)
|
||||
} else {
|
||||
resp, err = client.AnalyzeImages(imagePaths, prompt)
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 初始化数据库
|
||||
db, err := database.Init(&cfg.Database)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("初始化数据库失败: %v", err)
|
||||
}
|
||||
|
||||
// Output the result
|
||||
if len(resp.Choices) > 0 {
|
||||
fmt.Println(resp.Choices[0].Message.Content)
|
||||
}
|
||||
// 3. 构建路由(分层依赖注入)
|
||||
r := router.Setup(cfg, db, webFS)
|
||||
|
||||
if resp.Usage != nil && !quiet {
|
||||
fmt.Fprintf(os.Stderr, "\n---\nTokens: prompt=%d completion=%d total=%d\n",
|
||||
resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)
|
||||
// 4. 启动 HTTP 服务
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: r,
|
||||
}
|
||||
}
|
||||
|
||||
func getToolsDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
go func() {
|
||||
log.Printf("vision-tool 已启动: http://localhost:%d", cfg.Server.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 5. 优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("正在关闭服务...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("关闭异常: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
log.Println("服务已退出")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user