From a3bbf33358f1b68065902903cfb0083fd57c3537 Mon Sep 17 00:00:00 2001 From: Geliebte <1297754537@qq.com> Date: Wed, 12 Aug 2026 23:59:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E4=B8=BA=20Go=20Web?= =?UTF-8?q?=20=E8=84=9A=E6=89=8B=E6=9E=B6=E2=80=94=E2=80=94=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E5=9B=BE=E7=89=87=E8=AF=86=E5=88=AB=20Web=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=20+=20vision-cli=20=E7=8B=AC=E7=AB=8B=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 10 +- README.md | 177 +++++++++-------- build.bat | 10 + build.sh | 10 + config.go | 129 ------------ config.yaml | 29 +++ go.mod | 52 ++++- go.sum | 130 ++++++++++++ internal/config/config.go | 91 +++++++++ internal/database/database.go | 48 +++++ internal/handler/auth_handler.go | 88 +++++++++ internal/handler/vision_handler.go | 124 ++++++++++++ internal/middleware/middleware.go | 114 +++++++++++ internal/model/model.go | 38 ++++ internal/repository/user_repo.go | 45 +++++ internal/router/router.go | 101 ++++++++++ internal/service/auth_service.go | 89 +++++++++ internal/service/vision_service.go | 253 ++++++++++++++++++++++++ main.go | 154 +++++---------- mcp-server/package.json | 10 - mcp-server/server.js | 167 ---------------- vision.go | 304 ----------------------------- web/css/style.css | 211 ++++++++++++++++++++ web/index.html | 80 ++++++++ web/js/app.js | 236 ++++++++++++++++++++++ 25 files changed, 1902 insertions(+), 798 deletions(-) create mode 100644 build.bat create mode 100644 build.sh delete mode 100644 config.go create mode 100644 config.yaml create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/handler/auth_handler.go create mode 100644 internal/handler/vision_handler.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/model/model.go create mode 100644 internal/repository/user_repo.go create mode 100644 internal/router/router.go create mode 100644 internal/service/auth_service.go create mode 100644 internal/service/vision_service.go delete mode 100644 mcp-server/package.json delete mode 100644 mcp-server/server.js delete mode 100644 vision.go create mode 100644 web/css/style.css create mode 100644 web/index.html create mode 100644 web/js/app.js diff --git a/.gitignore b/.gitignore index 6d2cef6..119c60e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ -# Binary +# Binaries vision-tool.exe vision-tool +vision-cli.exe +vision-cli # API keys config.json -# Temp files -doc_update.json -config.json +# Runtime data +data/ +uploads/ diff --git a/README.md b/README.md index de04f32..cd2ee12 100644 --- a/README.md +++ b/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) -- 🖼️ 支持多图对比、base64 直传(无需图床/公网 URL) +- 🌐 Web 在线识别页:拖拽/多图上传 → 识别 → 复制结果 +- 🖥️ 独立 CLI 工具(`vision-cli`):不依赖 Web 服务,适合脚本/Claude Code 调用 +- 🖼️ 多图对比、base64 直传(无需图床/公网 URL) - 🔁 指数退避自动重试(429/5xx:2s → 5s → 8s → 11s) -- ⚡ `--no-think` 关闭思考模式,响应更快 -- 🔧 多源配置:命令行 > 环境变量 > 配置文件 > 内置默认 -- 🧩 附带 MCP Server(`mcp-server/`),可接入 MCP 客户端直接调用 +- 🔧 配置:命令行 > 环境变量 > config.yaml > 内置默认 +- 🔒 JWT 登录 + 管理员初始化(脚手架模板能力) ## 🚀 快速开始 @@ -23,105 +20,131 @@ ```bash cd vision-tool -go build -o vision-tool.exe . # Windows -# go build -o vision-tool . # Linux / macOS +go build -o vision-tool.exe . # Web 服务(Windows) +go build -o vision-cli.exe ./cmd/vision-cli # CLI 工具 +# 或直接运行 build.bat / build.sh ``` -### 基本用法 +### Web 使用 ```bash -# Claude Code 自动调用分析图片(推荐 -q 只输出结果) -./vision-tool.exe -q <图片路径> - -# 自定义提问(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 +go run . # 或 ./vision-tool.exe +# 打开 http://localhost:8080 ``` -### 参数一览 +1. 首次访问:初始化管理员账号(右上角) +2. 拖拽或点击选择图片(可多张) +3. (可选)输入自定义提问 +4. 点击「开始识别」→ 得到识别结果,可一键复制 -| 参数 | 说明 | -|---|---| -| `-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 会执行: +### CLI 使用 ```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 配置中允许该命令,或直接执行(首次会询问权限)。 - -## 🧩 MCP Server(可选) - -`mcp-server/` 提供 MCP(Model Context Protocol)服务,可注册到支持 MCP 的客户端: +Claude Code 在线调用方式:粘贴图片后执行 ```bash -cd mcp-server -npm install -node server.js # stdio 传输,由 MCP 客户端拉起 +/path/to/vision-cli.exe -q --no-think "/path/to/image.png" ``` -环境变量:`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 { - "api_key": "sk-...", - "model": "agnes-2.0-flash", - "base_url": "https://apihub.agnes-ai.com/v1/chat/completions", - "provider": "agnes" + "code": 0, + "message": "ok", + "data": { + "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 `) | +| `/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/ -├── main.go # CLI 入口(参数解析/调用编排) -├── vision.go # 视觉 API 客户端(请求构建/重试/多图) -├── config.go # 配置加载(多源优先级/供应商预设) -├── mcp-server/ # MCP 服务(server.js + package.json) -└── go.mod # Go 1.25 module +├── main.go # Web 服务入口(Gin + //go:embed web) +├── config.yaml # 统一配置 +├── build.bat / build.sh # 构建脚本 +├── cmd/ +│ └── 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 -- Windows Git Bash 下经 curl 传中文请用 `--data-binary @file.json`(避免编码损坏) +- 支持图片格式:PNG / JPG / GIF / WebP / BMP / TIFF +- 单张图片上限 10MB(config.yaml 可调) +- 识别接口免鉴权但有限流(10 req/s),如需生产部署请修改 JWT secret +- 生产环境建议 `auto_migrate: false`,手动管理数据库结构 - 图片过大时建议先压缩(降低延迟与体积) -- API Key 为内置默认值,若需更换:`--apikey` 或 `--save-config` --- diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..5cf732e --- /dev/null +++ b/build.bat @@ -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 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..3cb4ca7 --- /dev/null +++ b/build.sh @@ -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)" diff --git a/config.go b/config.go deleted file mode 100644 index 20e79f1..0000000 --- a/config.go +++ /dev/null @@ -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:] -} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..e6c8c3e --- /dev/null +++ b/config.yaml @@ -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 diff --git a/go.mod b/go.mod index 64ecf91..51dcfc6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,53 @@ 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dbfee6d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ee4a56b --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..89c000c --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..da723dc --- /dev/null +++ b/internal/handler/auth_handler.go @@ -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)) +} diff --git a/internal/handler/vision_handler.go b/internal/handler/vision_handler.go new file mode 100644 index 0000000..e55d8ae --- /dev/null +++ b/internal/handler/vision_handler.go @@ -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) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..eec8d71 --- /dev/null +++ b/internal/middleware/middleware.go @@ -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 ,通过后将用户信息写入 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() + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..95e9808 --- /dev/null +++ b/internal/model/model.go @@ -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} +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go new file mode 100644 index 0000000..b9f1c8c --- /dev/null +++ b/internal/repository/user_repo.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..a157ac8 --- /dev/null +++ b/internal/router/router.go @@ -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" + } +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..1e4947f --- /dev/null +++ b/internal/service/auth_service.go @@ -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) +} diff --git a/internal/service/vision_service.go b/internal/service/vision_service.go new file mode 100644 index 0000000..1f0b410 --- /dev/null +++ b/internal/service/vision_service.go @@ -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" + } +} diff --git a/main.go b/main.go index 32d0b6d..6cad632 100644 --- a/main.go +++ b/main.go @@ -1,123 +1,65 @@ package main import ( - "flag" + "context" + "embed" "fmt" + "log" + "net/http" "os" - "path/filepath" + "os/signal" + "syscall" + "time" + + "vision-tool/internal/config" + "vision-tool/internal/database" + "vision-tool/internal/router" ) +//go:embed web +var webFS embed.FS + func main() { - // Command-line flags - var ( - apiKey string - configPath string - prompt string - quiet bool - noThink bool - saveConfig bool - ) - - flag.StringVar(&apiKey, "apikey", "", "API key (or set AGNES_API_KEY env var)") - flag.StringVar(&configPath, "config", "", "Path to config.json file") - flag.StringVar(&prompt, "prompt", "", "Custom prompt for image analysis") - flag.BoolVar(&quiet, "q", false, "Quiet mode — only output the model's response text") - flag.BoolVar(&noThink, "no-think", false, "Disable thinking mode (faster, lower server load)") - flag.BoolVar(&saveConfig, "save-config", false, "Save the API key to config.json and exit") - provider := flag.String("provider", "agnes", "AI provider: agnes (default)") - flag.Parse() - - // Handle --save-config: persist the key and exit - if saveConfig { - if apiKey == "" { - fmt.Fprintln(os.Stderr, "Error: --apikey is required with --save-config") - os.Exit(1) - } - if configPath == "" { - configPath = filepath.Join(getToolsDir(), "config.json") - } - if err := SaveConfigFile(configPath, apiKey); err != nil { - fmt.Fprintf(os.Stderr, "Failed to save config: %v\n", err) - os.Exit(1) - } - fmt.Printf("API key saved to %s\n", configPath) - return - } - - // Load configuration - cfg, err := LoadConfig(apiKey, configPath) + // 1. 加载配置(config.yaml,可被环境变量覆盖) + cfg, err := config.Load("config.yaml") if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - // Apply provider preset (respects user's custom model/baseURL in config) - cfg.ApplyProvider(*provider) - - if !quiet { - fmt.Fprintf(os.Stderr, "Provider: %s | Model: %s | API key: %s\n", - cfg.Provider, cfg.Model, MaskKey(cfg.APIKey)) - } - - // Collect image paths from remaining args - imagePaths := flag.Args() - if len(imagePaths) == 0 { - fmt.Fprintln(os.Stderr, "Usage: vision-tool [flags] [image-file2 ...]") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Flags:") - flag.PrintDefaults() - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Examples:") - fmt.Fprintln(os.Stderr, " vision-tool photo.jpg") - fmt.Fprintln(os.Stderr, ` vision-tool -prompt "这张图里有什么文字?" screenshot.png`) - fmt.Fprintln(os.Stderr, " vision-tool -q img1.png img2.png") - fmt.Fprintln(os.Stderr, " vision-tool --apikey YOUR_KEY photo.jpg") - fmt.Fprintln(os.Stderr, " vision-tool --save-config --apikey YOUR_KEY") - os.Exit(1) - } - - // Validate image files exist - for _, p := range imagePaths { - if _, err := os.Stat(p); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error: file not found: %s\n", p) - os.Exit(1) - } - } - - client := NewVisionClient(cfg, noThink) - - if !quiet { - fmt.Fprintf(os.Stderr, "Analyzing %d image(s)...\n\n", len(imagePaths)) - } - - // Call the API - var resp *ChatResponse - if len(imagePaths) == 1 { - resp, err = client.AnalyzeImage(imagePaths[0], prompt) - } else { - resp, err = client.AnalyzeImages(imagePaths, prompt) + log.Fatalf("加载配置失败: %v", err) } + // 2. 初始化数据库 + db, err := database.Init(&cfg.Database) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + log.Fatalf("初始化数据库失败: %v", err) } - // Output the result - if len(resp.Choices) > 0 { - fmt.Println(resp.Choices[0].Message.Content) - } + // 3. 构建路由(分层依赖注入) + r := router.Setup(cfg, db, webFS) - if resp.Usage != nil && !quiet { - fmt.Fprintf(os.Stderr, "\n---\nTokens: prompt=%d completion=%d total=%d\n", - resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) + // 4. 启动 HTTP 服务 + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: r, } -} - -func getToolsDir() string { - exe, err := os.Executable() - if err != nil { - return "." - } - return filepath.Dir(exe) + go func() { + log.Printf("vision-tool 已启动: http://localhost:%d", cfg.Server.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("服务启动失败: %v", err) + } + }() + + // 5. 优雅关闭 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("正在关闭服务...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Printf("关闭异常: %v", err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + log.Println("服务已退出") } diff --git a/mcp-server/package.json b/mcp-server/package.json deleted file mode 100644 index 4b2fb11..0000000 --- a/mcp-server/package.json +++ /dev/null @@ -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" - } -} diff --git a/mcp-server/server.js b/mcp-server/server.js deleted file mode 100644 index d0cf456..0000000 --- a/mcp-server/server.js +++ /dev/null @@ -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); diff --git a/vision.go b/vision.go deleted file mode 100644 index eab5efc..0000000 --- a/vision.go +++ /dev/null @@ -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" - } -} diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..7ff7bc3 --- /dev/null +++ b/web/css/style.css @@ -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); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6656887 --- /dev/null +++ b/web/index.html @@ -0,0 +1,80 @@ + + + + + + Vision Tool — 免费图片识别 + + + +
+
+ +
+

Vision Tool

+

免费图片识别 · Agnes-2.0-Flash

+
+
+ +
+ +
+
+
+ +
🖼️
+

点击选择或拖拽图片到这里

+

支持 PNG / JPG / GIF / WebP / BMP,可多张同时识别(对比异同)

+
+
+ +
+ + +
+
+ + +
+ + + + + + + +
推理模型专心推理,图片理解交给免费小模型 · 识别接口支持 API 直接调用
+ + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..468fe0c --- /dev/null +++ b/web/js/app.js @@ -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 = '识别中…'; + 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; +});