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

10
.gitignore vendored
View File

@@ -1,10 +1,12 @@
# Binary # Binaries
vision-tool.exe vision-tool.exe
vision-tool vision-tool
vision-cli.exe
vision-cli
# API keys # API keys
config.json config.json
# Temp files # Runtime data
doc_update.json data/
config.json uploads/

177
README.md
View File

@@ -1,21 +1,18 @@
# vision-tool — 给 Claude Code 装上眼睛 👁️ # vision-tool — 免费图片识别 👁️
基于 Go 构建的图片视觉分析 CLI 工具,对接 **Agnes-2.0-Flash** 免费视觉模型(Sapiens AI)。让 Claude Code 在不消耗昂贵多模态 token 的前提下"看懂"图片——适用于 **UI 设计稿分析、布局结构解读、页面内容/文字提取** 等场景。 基于 Go 的图片视觉分析工具,对接 **Agnes-2.0-Flash** 免费视觉模型(Sapiens AI)。提供 **Web 在线识别页面** 和 **CLI 工具** 两种形态,适用于 **UI 设计稿分析、布局结构解读、页面内容/文字提取** 等场景。
``` **核心思路:推理模型专心推理,图片理解交给免费小模型。成本为零。**
用户粘贴图片 → Claude Code 获取路径 → 调用 vision-tool.exe → 免费模型返回文本描述 → Claude Code 基于文本推理
```
**核心思路:推理模型专心推理,图片理解交给免费小模型。各取所长,成本为零。**
## ✨ 特性 ## ✨ 特性
- 🆓 完全免费(Agnes-2.0-Flash:Input $0 / Output $0) - 🆓 完全免费(Agnes-2.0-Flash:Input $0 / Output $0)
- 🖼️ 支持多图对比、base64 直传(无需图床/公网 URL) - 🌐 Web 在线识别页:拖拽/多图上传 → 识别 → 复制结果
- 🖥️ 独立 CLI 工具(`vision-cli`):不依赖 Web 服务,适合脚本/Claude Code 调用
- 🖼️ 多图对比、base64 直传(无需图床/公网 URL)
- 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s) - 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s)
- ⚡ `--no-think` 关闭思考模式,响应更快 - 🔧 配置:命令行 > 环境变量 > config.yaml > 内置默认
- 🔧 多源配置:命令行 > 环境变量 > 配置文件 > 内置默认 - 🔒 JWT 登录 + 管理员初始化(脚手架模板能力)
- 🧩 附带 MCP Server(`mcp-server/`),可接入 MCP 客户端直接调用
## 🚀 快速开始 ## 🚀 快速开始
@@ -23,105 +20,131 @@
```bash ```bash
cd vision-tool cd vision-tool
go build -o vision-tool.exe . # Windows go build -o vision-tool.exe . # Web 服务(Windows)
# go build -o vision-tool . # Linux / macOS go build -o vision-cli.exe ./cmd/vision-cli # CLI 工具
# 或直接运行 build.bat / build.sh
``` ```
### 基本用法 ### Web 使用
```bash ```bash
# Claude Code 自动调用分析图片(推荐 -q 只输出结果) go run . # 或 ./vision-tool.exe
./vision-tool.exe -q <图片路径> # 打开 http://localhost:8080
# 自定义提问(UI 设计分析场景)
./vision-tool.exe -q -prompt "请分析这张 UI 设计稿:布局结构、配色、组件层级、文字内容" design.png
# 多图对比
./vision-tool.exe -q screenshot1.png screenshot2.png
# 安静模式 + 关闭思考(Claude Code 推荐组合)
./vision-tool.exe -q --no-think screenshot.png
# 指定 API Key(或设置环境变量 AGNES_API_KEY)
./vision-tool.exe --apikey YOUR_KEY image.png
# 持久化保存 Key 到 config.json
./vision-tool.exe --save-config --apikey YOUR_KEY
``` ```
### 参数一览 1. 首次访问:初始化管理员账号(右上角)
2. 拖拽或点击选择图片(可多张)
3. (可选)输入自定义提问
4. 点击「开始识别」→ 得到识别结果,可一键复制
| 参数 | 说明 | ### CLI 使用
|---|---|
| `-q` | 安静模式,只输出模型结果文本(Claude 调用推荐) |
| `-prompt` | 自定义分析提问(默认:布局/元素/文字/要点结构化分析) |
| `--no-think` | 关闭 thinking 模式(更快、更低负载) |
| `--apikey` | API Key(优先于环境变量与配置文件) |
| `-config` | 指定 config.json 路径 |
| `--save-config` | 保存 API Key 后退出 |
| `-provider` | 模型供应商(默认 `agnes`) |
### 默认分析 Prompt(可被 -prompt 覆盖)
> 请详细分析这张图片:1) 整体布局结构(区块划分/层级)2) UI 设计元素(颜色/字体/组件/间距)3) 所有文字内容(逐字提取)4) 交互与视觉要点。以结构化列表输出。
## 🤖 在 Claude Code 中在线调用
1. **构建 exe**:`go build -o vision-tool.exe .`
2. **调用**:在 Claude Code 会话中,粘贴图片后告诉 Claude 图片的本地路径,Claude 会执行:
```bash ```bash
/path/to/vision-tool/vision-tool.exe -q --no-think "/path/to/your/image.png" ./vision-cli.exe photo.jpg # 识别图片
./vision-cli.exe -q --no-think screenshot.png # 安静模式 + 关闭思考(Claude Code 推荐)
./vision-cli.exe -prompt "提取图中所有文字" img.png
./vision-cli.exe -q img1.png img2.png # 多图对比
./vision-cli.exe --apikey YOUR_KEY image.png # 指定 Key
``` ```
3. Claude 拿到模型返回的文本描述后,即可基于此进行 UI 分析、代码生成或内容提取。 Claude Code 在线调用方式:粘贴图片后执行
**权限提示**:可在 Claude Code 配置中允许该命令,或直接执行(首次会询问权限)。
## 🧩 MCP Server(可选)
`mcp-server/` 提供 MCP(Model Context Protocol)服务,可注册到支持 MCP 的客户端:
```bash ```bash
cd mcp-server /path/to/vision-cli.exe -q --no-think "/path/to/image.png"
npm install
node server.js # stdio 传输,由 MCP 客户端拉起
``` ```
环境变量:`AGNES_API_KEY` / `VISION_MODEL` / `VISION_BASE_URL`。 ## 📡 HTTP API
## ⚙️ 配置优先级 统一响应格式:`{code, message, data}`(`code=0` 为成功)
`命令行参数 > 环境变量(AGNES_API_KEY)> 配置文件(config.json)> 内置默认` ### 在线图片识别(免鉴权 + 限流)
config.json 结构: ```bash
curl -F "images=@a.png" -F "images=@b.png" \
-F "prompt=比较这两张图的异同" \
http://localhost:8080/api/v1/vision/analyze
```
响应:
```json ```json
{ {
"api_key": "sk-...", "code": 0,
"model": "agnes-2.0-flash", "message": "ok",
"base_url": "https://apihub.agnes-ai.com/v1/chat/completions", "data": {
"provider": "agnes" "result": "……识别结果文本……",
"images": ["/uploads/xxx.png"],
"usage": {"prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200}
}
} }
``` ```
### 完整接口清单
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/health` | GET | 健康检查 |
| `/api/v1/admin/check` | GET | 管理员是否已初始化 |
| `/api/v1/admin/init` | POST | 初始化管理员 `{username, password}` |
| `/api/v1/auth/login` | POST | 登录 `{username, password}` → `{token}` |
| `/api/v1/user/profile` | GET | 当前用户信息(需 `Authorization: Bearer <token>`) |
| `/api/v1/vision/analyze` | POST | 图片识别(multipart:`images` × 1-N + 可选 `prompt`) |
| `/uploads/*` | GET | 上传图片静态访问 |
## ⚙️ 配置(config.yaml)
```yaml
server:
port: 8080
database:
driver: sqlite # 当前仅 sqlite(pure-go,无需 CGO)
path: ./data/app.db
auto_migrate: true
jwt:
secret: "change-me-in-production"
expire_hours: 720
upload:
path: uploads
max_size: 10485760 # 10MB
ai:
api_key: "sk-..." # Agnes API Key(或环境变量 AGNES_API_KEY)
base_url: "https://apihub.agnes-ai.com/v1/chat/completions"
model: "agnes-2.0-flash"
```
配置优先级:`命令行参数 > 环境变量(AGNES_API_KEY)> config.yaml > 内置默认(内置免费 Key)`
## 🧱 项目结构 ## 🧱 项目结构
``` ```
vision-tool/ vision-tool/
├── main.go # CLI 入口(参数解析/调用编排) ├── main.go # Web 服务入口(Gin + //go:embed web)
├── vision.go # 视觉 API 客户端(请求构建/重试/多图) ├── config.yaml # 统一配置
├── config.go # 配置加载(多源优先级/供应商预设) ├── build.bat / build.sh # 构建脚本
├── mcp-server/ # MCP 服务(server.js + package.json) ├── cmd/
└── go.mod # Go 1.25 module │ └── vision-cli/ # 独立 CLI 工具(打包为单个二进制)
├── internal/
│ ├── config/ # 配置加载(yaml + 环境变量覆盖)
│ ├── database/ # GORM + SQLite 初始化 + AutoMigrate
│ ├── model/ # 数据模型 + 统一响应格式
│ ├── repository/ # 数据访问层
│ ├── service/ # 业务逻辑(auth / vision 免费模型调用)
│ ├── handler/ # HTTP 处理器
│ ├── middleware/ # CORS / JWT / 令牌桶限流
│ └── router/ # 路由注册 + 前端内嵌 + SPA 回退
└── web/ # 原生单页(在线图片识别,内嵌进二进制)
├── index.html
├── css/style.css
└── js/app.js
``` ```
## ⚠️ 注意事项 ## ⚠️ 注意事项
- 支持图片格式:PNG / JPEG / GIF / WebP / BMP - 支持图片格式:PNG / JPG / GIF / WebP / BMP / TIFF
- Windows Git Bash 下经 curl 传中文请用 `--data-binary @file.json`(避免编码损坏) - 单张图片上限 10MB(config.yaml 可调)
- 识别接口免鉴权但有限流(10 req/s),如需生产部署请修改 JWT secret
- 生产环境建议 `auto_migrate: false`,手动管理数据库结构
- 图片过大时建议先压缩(降低延迟与体积) - 图片过大时建议先压缩(降低延迟与体积)
- API Key 为内置默认值,若需更换:`--apikey` 或 `--save-config`
--- ---

10
build.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
rem ============================================
rem vision-tool 构建脚本(Windows 本地)
rem 产物:vision-tool.exe(Web 服务) / vision-cli.exe(CLI 工具)
rem ============================================
set CGO_ENABLED=0
go build -o vision-tool.exe .
go build -o vision-cli.exe ./cmd/vision-cli
echo.
echo Build done: vision-tool.exe / vision-cli.exe

10
build.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# ============================================
# vision-tool 构建脚本(Linux 交叉编译,用于部署)
# 产物:vision-tool(Web 服务) / vision-cli(CLI 工具)
# ============================================
set -e
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o vision-tool .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o vision-cli ./cmd/vision-cli
chmod +x vision-tool vision-cli
echo "Build done: vision-tool / vision-cli (linux/amd64)"

129
config.go
View File

@@ -1,129 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// Config holds the application configuration.
type Config struct {
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Provider string `json:"provider"` // "agnes"
}
// Provider presets
var providerPresets = map[string]struct {
Model string
BaseURL string
}{
"agnes": {
Model: "agnes-2.0-flash",
BaseURL: "https://apihub.agnes-ai.com/v1/chat/completions",
},
}
// Hardcoded API key
const hardcodedAPIKey = "sk-68FL9xXRrJhxcY5TCGxoaFlQ94oCBioIJhyGfeDMCkCvA0SV"
// DefaultConfig returns a Config with sensible defaults (Agnes AI).
func DefaultConfig() *Config {
return &Config{
Provider: "agnes",
Model: "agnes-2.0-flash",
BaseURL: "https://apihub.agnes-ai.com/v1/chat/completions",
APIKey: hardcodedAPIKey,
}
}
// ApplyProvider applies a named provider preset, respecting user overrides.
func (c *Config) ApplyProvider(provider string) {
if preset, ok := providerPresets[provider]; ok {
c.Provider = provider
if c.Model == "" || c.Model == "agnes-2.0-flash" {
c.Model = preset.Model
}
if c.BaseURL == "" || c.BaseURL == "https://apihub.agnes-ai.com/v1/chat/completions" {
c.BaseURL = preset.BaseURL
}
}
}
// LoadConfig loads configuration from multiple sources in priority order:
// 1. Command-line --apikey flag (highest)
// 2. AGNES_API_KEY environment variable
// 3. config.json in the same directory as the executable
// 4. Hardcoded default key (lowest)
func LoadConfig(flagKey, configPath string) (*Config, error) {
cfg := DefaultConfig()
// Load from config file if it exists
if configPath == "" {
execDir := getExecDir()
configPath = filepath.Join(execDir, "config.json")
}
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil {
if fileCfg.APIKey != "" {
cfg.APIKey = fileCfg.APIKey
}
if fileCfg.Model != "" {
cfg.Model = fileCfg.Model
}
if fileCfg.BaseURL != "" {
cfg.BaseURL = fileCfg.BaseURL
}
}
}
// Override with environment variable
if envKey := os.Getenv("AGNES_API_KEY"); envKey != "" {
cfg.APIKey = envKey
}
// Highest priority: command-line flag
if flagKey != "" {
cfg.APIKey = flagKey
}
// API key is always available thanks to hardcoded default
return cfg, nil
}
// SaveConfigFile saves config to a JSON file.
func SaveConfigFile(path, apiKey string) error {
cfg := DefaultConfig()
cfg.APIKey = apiKey
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func getExecDir() string {
execPath, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(execPath)
}
// MaskKey masks the API key for safe display, showing only first 8 and last 4 chars.
func MaskKey(key string) string {
if len(key) <= 12 {
return strings.Repeat("*", len(key))
}
return key[:8] + "..." + key[len(key)-4:]
}

29
config.yaml Normal file
View File

@@ -0,0 +1,29 @@
# ========== 服务配置 ==========
server:
port: 8080
# ========== 数据库配置 ==========
database:
driver: sqlite # 当前仅支持 sqlite(pure-go,无需 CGO)
path: ./data/app.db
auto_migrate: true # 启动自动建表,生产环境建议 false
# ========== JWT 认证 ==========
jwt:
secret: "change-me-in-production"
expire_hours: 720 # Token 有效期(小时)
# ========== 文件上传 ==========
upload:
path: uploads
max_size: 10485760 # 单张图片最大 10MB
# ========== AI 视觉模型(Agnes 免费) ==========
ai:
api_key: "sk-68FL9xXRrJhxcY5TCGxoaFlQ94oCBioIJhyGfeDMCkCvA0SV"
base_url: "https://apihub.agnes-ai.com/v1/chat/completions"
model: "agnes-2.0-flash"
# ========== 环境配置 ==========
env:
name: dev # dev | prod

52
go.mod
View File

@@ -1,3 +1,53 @@
module vision-tool module vision-tool
go 1.25 go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.55.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.2
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

130
go.sum Normal file
View File

@@ -0,0 +1,130 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

91
internal/config/config.go Normal file
View File

@@ -0,0 +1,91 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config 是应用统一配置,对应 config.yaml。
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
JWT JWTConfig `yaml:"jwt"`
Upload UploadConfig `yaml:"upload"`
AI AIConfig `yaml:"ai"`
Env EnvConfig `yaml:"env"`
}
type ServerConfig struct {
Port int `yaml:"port"`
}
type DatabaseConfig struct {
Driver string `yaml:"driver"`
Path string `yaml:"path"`
AutoMigrate bool `yaml:"auto_migrate"`
}
type JWTConfig struct {
Secret string `yaml:"secret"`
ExpireHours int `yaml:"expire_hours"`
}
type UploadConfig struct {
Path string `yaml:"path"`
MaxSize int64 `yaml:"max_size"`
}
type AIConfig struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
}
type EnvConfig struct {
Name string `yaml:"name"`
}
// hardcodedAPIKey 内置默认免费 Key(与 config.yaml 一致),
// 保证 CLI 工具在无配置文件时也能开箱即用。
const hardcodedAPIKey = "sk-68FL9xXRrJhxcY5TCGxoaFlQ94oCBioIJhyGfeDMCkCvA0SV"
// DefaultConfig 返回开箱即用的默认配置。
func DefaultConfig() *Config {
return &Config{
Server: ServerConfig{Port: 8080},
Database: DatabaseConfig{Driver: "sqlite", Path: "./data/app.db", AutoMigrate: true},
JWT: JWTConfig{Secret: "change-me-in-production", ExpireHours: 720},
Upload: UploadConfig{Path: "uploads", MaxSize: 10 * 1024 * 1024},
AI: AIConfig{
APIKey: hardcodedAPIKey,
Model: "agnes-2.0-flash",
BaseURL: "https://apihub.agnes-ai.com/v1/chat/completions",
},
Env: EnvConfig{Name: "dev"},
}
}
// Load 从 yaml 文件加载配置;文件不存在时使用默认值(便于快速上手)。
// 环境变量覆盖:AGNES_API_KEY → ai.api_key。
func Load(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, fmt.Errorf("读取 %s: %w", path, err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析 %s: %w", path, err)
}
// 环境变量覆盖
if key := os.Getenv("AGNES_API_KEY"); key != "" {
cfg.AI.APIKey = key
}
return cfg, nil
}

View File

@@ -0,0 +1,48 @@
package database
import (
"fmt"
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"vision-tool/internal/config"
"vision-tool/internal/model"
)
// Init 初始化数据库连接并执行 AutoMigrate。
// 使用 pure-go 的 glebarez/sqlite 驱动,CGO_ENABLED=0 下可编译运行。
func Init(cfg *config.DatabaseConfig) (*gorm.DB, error) {
var db *gorm.DB
var err error
switch cfg.Driver {
case "sqlite":
fallthrough
default:
if dir := filepath.Dir(cfg.Path); dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录: %w", err)
}
}
db, err = gorm.Open(sqlite.Open(cfg.Path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
}
if err != nil {
return nil, fmt.Errorf("连接数据库(%s): %w", cfg.Driver, err)
}
// auto_migrate:自动建表/更新表结构,生产环境建议手动管理
if cfg.AutoMigrate {
if err := db.AutoMigrate(&model.User{}); err != nil {
return nil, fmt.Errorf("自动建表: %w", err)
}
}
return db, nil
}

View File

@@ -0,0 +1,88 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"vision-tool/internal/model"
"vision-tool/internal/service"
)
// AuthHandler HTTP 处理器:管理员初始化 / 登录 / 用户信息。
type AuthHandler struct {
svc *service.AuthService
}
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
return &AuthHandler{svc: svc}
}
type initRequest struct {
Username string `json:"username" binding:"required,min=2,max=32"`
Password string `json:"password" binding:"required,min=6,max=64"`
}
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Check GET /api/v1/admin/check — 管理员是否已初始化
func (h *AuthHandler) Check(c *gin.Context) {
initialized, err := h.svc.CheckAdmin()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{"initialized": initialized}))
}
// Init POST /api/v1/admin/init — 初始化管理员(仅首次)
func (h *AuthHandler) Init(c *gin.Context) {
var req initRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "用户名 2-32 位,密码至少 6 位"))
return
}
if err := h.svc.InitAdmin(req.Username, req.Password); err != nil {
if errors.Is(err, service.ErrAdminExists) {
c.JSON(http.StatusConflict, model.Err(model.CodeConflict, err.Error()))
return
}
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{"message": "管理员初始化成功"}))
}
// Login POST /api/v1/auth/login — 登录获取 JWT token
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请输入用户名和密码"))
return
}
token, err := h.svc.Login(req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, err.Error()))
return
}
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{"token": token}))
}
// Profile GET /api/v1/user/profile — 当前用户信息(JWT 保护)
func (h *AuthHandler) Profile(c *gin.Context) {
userID := c.GetUint("user_id")
user, err := h.svc.Profile(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(user))
}

View File

@@ -0,0 +1,124 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"vision-tool/internal/config"
"vision-tool/internal/model"
"vision-tool/internal/service"
)
// VisionHandler HTTP 处理器:在线图片识别。
type VisionHandler struct {
svc *service.VisionService
upload config.UploadConfig
}
func NewVisionHandler(svc *service.VisionService, upload config.UploadConfig) *VisionHandler {
return &VisionHandler{svc: svc, upload: upload}
}
// Analyze POST /api/v1/vision/analyze — 上传 1-N 张图片并调用免费视觉模型识别。
//
// multipart 表单字段:
//
// images 图片文件(可多个,支持 png/jpg/gif/webp/bmp/tiff)
// prompt 可选,自定义分析提问(默认:结构化描述图片内容)
//
// 免鉴权 + 限流,方便 curl / Claude Code 直接调用。
func (h *VisionHandler) Analyze(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请使用 multipart/form-data 上传图片"))
return
}
files := form.File["images"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, "请至少上传一张图片 (images)"))
return
}
// prompt 字段可选:未传时表单里没有该 key(切片为空),直接取 [0] 会越界 panic
prompt := ""
if len(form.Value["prompt"]) > 0 {
prompt = strings.TrimSpace(form.Value["prompt"][0])
}
// 保存上传图片到 uploads/,返回本地路径列表
paths := make([]string, 0, len(files))
urls := make([]string, 0, len(files))
for _, fh := range files {
path, url, err := h.saveImage(fh)
if err != nil {
c.JSON(http.StatusBadRequest, model.Err(model.CodeBadParam, err.Error()))
return
}
paths = append(paths, path)
urls = append(urls, url)
}
// 调用免费视觉模型(Web 端默认开启 thinking)
result, err := h.svc.Analyze(paths, prompt, false)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Err(model.CodeServerErr, "识别失败: "+err.Error()))
return
}
c.JSON(http.StatusOK, model.OK(gin.H{
"result": result.Content,
"images": urls,
"usage": result.Usage,
}))
}
// saveImage 校验并保存上传图片,返回磁盘路径与访问 URL。
func (h *VisionHandler) saveImage(fh *multipart.FileHeader) (string, string, error) {
ext := strings.ToLower(filepath.Ext(fh.Filename))
allowed := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".bmp": true, ".tiff": true, ".tif": true}
if !allowed[ext] {
return "", "", fmt.Errorf("不支持的图片格式: %s(支持 png/jpg/gif/webp/bmp/tiff)", fh.Filename)
}
if fh.Size > h.upload.MaxSize {
return "", "", fmt.Errorf("图片超过大小限制: %s", fh.Filename)
}
src, err := fh.Open()
if err != nil {
return "", "", fmt.Errorf("读取上传文件: %w", err)
}
defer src.Close()
if err := os.MkdirAll(h.upload.Path, 0755); err != nil {
return "", "", fmt.Errorf("创建上传目录: %w", err)
}
name := randomName() + ext
diskPath := filepath.Join(h.upload.Path, name)
dst, err := os.Create(diskPath)
if err != nil {
return "", "", fmt.Errorf("保存文件: %w", err)
}
defer dst.Close()
if _, err := dst.ReadFrom(src); err != nil {
return "", "", fmt.Errorf("写入文件: %w", err)
}
return diskPath, "/uploads/" + name, nil
}
// randomName 生成 16 字节 hex 随机文件名。
func randomName() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,114 @@
package middleware
import (
"math"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"vision-tool/internal/model"
)
// CORS 跨域中间件(开发环境放开,生产环境应限制为具体域名)。
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// ============================================================================
// JWT 鉴权
// ============================================================================
// Claims JWT 载荷。
type Claims struct {
UserID uint `json:"uid"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// JWTAuth 校验 Authorization: Bearer <token>,通过后将用户信息写入 context。
func JWTAuth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
tokenStr, ok := strings.CutPrefix(header, "Bearer ")
if !ok || tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "未登录"))
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Err(model.CodeUnauthorized, "登录已过期,请重新登录"))
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
// ============================================================================
// 内存令牌桶限流(全局限流,防匿名刷接口)
// ============================================================================
// RateLimiter 基于时间的令牌桶。
type RateLimiter struct {
mu sync.Mutex
rate float64 // 每秒补充令牌数
burst float64 // 桶容量
tokens float64
lastTime time.Time
}
func NewRateLimiter(rps, burst int) *RateLimiter {
return &RateLimiter{
rate: float64(rps),
burst: float64(burst),
tokens: float64(burst),
lastTime: time.Now(),
}
}
func (rl *RateLimiter) Allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
rl.tokens = math.Min(rl.burst, rl.tokens+now.Sub(rl.lastTime).Seconds()*rl.rate)
rl.lastTime = now
if rl.tokens >= 1 {
rl.tokens--
return true
}
return false
}
// Middleware 返回限流 gin 中间件。
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !rl.Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, model.Err(429, "请求过于频繁,请稍后再试"))
return
}
c.Next()
}
}

38
internal/model/model.go Normal file
View File

@@ -0,0 +1,38 @@
package model
import "time"
// User 管理员账号。
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64" json:"username"`
PasswordHash string `gorm:"size:255" json:"-"`
Role string `gorm:"size:32" json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// Response 统一响应格式 {code, message, data}。
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// 业务错误码
const (
CodeOK = 0
CodeBadParam = 400
CodeUnauthorized = 401
CodeForbidden = 403
CodeNotFound = 404
CodeConflict = 409
CodeServerErr = 500
)
func OK(data interface{}) *Response {
return &Response{Code: CodeOK, Message: "ok", Data: data}
}
func Err(code int, msg string) *Response {
return &Response{Code: code, Message: msg}
}

View File

@@ -0,0 +1,45 @@
package repository
import (
"gorm.io/gorm"
"vision-tool/internal/model"
)
// UserRepository 数据访问层:用户表 CRUD。
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
// Count 返回用户总数(用于判断管理员是否已初始化)。
func (r *UserRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&model.User{}).Count(&count).Error
return count, err
}
func (r *UserRepository) Create(user *model.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) FindByID(id uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}

101
internal/router/router.go Normal file
View File

@@ -0,0 +1,101 @@
package router
import (
"io/fs"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"vision-tool/internal/config"
"vision-tool/internal/handler"
"vision-tool/internal/middleware"
"vision-tool/internal/model"
"vision-tool/internal/repository"
"vision-tool/internal/service"
)
// Setup 构建路由,并完成分层依赖注入:
// db → repository → service → handler → router。
// webFS 为 //go:embed web 的嵌入文件系统,用于内嵌前端。
func Setup(cfg *config.Config, db *gorm.DB, webFS fs.FS) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
// ---------- 分层依赖注入 ----------
userRepo := repository.NewUserRepository(db)
authSvc := service.NewAuthService(userRepo, cfg.JWT.Secret, time.Duration(cfg.JWT.ExpireHours)*time.Hour)
visionSvc := service.NewVisionService(&cfg.AI)
authH := handler.NewAuthHandler(authSvc)
visionH := handler.NewVisionHandler(visionSvc, cfg.Upload)
// 识别接口免鉴权,用令牌桶限流防刷
analyzeLimiter := middleware.NewRateLimiter(10, 20)
// ---------- API 路由 ----------
api := r.Group("/api")
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, model.OK(gin.H{"status": "ok"}))
})
v1 := api.Group("/v1")
{
// 管理员初始化(方式 A:check + init)
v1.GET("/admin/check", authH.Check)
v1.POST("/admin/init", authH.Init)
// 认证
v1.POST("/auth/login", authH.Login)
v1.GET("/user/profile", middleware.JWTAuth(cfg.JWT.Secret), authH.Profile)
// 在线图片识别(免鉴权 + 限流)
v1.POST("/vision/analyze", analyzeLimiter.Middleware(), visionH.Analyze)
}
// ---------- 静态资源 ----------
// 上传图片
r.StaticFS("/uploads", http.Dir(cfg.Upload.Path))
// 内嵌前端:未匹配路由统一走 NoRoute
// /api/* /uploads/* → 404;embed 内文件 → 直接返回;其余 → index.html(SPA 回退)
assets, err := fs.Sub(webFS, "web")
if err != nil {
panic("web 目录嵌入失败: " + err.Error())
}
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/uploads/") {
c.JSON(http.StatusNotFound, model.Err(model.CodeNotFound, "接口不存在"))
return
}
if data, err := fs.ReadFile(assets, strings.TrimPrefix(path, "/")); err == nil {
c.Data(http.StatusOK, mimeByExt(path), data)
return
}
index, _ := fs.ReadFile(assets, "index.html")
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
return r
}
// mimeByExt 按扩展名返回静态资源 Content-Type。
func mimeByExt(path string) string {
switch filepath.Ext(path) {
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "application/javascript; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
default:
return "text/html; charset=utf-8"
}
}

View File

@@ -0,0 +1,89 @@
package service
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"vision-tool/internal/middleware"
"vision-tool/internal/model"
"vision-tool/internal/repository"
)
var (
ErrAdminExists = errors.New("管理员已初始化,禁止重复初始化")
ErrInvalidCredentials = errors.New("用户名或密码错误")
)
// AuthService 认证业务:管理员初始化 / 登录 / 用户信息。
type AuthService struct {
repo *repository.UserRepository
secret string
expire time.Duration
}
func NewAuthService(repo *repository.UserRepository, secret string, expire time.Duration) *AuthService {
return &AuthService{repo: repo, secret: secret, expire: expire}
}
// CheckAdmin 判断是否已存在管理员(方式 A:check + init)。
func (s *AuthService) CheckAdmin() (bool, error) {
count, err := s.repo.Count()
return count > 0, err
}
// InitAdmin 初始化管理员,已存在时拒绝。
func (s *AuthService) InitAdmin(username, password string) error {
initialized, err := s.CheckAdmin()
if err != nil {
return err
}
if initialized {
return ErrAdminExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := &model.User{
Username: username,
PasswordHash: string(hash),
Role: "admin",
}
return s.repo.Create(user)
}
// Login 校验账号密码,签发 JWT token。
func (s *AuthService) Login(username, password string) (string, error) {
user, err := s.repo.FindByUsername(username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrInvalidCredentials
}
return "", err
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
return "", ErrInvalidCredentials
}
claims := middleware.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.expire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.secret))
}
// Profile 按 ID 返回用户信息。
func (s *AuthService) Profile(userID uint) (*model.User, error) {
return s.repo.FindByID(userID)
}

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"
}
}

148
main.go
View File

@@ -1,123 +1,65 @@
package main package main
import ( import (
"flag" "context"
"embed"
"fmt" "fmt"
"log"
"net/http"
"os" "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() { func main() {
// Command-line flags // 1. 加载配置(config.yaml,可被环境变量覆盖)
var ( cfg, err := config.Load("config.yaml")
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)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) log.Fatalf("加载配置失败: %v", 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)
} }
// 2. 初始化数据库
db, err := database.Init(&cfg.Database)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) log.Fatalf("初始化数据库失败: %v", err)
os.Exit(1)
} }
// Output the result // 3. 构建路由(分层依赖注入)
if len(resp.Choices) > 0 { r := router.Setup(cfg, db, webFS)
fmt.Println(resp.Choices[0].Message.Content)
}
if resp.Usage != nil && !quiet { // 4. 启动 HTTP 服务
fmt.Fprintf(os.Stderr, "\n---\nTokens: prompt=%d completion=%d total=%d\n", srv := &http.Server{
resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: r,
} }
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)
} }
}()
func getToolsDir() string { // 5. 优雅关闭
exe, err := os.Executable() quit := make(chan os.Signal, 1)
if err != nil { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
return "." <-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)
} }
return filepath.Dir(exe) if sqlDB, err := db.DB(); err == nil {
sqlDB.Close()
}
log.Println("服务已退出")
} }

View File

@@ -1,10 +0,0 @@
{
"name": "vision-tool-mcp",
"version": "1.0.0",
"description": "MCP server for vision-tool — image analysis via Agnes AI",
"type": "module",
"main": "server.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
}
}

View File

@@ -1,167 +0,0 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
// ============================================================================
// Configuration (mirrors config.go defaults)
// ============================================================================
const DEFAULT_API_KEY = "sk-68FL9xXRrJhxcY5TCGxoaFlQ94oCBioIJhyGfeDMCkCvA0SV";
const DEFAULT_MODEL = "agnes-2.0-flash";
const DEFAULT_BASE_URL = "https://apihub.agnes-ai.com/v1/chat/completions";
const API_KEY = process.env.AGNES_API_KEY || DEFAULT_API_KEY;
const MODEL = process.env.VISION_MODEL || DEFAULT_MODEL;
const BASE_URL = process.env.VISION_BASE_URL || DEFAULT_BASE_URL;
// ============================================================================
// MIME type detection
// ============================================================================
function detectMimeType(path) {
const ext = path.split(".").pop().toLowerCase();
const map = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
};
return map[ext] || "image/png";
}
// ============================================================================
// Vision API client
// ============================================================================
async function analyzeImage(imagePath, prompt) {
const imgData = readFileSync(imagePath);
const mimeType = detectMimeType(imagePath);
const base64Img = Buffer.from(imgData).toString("base64");
const dataURL = `data:${mimeType};base64,${base64Img}`;
if (!prompt) {
prompt = "请详细描述这张图片的内容。如果图片中有文字,请完整识别出来。请用中文回答。";
}
const body = {
model: MODEL,
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: dataURL } },
{ type: "text", text: prompt },
],
},
],
chat_template_kwargs: { enable_thinking: true },
};
const resp = await fetch(BASE_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(120_000),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`API error (HTTP ${resp.status}): ${text}`);
}
const data = await resp.json();
if (data.error) {
throw new Error(`API error: [${data.error.code}] ${data.error.message}`);
}
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("No content in API response");
}
return content;
}
// ============================================================================
// MCP Server
// ============================================================================
const server = new Server(
{
name: "vision-tool",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Register tool list
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "analyze_image",
description:
"分析图片内容。使用 Agnes AI 视觉模型识别图片中的对象、场景、文字等。" +
"支持 PNG、JPG、GIF、WebP、BMP、TIFF 格式。可以用自定义 prompt 指定分析重点。",
inputSchema: {
type: "object",
properties: {
image_path: {
type: "string",
description: "图片文件的绝对路径(例如 C:\\Users\\xxx\\photo.png)",
},
prompt: {
type: "string",
description:
"自定义分析提示词。默认为详细描述图片内容并识别所有文字。例如:" +
'"这张图里有什么文字?" 或 "描述图片中的UI界面布局"。',
},
},
required: ["image_path"],
},
},
],
}));
// Register tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name !== "analyze_image") {
throw new Error(`Unknown tool: ${name}`);
}
const imagePath = args.image_path;
const prompt = args.prompt || "";
try {
const result = await analyzeImage(imagePath, prompt);
return {
content: [{ type: "text", text: result }],
};
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err.message}` }],
isError: true,
};
}
});
// Start
const transport = new StdioServerTransport();
await server.connect(transport);

304
vision.go
View File

@@ -1,304 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// ============================================================================
// API Types (OpenAI-compatible format used by Agnes)
// ============================================================================
// ChatMessage represents a message in the chat API.
type ChatMessage struct {
Role string `json:"role"`
Content []MessagePart `json:"content"`
}
// MessagePart is a part of a message — text or image_url.
type MessagePart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL holds the image URL.
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
}
// ChatRequest is the request body for the chat completions API.
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
ChatTemplateKwargs *ChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
}
// ChatTemplateKwargs enables thinking mode in Agnes (OpenAI-compatible format).
type ChatTemplateKwargs struct {
EnableThinking bool `json:"enable_thinking"`
}
// ChatResponse is the response from the chat completions API.
type ChatResponse struct {
ID string `json:"id"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents an error returned by the API.
type APIError struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}
// Choice represents a single response choice.
type Choice struct {
Index int `json:"index"`
Message RespMessage `json:"message"`
}
// RespMessage is the message content in the response.
type RespMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Usage holds token usage info.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ============================================================================
// Vision Client
// ============================================================================
// VisionClient handles calling the vision API.
type VisionClient struct {
apiKey string
model string
baseURL string
httpClient *http.Client
noThinking bool
maxRetries int
}
// NewVisionClient creates a new VisionClient.
func NewVisionClient(cfg *Config, noThinking bool) *VisionClient {
return &VisionClient{
apiKey: cfg.APIKey,
model: cfg.Model,
baseURL: cfg.BaseURL,
noThinking: noThinking,
maxRetries: 3,
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
}
}
// AnalyzeImage sends an image to the vision API and returns the description.
// The imagePath can be a local file path or an http/https URL.
func (c *VisionClient) AnalyzeImage(imagePath, prompt string) (*ChatResponse, error) {
imageContent, err := c.buildImageContent(imagePath)
if err != nil {
return nil, err
}
if prompt == "" {
prompt = "请详细描述这张图片的内容。如果图片中有文字,请完整识别出来。请用中文回答。"
}
parts := []MessagePart{imageContent, {Type: "text", Text: prompt}}
req := c.buildRequest(parts)
return c.callWithRetry(req)
}
// AnalyzeImages sends multiple images to the vision API and returns the description.
func (c *VisionClient) AnalyzeImages(imagePaths []string, prompt string) (*ChatResponse, error) {
if prompt == "" {
prompt = "请详细描述这些图片的内容,比较它们之间的异同。请用中文回答。"
}
var parts []MessagePart
for _, path := range imagePaths {
content, err := c.buildImageContent(path)
if err != nil {
return nil, fmt.Errorf("reading image %s: %w", path, err)
}
parts = append(parts, content)
}
parts = append(parts, MessagePart{Type: "text", Text: prompt})
req := c.buildRequest(parts)
return c.callWithRetry(req)
}
// buildImageContent creates a MessagePart for an image.
// If the path is a URL, use it directly. Otherwise, base64-encode the local file.
func (c *VisionClient) buildImageContent(path string) (MessagePart, error) {
// If it's already a URL, use it directly
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return MessagePart{
Type: "image_url",
ImageURL: &ImageURL{URL: path},
}, nil
}
// Local file — base64 encode
imgData, mimeType, err := readImageFile(path)
if err != nil {
return MessagePart{}, err
}
base64Img := base64.StdEncoding.EncodeToString(imgData)
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Img)
return MessagePart{
Type: "image_url",
ImageURL: &ImageURL{URL: dataURL},
}, nil
}
// buildRequest constructs a ChatRequest with the given content parts.
func (c *VisionClient) buildRequest(parts []MessagePart) ChatRequest {
req := ChatRequest{
Model: c.model,
Messages: []ChatMessage{
{
Role: "user",
Content: parts,
},
},
}
// Enable thinking mode if not disabled
if !c.noThinking {
req.ChatTemplateKwargs = &ChatTemplateKwargs{EnableThinking: true}
}
return req
}
// call sends the request to the API and parses the response.
func (c *VisionClient) call(req ChatRequest) (*ChatResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
httpReq, err := http.NewRequest("POST", c.baseURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("calling API: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
// Try to parse error response
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("parsing response: %w\nRaw: %s", err, string(respBody))
}
return &chatResp, nil
}
// callWithRetry calls the API with automatic retry on transient errors.
func (c *VisionClient) callWithRetry(req ChatRequest) (*ChatResponse, error) {
var lastErr error
for attempt := 0; attempt <= c.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, c.maxRetries)
time.Sleep(delay)
}
resp, err := c.call(req)
if err != nil {
errStr := err.Error()
// Retry on rate limiting (429) or server errors (5xx)
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("all %d attempts failed, last error: %w", c.maxRetries+1, lastErr)
}
// ============================================================================
// Image file helpers
// ============================================================================
// readImageFile reads an image file from disk and returns its bytes and MIME type.
func readImageFile(path string) ([]byte, string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
return data, detectMimeType(path), nil
}
// detectMimeType detects the MIME type from file extension.
func detectMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".png":
return "image/png"
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"
default:
return "image/png"
}
}

211
web/css/style.css Normal file
View File

@@ -0,0 +1,211 @@
/* ===== 基础 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #4f6ef7;
--primary-dark: #3a55d8;
--bg: #f4f6fb;
--card: #ffffff;
--text: #1f2430;
--text-light: #6b7280;
--border: #e5e9f2;
--danger: #e5484d;
--radius: 12px;
}
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== 顶栏 ===== */
.topbar {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand .logo { font-size: 30px; }
.brand h1 { font-size: 20px; font-weight: 700; }
.brand p { font-size: 12px; color: var(--text-light); margin-top: 2px; }
.account { display: flex; align-items: center; gap: 10px; font-size: 14px; }
.account .user-chip {
background: #eef1fe;
color: var(--primary-dark);
padding: 6px 12px;
border-radius: 999px;
font-weight: 600;
}
/* ===== 布局 ===== */
.container {
flex: 1;
width: 100%;
max-width: 860px;
margin: 0 auto;
padding: 28px 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.05);
}
/* ===== 上传区 ===== */
.dropzone {
border: 2px dashed #c7d2fe;
border-radius: var(--radius);
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all .15s ease;
background: #fafbff;
}
.dropzone:hover, .dropzone.dragover { border-color: var(--primary); background: #eef1fe; }
.dropzone-icon { font-size: 40px; }
.dropzone-title { margin-top: 10px; font-weight: 600; font-size: 15px; }
.dropzone-hint { margin-top: 6px; font-size: 12px; color: var(--text-light); }
.preview-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.preview-item {
position: relative;
width: 96px;
height: 96px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
}
.preview-item img { width: 100%; height: 100%; object-fit: cover; }
.preview-item .remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, .55);
color: #fff;
font-size: 12px;
line-height: 20px;
text-align: center;
cursor: pointer;
border: none;
}
.preview-item .remove:hover { background: var(--danger); }
/* ===== 提问与按钮 ===== */
.prompt-row { display: flex; gap: 12px; margin-top: 18px; }
.prompt-input {
flex: 1;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
outline: none;
transition: border-color .15s;
}
.prompt-input:focus { border-color: var(--primary); }
.btn {
border: none;
border-radius: 10px;
padding: 12px 22px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all .15s;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-light); }
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
.btn-block { width: 100%; margin-top: 6px; }
/* ===== 结果区 ===== */
.result-head { display: flex; align-items: center; justify-content: space-between; }
.result-head h2 { font-size: 16px; }
.result-body {
margin-top: 14px;
background: #f8f9fc;
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
max-height: 480px;
overflow-y: auto;
}
.result-meta { margin-top: 10px; font-size: 12px; color: var(--text-light); }
/* ===== 加载态 ===== */
.loading { display: inline-flex; align-items: center; gap: 10px; }
.spinner {
width: 18px; height: 18px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ===== 模态框 ===== */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 20, 35, .45);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--card);
border-radius: 14px;
padding: 28px;
width: 380px;
box-shadow: 0 20px 60px rgba(0, 0, 0, .2);
}
.modal h2 { font-size: 18px; }
.modal-desc { font-size: 13px; color: var(--text-light); margin: 6px 0 16px; }
.modal-input {
width: 100%;
padding: 11px 13px;
margin-bottom: 10px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
outline: none;
}
.modal-input:focus { border-color: var(--primary); }
.modal-error { color: var(--danger); font-size: 13px; min-height: 18px; margin-bottom: 4px; }
/* ===== 页脚 ===== */
.footer {
text-align: center;
padding: 18px;
font-size: 12px;
color: var(--text-light);
}

