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:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
177
README.md
@@ -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
10
build.bat
Normal 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
10
build.sh
Normal 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
129
config.go
@@ -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
29
config.yaml
Normal 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
52
go.mod
@@ -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
130
go.sum
Normal 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
91
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
48
internal/database/database.go
Normal file
48
internal/database/database.go
Normal 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
|
||||||
|
}
|
||||||
88
internal/handler/auth_handler.go
Normal file
88
internal/handler/auth_handler.go
Normal 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))
|
||||||
|
}
|
||||||
124
internal/handler/vision_handler.go
Normal file
124
internal/handler/vision_handler.go
Normal 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)
|
||||||
|
}
|
||||||
114
internal/middleware/middleware.go
Normal file
114
internal/middleware/middleware.go
Normal 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
38
internal/model/model.go
Normal 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}
|
||||||
|
}
|
||||||
45
internal/repository/user_repo.go
Normal file
45
internal/repository/user_repo.go
Normal 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
101
internal/router/router.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
internal/service/auth_service.go
Normal file
89
internal/service/auth_service.go
Normal 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)
|
||||||
|
}
|
||||||
253
internal/service/vision_service.go
Normal file
253
internal/service/vision_service.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
154
main.go
154
main.go
@@ -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)
|
||||||
func getToolsDir() string {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
exe, err := os.Executable()
|
log.Fatalf("服务启动失败: %v", err)
|
||||||
if err != nil {
|
}
|
||||||
return "."
|
}()
|
||||||
}
|
|
||||||
return filepath.Dir(exe)
|
// 5. 优雅关闭
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
log.Println("正在关闭服务...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("关闭异常: %v", err)
|
||||||
|
}
|
||||||
|
if sqlDB, err := db.DB(); err == nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
}
|
||||||
|
log.Println("服务已退出")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
304
vision.go
@@ -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
211
web/css/style.css
Normal 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
80
web/index.html
Normal 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
236
web/js/app.js
Normal 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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user