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:
2026-08-12 23:59:17 +08:00
parent 8bf77301bd
commit a3bbf33358
25 changed files with 1902 additions and 798 deletions

View File

@@ -0,0 +1,253 @@
package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"vision-tool/internal/config"
)
// ============================================================================
// API Types (OpenAI-compatible format used by Agnes)
// ============================================================================
type ChatMessage struct {
Role string `json:"role"`
Content []MessagePart `json:"content"`
}
type MessagePart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
}
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
ChatTemplateKwargs *ChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
}
// ChatTemplateKwargs 开启 Agnes 的 thinking 模式(OpenAI 兼容格式)。
type ChatTemplateKwargs struct {
EnableThinking bool `json:"enable_thinking"`
}
type ChatResponse struct {
ID string `json:"id"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
Error *APIError `json:"error,omitempty"`
}
type APIError struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}
type Choice struct {
Index int `json:"index"`
Message RespMessage `json:"message"`
}
type RespMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// AnalyzeResult 识别结果。
type AnalyzeResult struct {
Content string
Usage *Usage
}
// ============================================================================
// Vision Service
// ============================================================================
// VisionService 调用 Agnes 免费视觉模型识别图片内容。
type VisionService struct {
apiKey string
model string
baseURL string
httpClient *http.Client
maxRetries int
}
func NewVisionService(cfg *config.AIConfig) *VisionService {
return &VisionService{
apiKey: cfg.APIKey,
model: cfg.Model,
baseURL: cfg.BaseURL,
maxRetries: 3,
httpClient: &http.Client{Timeout: 120 * time.Second},
}
}
// Analyze 分析一张或多张图片(本地路径或 http(s) URL 均可),返回模型文本与 token 用量。
// noThinking 为 true 时关闭 thinking 模式(更快、更低负载)。
func (s *VisionService) Analyze(images []string, prompt string, noThinking bool) (*AnalyzeResult, error) {
if prompt == "" {
if len(images) > 1 {
prompt = "请详细描述这些图片的内容,比较它们之间的异同。请用中文回答。"
} else {
prompt = "请详细描述这张图片的内容。如果图片中有文字,请完整识别出来。请用中文回答。"
}
}
var parts []MessagePart
for _, path := range images {
content, err := s.buildImageContent(path)
if err != nil {
return nil, fmt.Errorf("读取图片 %s: %w", path, err)
}
parts = append(parts, content)
}
parts = append(parts, MessagePart{Type: "text", Text: prompt})
req := ChatRequest{
Model: s.model,
Messages: []ChatMessage{
{Role: "user", Content: parts},
},
ChatTemplateKwargs: &ChatTemplateKwargs{EnableThinking: !noThinking},
}
resp, err := s.callWithRetry(req)
if err != nil {
return nil, err
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("模型未返回内容")
}
return &AnalyzeResult{Content: resp.Choices[0].Message.Content, Usage: resp.Usage}, nil
}
// buildImageContent 构建图片消息块:URL 直接使用,本地文件 base64 编码。
func (s *VisionService) buildImageContent(path string) (MessagePart, error) {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return MessagePart{
Type: "image_url",
ImageURL: &ImageURL{URL: path},
}, nil
}
imgData, err := os.ReadFile(path)
if err != nil {
return MessagePart{}, err
}
dataURL := fmt.Sprintf("data:%s;base64,%s", detectMimeType(path), base64.StdEncoding.EncodeToString(imgData))
return MessagePart{
Type: "image_url",
ImageURL: &ImageURL{URL: dataURL},
}, nil
}
// callWithRetry 调用 API,对 429/5xx/rate/busy 指数退避重试(2s → 5s → 8s → 11s)。
func (s *VisionService) callWithRetry(req ChatRequest) (*ChatResponse, error) {
var lastErr error
for attempt := 0; attempt <= s.maxRetries; attempt++ {
if attempt > 0 {
delay := time.Duration(2+attempt*3) * time.Second
fmt.Fprintf(os.Stderr, "Retrying in %v (attempt %d/%d)...\n", delay, attempt, s.maxRetries)
time.Sleep(delay)
}
resp, err := s.call(req)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "429") ||
strings.Contains(errStr, "500") ||
strings.Contains(errStr, "502") ||
strings.Contains(errStr, "503") ||
strings.Contains(errStr, "rate") ||
strings.Contains(errStr, "busy") {
lastErr = err
continue
}
return nil, err
}
return resp, nil
}
return nil, fmt.Errorf("重试 %d 次仍失败: %w", s.maxRetries, lastErr)
}
func (s *VisionService) call(req ChatRequest) (*ChatResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("序列化请求: %w", err)
}
httpReq, err := http.NewRequest(http.MethodPost, s.baseURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("创建请求: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+s.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("调用 API: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应: %w", err)
}
if resp.StatusCode != http.StatusOK {
var chatResp ChatResponse
if json.Unmarshal(respBody, &chatResp) == nil && chatResp.Error != nil {
return nil, fmt.Errorf("API error (HTTP %d): [%s] %s",
resp.StatusCode, chatResp.Error.Code, chatResp.Error.Message)
}
return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, fmt.Errorf("解析响应: %w\nRaw: %s", err, string(respBody))
}
return &chatResp, nil
}
// detectMimeType 按扩展名判断图片 MIME 类型。
func detectMimeType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".bmp":
return "image/bmp"
case ".tiff", ".tif":
return "image/tiff"
case ".png":
fallthrough
default:
return "image/png"
}
}