80
web/index.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vision Tool — 免费图片识别</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">👁️</span>
<div>
<h1>Vision Tool</h1>
<p>免费图片识别 · Agnes-2.0-Flash</p>
</div>
</div>
<div class="account" id="accountArea">
<!-- 登录状态由 JS 渲染 -->
</div>
</header>
<main class="container">
<section class="card upload-card">
<div class="dropzone" id="dropzone">
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp,image/tiff" multiple hidden>
<div class="dropzone-icon">🖼️</div>
<p class="dropzone-title">点击选择或拖拽图片到这里</p>
<p class="dropzone-hint">支持 PNG / JPG / GIF / WebP / BMP,可多张同时识别(对比异同)</p>
</div>
<div class="preview-list" id="previewList"></div>
<div class="prompt-row">
<input type="text" id="promptInput" class="prompt-input"
placeholder="可选:自定义分析提问,例如「提取图中所有文字」「分析这个 UI 的布局结构」">
<button class="btn btn-primary" id="analyzeBtn" disabled>开始识别</button>
</div>
</section>
<section class="card result-card" id="resultCard" hidden>
<div class="result-head">
<h2>识别结果</h2>
<div class="result-actions">
<button class="btn btn-ghost" id="copyBtn">复制</button>
</div>
</div>
<pre class="result-body" id="resultBody"></pre>
<p class="result-meta" id="resultMeta"></p>
</section>
</main>
<!-- 初始化管理员模态框 -->
<div class="modal-mask" id="initModal" hidden>
<div class="modal">
<h2>初始化管理员</h2>
<p class="modal-desc">首次使用,请创建管理员账号</p>
<input type="text" id="initUsername" placeholder="用户名" class="modal-input">
<input type="password" id="initPassword" placeholder="密码(至少 6 位)" class="modal-input">
<input type="password" id="initPassword2" placeholder="确认密码" class="modal-input">
<div class="modal-error" id="initError"></div>
<button class="btn btn-primary btn-block" id="initBtn">创建管理员</button>
</div>
</div>
<!-- 登录模态框 -->
<div class="modal-mask" id="loginModal" hidden>
<div class="modal">
<h2>登录</h2>
<input type="text" id="loginUsername" placeholder="用户名" class="modal-input">
<input type="password" id="loginPassword" placeholder="密码" class="modal-input">
<div class="modal-error" id="loginError"></div>
<button class="btn btn-primary btn-block" id="loginBtn">登录</button>
</div>
</div>
<footer class="footer">推理模型专心推理,图片理解交给免费小模型 · 识别接口支持 API 直接调用</footer>
<script src="/js/app.js"></script>
</body>
</html>

