Files
vision-tool/internal/service/vision_service.go
Geliebte a3bbf33358 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>
2026-08-12 23:59:17 +08:00

254 lines
6.9 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 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"
}
}