236
web/js/app.js Normal file
View File

@@ -0,0 +1,236 @@
// ============================================================================
// Vision Tool — 在线免费图片识别(原生单页)
// ============================================================================
const $ = (id) => document.getElementById(id);
// ---------- API 封装 ----------
async function api(path, options = {}) {
const headers = options.headers || {};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
const token = localStorage.getItem("vt_token");
if (token) headers["Authorization"] = `Bearer ${token}`;
const resp = await fetch(path, { ...options, headers });
let data;
try {
data = await resp.json();
} catch {
throw new Error(`HTTP ${resp.status}: 响应解析失败`);
}
if (data.code !== 0) throw new Error(data.message || `HTTP ${resp.status}`);
return data.data;
}
// ---------- 状态 ----------
let selectedFiles = []; // 待识别的图片文件
let analyzing = false;
// ---------- 顶栏账号区 ----------
async function refreshAccount() {
const area = $("accountArea");
area.innerHTML = "";
const { initialized } = await api("/api/v1/admin/check");
if (!initialized) {
const btn = document.createElement("button");
btn.className = "btn btn-primary";
btn.textContent = "初始化管理员";
btn.onclick = () => showModal("initModal");
area.appendChild(btn);
return;
}
const token = localStorage.getItem("vt_token");
if (!token) {
const btn = document.createElement("button");
btn.className = "btn btn-ghost";
btn.textContent = "登录";
btn.onclick = () => showModal("loginModal");
area.appendChild(btn);
return;
}
// 校验 token 有效性
try {
const user = await api("/api/v1/user/profile");
const chip = document.createElement("span");
chip.className = "user-chip";
chip.textContent = `👤 ${user.username}`;
area.appendChild(chip);
const logout = document.createElement("button");
logout.className = "btn btn-ghost";
logout.textContent = "退出";
logout.onclick = () => {
localStorage.removeItem("vt_token");
refreshAccount();
};
area.appendChild(logout);
} catch {
localStorage.removeItem("vt_token");
refreshAccount();
}
}
// ---------- 模态框 ----------
function showModal(id) {
$(id).hidden = false;
$("initError").textContent = "";
$("loginError").textContent = "";
}
function hideModal(id) { $(id).hidden = true; }
$("initModal").addEventListener("click", (e) => { if (e.target === e.currentTarget) hideModal("initModal"); });
$("loginModal").addEventListener("click", (e) => { if (e.target === e.currentTarget) hideModal("loginModal"); });
// 初始化管理员
$("initBtn").addEventListener("click", async () => {
const username = $("initUsername").value.trim();
const password = $("initPassword").value;
const password2 = $("initPassword2").value;
if (!username || password.length < 6) {
$("initError").textContent = "用户名不能为空,密码至少 6 位";
return;
}
if (password !== password2) {
$("initError").textContent = "两次输入的密码不一致";
return;
}
try {
await api("/api/v1/admin/init", {
method: "POST",
body: JSON.stringify({ username, password }),
});
hideModal("initModal");
await login(username, password);
refreshAccount();
} catch (err) {
$("initError").textContent = err.message;
}
});
// 登录
$("loginBtn").addEventListener("click", async () => {
const username = $("loginUsername").value.trim();
const password = $("loginPassword").value;
if (!username || !password) {
$("loginError").textContent = "请输入用户名和密码";
return;
}
try {
await login(username, password);
hideModal("loginModal");
refreshAccount();
} catch (err) {
$("loginError").textContent = err.message;
}
});
async function login(username, password) {
const data = await api("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
localStorage.setItem("vt_token", data.token);
}
// ---------- 图片选择与预览 ----------
const dropzone = $("dropzone");
const fileInput = $("fileInput");
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dragover"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragover"));
dropzone.addEventListener("drop", (e) => {
e.preventDefault();
dropzone.classList.remove("dragover");
addFiles(e.dataTransfer.files);
});
fileInput.addEventListener("change", () => { addFiles(fileInput.files); fileInput.value = ""; });
function addFiles(files) {
for (const f of files) {
if (!f.type.startsWith("image/")) continue;
if (f.size > 10 * 1024 * 1024) { alert(`图片过大(>10MB): ${f.name}`); continue; }
selectedFiles.push(f);
}
renderPreviews();
}
function renderPreviews() {
const list = $("previewList");
list.innerHTML = "";
selectedFiles.forEach((f, i) => {
const item = document.createElement("div");
item.className = "preview-item";
const img = document.createElement("img");
img.src = URL.createObjectURL(f);
img.alt = f.name;
const rm = document.createElement("button");
rm.className = "remove";
rm.textContent = "×";
rm.onclick = () => {
selectedFiles.splice(i, 1);
renderPreviews();
};
item.append(img, rm);
list.appendChild(item);
});
$("analyzeBtn").disabled = selectedFiles.length === 0 || analyzing;
}
// ---------- 识别 ----------
$("analyzeBtn").addEventListener("click", analyze);
async function analyze() {
if (analyzing || selectedFiles.length === 0) return;
analyzing = true;
renderPreviews();
const btn = $("analyzeBtn");
btn.innerHTML = '<span class="loading"><span class="spinner"></span>识别中…</span>';
const resultCard = $("resultCard");
resultCard.hidden = false;
$("resultBody").textContent = "正在调用免费视觉模型,请稍候(最长约 2 分钟)…";
$("resultMeta").textContent = "";
const fd = new FormData();
selectedFiles.forEach((f) => fd.append("images", f, f.name));
const prompt = $("promptInput").value.trim();
if (prompt) fd.append("prompt", prompt);
try {
const data = await api("/api/v1/vision/analyze", { method: "POST", body: fd });
$("resultBody").textContent = data.result;
if (data.usage) {
$("resultMeta").textContent =
`token 用量: prompt ${data.usage.prompt_tokens} / completion ${data.usage.completion_tokens} / total ${data.usage.total_tokens}`;
}
} catch (err) {
$("resultBody").textContent = "❌ 识别失败: " + err.message;
} finally {
analyzing = false;
btn.textContent = "开始识别";
renderPreviews();
}
}
// 复制结果
$("copyBtn").addEventListener("click", async () => {
const text = $("resultBody").textContent;
try {
await navigator.clipboard.writeText(text);
$("copyBtn").textContent = "已复制 ✓";
setTimeout(() => ($("copyBtn").textContent = "复制"), 1500);
} catch {
alert("复制失败,请手动选择文本");
}
});
// ---------- 启动 ----------
refreshAccount().catch((err) => {
// check 失败(服务未启动等),提示
document.querySelector(".brand p").textContent = "⚠️ 服务连接失败: " + err.message;
});