Compare commits

...
4 Commits
Author SHA1 Message Date
chiyiandDevin 72d4eec203 feat(channel): 同步 vivid 最新渠道设计 — 手动全量同步端点/并发0=不限/同名别名报错
对齐 vivid 6039423/5802749/a1f808f/cfb6d49/aba8474/b01636f:
- 新增 POST /channels/sync-models:已保存渠道手动全量同步,上游不可达/
  空清单直接报错(保存路径仍降级为告警);渠道行按钮不再弹勾选框,
  表单内对已保存渠道同样直同步,新渠道保持预览+勾选即绑定
- 渠道并发 0 = 不限制(原 0=默认5),上限放开到 int32,槽位 Lua 本就
  max=0 放行
- 别名与上游模型同名提交即报错「留空即同名」,前后端双校验
- 渠道列表去掉上游地址与模型两列(同 vivid),状态徽章悬浮显示失败原因

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:43:30 +08:00
chiyiandDevin 5226e11cfc docs: 清理 OpenAI 时代残留,文档改述 vivid 协议 + 渠道架构
- README/README.en 重写:同构网关 + vivid 四端点 + 渠道同步/调度,
  明确不支持 OpenAI 协议上游;移除账号池/七大平台/OpenAI 兼容描述
- backend/frontend README、.env.example 更新(补 RUSTFS/COOKIE_SECURE/
  CHANNEL_SECRET)
- docker-compose 增加 CHANNEL_SECRET(渠道 key AES-GCM 密钥)
- 清掉代码注释/文档示例里的 OpenAI/gpt-image-2 残留

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:43:24 +08:00
chiyiandDevin b76e6ab717 feat(model): 同步 vivid 最新变更 — 去模型开关,配价即上架
对齐 vivid a338edb / 7fb5c57 / 4f454b6:
- model_configs.enabled 列删除:上架唯一门槛 = 绑定了启用渠道 + 全部档位
  有价(视频另需时长有价)。/v1/models、/api/models、video-presets、
  managed-models 全部改按 sellable 透出
- 保存渠道即全量同步上游模型(消失解绑/孤儿删除/失败告警不删库)
- v1 job 持久化 upstream jobId,重启后维护循环按 jobId 续等不丢单

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 22:01:00 +08:00
chiyiandDevin abd7b5c23b feat(channels): 拉模型改为勾选即绑定,启动清扫遗留模型
- fetch-models 返回 bound 标记,渠道页勾选=立即导入绑定,
  取消=解绑(无其他渠道接入时本地模型行一并删除)
- 新增 POST /channels/unbind-model
- 启动时 PurgeOrphanModels 清掉 provider 时代的无绑定模型行
- 模型管理页移除「渠道导入模型」按钮

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 21:35:30 +08:00
28 changed files with 839 additions and 697 deletions
+80 -248
View File
@@ -1,305 +1,137 @@
<div align="center"> <div align="center">
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" /> <img src="frontend/public/favicon.svg" width="84" alt="image2api" />
<h1>image2api</h1> <h1>image2api</h1>
**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, seven platforms aggregated, a ready-to-run operations system** **vivid-protocol AI image / video generation gateway — channel-based upstream routing, a ready-to-run operations system**
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub> <sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
[简体中文](README.md) | **English** [简体中文](README.md) | **English**
[![Online Demo](https://img.shields.io/badge/Live%20Demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run) [![Online Demo](https://img.shields.io/badge/demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org) [![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment) [![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-compatible-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-your--proxy-lightgrey)](#-deployment)
[![Providers](https://img.shields.io/badge/providers-7-orange)](#-supported-models--providers)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment) [![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license) [![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-openai-compatible-api) · [Community](#-community--contact) [Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-vivid-protocol-api)
<br/>
<img src="docs/screenshots/playground.png" alt="image2api — playground" width="860" />
</div> </div>
--- ---
## 📖 Table of Contents ## ✨ Introduction
- [Overview](#-overview) **image2api** is a homogeneous gateway. Upstream, it connects any number of **vivid-protocol (vividai) instances** as "channels" and routes jobs across them. Downstream, it exposes the same **vivid-protocol API** to callers: `POST /v1/generate` (NDJSON stream) · `POST /v1/uploads` (presigned upload) · `GET /v1/models` · `GET /v1/balance`. No built-in provider code, no account pools — an upstream is just another vividai instance, so gateways can be layered arbitrarily.
- [Screenshots](#-screenshots)
- [Features](#-features)
- [Supported Models / Providers](#-supported-models--providers)
- [OpenAI-Compatible API](#-openai-compatible-api)
- [Deployment](#-deployment)
- [Tech Stack](#-tech-stack)
- [Repository Layout](#-repository-layout)
- [Roadmap](#-roadmap)
- [Community / Contact](#-community--contact)
- [License](#-license)
## ✨ Overview It is more than a gateway: built-in **credit billing, CDK top-ups, online payment (epay), invite rewards, reseller pricing, user accounts, an admin console and a modern generation UI** — one command turns it into a production AI generation site. The author's live instance **[Vivid AI · vividai.run](https://vividai.run)** runs on this project.
**image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Grok, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service. > 💡 Frontend + backend fully open source (MIT). Go + Vue 3.
It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project. **One-liner** 🔁 vivid-protocol channel mesh · 📦 save-a-channel syncs all models · 💳 credits + payment + reseller pricing · 🧩 concurrency groups · 🎨 generation UI + admin console · 🐳 one-command deploy
> 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host.
**At a glance** 🔌 OpenAI-compatible · 🤖 7 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy (bring your own TLS proxy)
## 🖼️ Screenshots
<div align="center">
<sub>🎨 Modern generation frontend · light / dark admin console · data-driven ops dashboard</sub>
</div>
<table>
<tr>
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
<td width="50%"><img src="docs/screenshots/models.png" alt="Models" /></td>
</tr>
<tr>
<td align="center"><b>📊 Dashboard</b><br/><sub>Users / volume / provider health / 24h trend</sub></td>
<td align="center"><b>🧩 Model management</b><br/><sub>Per-model capabilities, pricing & weight</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/screenshots/accounts.png" alt="Accounts" /></td>
<td width="50%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
</tr>
<tr>
<td align="center"><b>🔑 Account pools</b><br/><sub>Multi-account pools · weight / concurrency · CRUD</sub></td>
<td align="center"><b>📜 Call logs</b><br/><sub>Success / failure / in-progress · prompts & latency</sub></td>
</tr>
</table>
## 🚀 Features ## 🚀 Features
#### 🎨 Generation #### 🎨 Generation
- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference) - Image + video in one place, with multi-modal references (images / first-last frames / video / audio) per model capability
- Multiple resolutions (images 1K / 2K / 4K · videos 720p / 1080p), aspect ratios and video durations — configured and priced per model - Multiple resolutions (1K/2K/4K images · 720p/1080p/768p video), aspect ratios, discrete or per-second video durations — all per-model configurable and priced
- 7 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes - **Priced = listed**: no enable switch — a model goes public once every tier is priced and an enabled channel serves it
- **Model aliases**: one model can expose multiple public ids — API calls with any alias resolve to it - **Model aliases**: one upstream model can map to several public ids
- **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager - Optional **de-AI** post-processing on generated images (per-tier surcharge)
#### 🔌 OpenAI Compatible #### 🔁 Channel routing
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` · balance `/v1/user/balance` (remaining / cumulative used) - **Save = full sync**: storing a channel pulls the upstream `/v1/models`, binds everything, unbinds what disappeared and deletes orphans
- **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK - **Weight + concurrency-aware** dispatch with failover; upstream 4xx rejections pass through in Chinese, transient errors move to the next channel
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table - Channel keys stored AES-GCM encrypted (`CHANNEL_SECRET`); upstream balance probed live
#### 🔁 Account Pools + Smart Failover #### 🔌 vivid-protocol API
- Round-robin scheduling across the pool; one bad account doesn't break the whole - `POST /v1/generate` — NDJSON stream: first frame returns `jobId`, ~25s blank-line heartbeats, terminal `succeeded`/`failed`
- **Out of quota → switch** · **auth expired → refresh & retry / kill** · **transient → retry same account ×3** · **bad params → fail fast** - `POST /v1/uploads` — presigned PUT direct-to-storage for reference files
- **Pre-deducted credits**: atomic debit before generation, auto-refunded on failure, no over-spend under concurrency - `GET /v1/models` — bare array with capability specs · `GET /v1/balance` — balance
- **jobId reconnect**: resubmit with only `jobId` to resume a dropped stream — no duplicate job or charge; pending jobs resume after a server restart too
- Unified errors `{"error":{"code","message"}}` (4001 rejected / 4002 insufficient / 4011 unauthorized / 4293 concurrency / 5001 server); API keys are `vk_` + 48 hex
#### 🔐 Automatic Token Keep-alive #### 💳 Billing & operations
- Single-use rotating tokens (Krea / Imagine) are **renewed proactively 10 minutes before expiry**; new tokens persisted automatically - Credits (pre-charge + auto-refund), per model/tier/duration pricing; de-AI surcharge per tier
- Adobe cookies exchanged for fresh tokens on a schedule; bare JWTs killed on expiry - **Reseller pricing**: reseller role + per-model reseller prices (API calls included)
- Daily quota recovered at each provider's reset time, then re-probed for the real balance - **Online payment (epay)**: WeChat/Alipay QR, fixed + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callbacks
- **CDK codes** · invite rewards · email register / verification / password reset
- **Concurrency groups**: per-group in-flight caps across UI + API (`0` = unlimited)
- Roles: user / reseller / admin
#### 💳 Billing & Operations #### 🛠️ Admin console
- Credit-based (**pre-deduct + refund on failure**), priced per model / resolution / duration; de-AI fingerprint adds a per-tier surcharge - Dashboard (trends / DAU / top failures / top spenders)
- **Agent pricing**: a user can be set as an "agent" role and models can carry agent prices; agent users (including their API key calls) are billed at the agent price, falling back to the normal price when unset - **Channels** (save-to-sync, upstream balance, enable, concurrency, weight) · models (standard + reseller pricing, aliases, read-only listable state) · concurrency groups · orders · global logs · users · CDK · media manager · showcase · announcements · site config
- **Online top-up (易支付 / epay)**: WeChat / Alipay QR, preset + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callback auto-credits; cumulative top-up tracked - **Banned words**: prompt hits rejected, per-word / per-user trigger stats
- **CDK redeem codes** · **referral rewards** · email sign-up / verification code / password reset
- **Concurrency groups**: cap a user's simultaneous generations (playground + API key combined, `0` = unlimited), self-healing Redis counters, new users auto-join the default group
- Three roles: regular user / agent / admin (single)
#### 🖥️ User Frontend (Vue 3) **🧰 Engineering**: S3/RustFS media behind an authenticated proxy + retention cleanup · presigned reference uploads · self-healing maintenance (stale-job resume/refund, expired file pruning) · one-command Docker deploy (TLS is your reverse proxy's job).
- Playground · creations gallery · generation logs (with failure reasons / source tags)
- Gallery **multi-select batch ops**: select page / bulk delete (videos take their frame stills along) / bulk download (multiple files auto-packed into a zip, fetched concurrently)
- Lightbox preview with built-in **copy original / download / close** buttons; one-click copy of the original image from cards
- **Top-up · Orders** (recharge history / resume unpaid) · API docs · API key management · referral · about, light / dark theme
- **In-app announcements**: a Markdown notice pops up after login and re-shows whenever its content changes
#### 🛠️ Admin Console ## 🔌 Channel architecture
- Overview dashboard (trends / DAU / top failures / top spenders)
- Model management (normal + agent price + aliases) · account management (bulk import / dedup / quota) · **concurrency groups** · **order management** (filter / search / paginate) · site-wide logs · user management (set as agent / assign concurrency group / view cumulative top-up / banned-word hits) · CDK · image management (multi-select bulk delete / zip download) · showcase · **announcements** · site config (incl. epay, de-AI fingerprint toggle & surcharge pricing)
- **Banned words**: add / remove words in the console (paginated + multi-select bulk delete); prompts containing a banned word are rejected outright (playground + API, case-insensitive), with per-word / per-user hit counters
**🧰 Engineering highlights**: tls-client (Chrome JA3/JA4 fingerprint) reliably passes Cloudflare · media stored in S3/RustFS, served through an authenticated proxy with retention cleanup · self-healing maintenance loop (quota recovery / credential refresh / orphan-job cleanup with refunds) · one-command Docker deploy (TLS via your own reverse proxy). ```
caller ──vivid protocol──▶ image2api ──vivid protocol──▶ upstream vividai A (weight w, conc c)
## 🤖 Supported Models / Providers (this host) ├─ upstream vividai B
└─ …any number
| Provider | Models (examples) | Type |
|---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
| **OpenAI** | gpt-image-2 | Image |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | Video / Image |
| **Grok (console.x.ai)** | grok-video (imagine text/image-to-video), grok-image / grok-image-quality (imagine text/image-to-image) | Video / Image |
| **Leonardo.ai** | seedream-4.5 | Image |
| **Krea.ai** | flux-klein-2 | Image |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image |
> Models are enabled and priced dynamically from the admin console — add or remove anytime.
## 🔌 OpenAI-Compatible API
```bash
# Text-to-image — pure OpenAI params: size drives both aspect ratio + tier (long edge <1800→1K / <3500→2K / ≥3500→4K)
curl https://your-domain/v1/images/generations \
-H "Authorization: Bearer sk-xxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "a cute cat on a desk, studio lighting",
"size": "2048x2048"
}'
# Image-to-image — multipart reference upload (multiple via image[])
curl https://your-domain/v1/images/edits \
-H "Authorization: Bearer sk-xxxx" \
-F model="seedream-4.5" -F prompt="make it cyberpunk" -F image=@input.png
``` ```
Images return OpenAI-style `{ "created": ..., "data": [{ "b64_json": "..." }] }` (raw base64, no `data:` prefix, nothing stored server-side). **Video** is async: `POST /v1/videos` → poll `GET /v1/videos/{id}` until `completed` → `GET /v1/videos/{id}/content` for the mp4. Full parameters are documented on the in-app **/docs** page. - A channel = a homogeneous upstream: `base_url` + `vk_` key + enable switch + concurrency cap + routing weight
- Models come from the upstream `/v1/models` sync; locally they only get aliases and pricing; **one model can fail over across channels**
- This service speaks the same protocol externally — it can itself be mounted as someone's channel, infinitely nestable
- **No OpenAI-protocol upstreams**: a channel must be a vivid-protocol instance
## 📡 vivid-protocol API
```bash
# generate — NDJSON stream; first frame returns jobId
curl -N https://your-domain/v1/generate \
-H "Authorization: Bearer vk_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
# → {"jobId":"…","status":"running"} …heartbeats… {"status":"succeeded","outputs":[{"url":"…"}]}
# reconnect — resubmit with only jobId, no duplicate job
curl -N https://your-domain/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<jobId>"}'
# reference files — presigned upload session, then PUT the bytes
curl https://your-domain/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
curl https://your-domain/v1/models -H "Authorization: Bearer vk_xxxx"
curl https://your-domain/v1/balance -H "Authorization: Bearer vk_xxxx"
```
Full parameters, the error-code table and Python examples live on the in-app **/docs** page.
## 🚀 Deployment ## 🚀 Deployment
> Domain + HTTPS are handled by your own reverse proxy (this project issues no certificates). **Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx listens on container **HTTP :2000**); point your reverse proxy at `http://<host>:2000`.
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx serving **HTTP on container port 2000**); point your reverse proxy at `http://<host>:2000` (port via `WEB_PORT`; edit the values (passwords / keys / `CORS_ORIGINS`, and `COOKIE_SECURE=true` when your proxy serves HTTPS) directly in `docker-compose.yml`). > ⚠️ Change the secrets in `docker-compose.yml` before deploying — especially **`CHANNEL_SECRET`** (encrypts stored channel keys; rotating it makes saved keys undecryptable).
Or **build from source** — bring your own **PostgreSQL · Redis · RustFS (or any S3) · reverse proxy**: Manual build needs PostgreSQL · Redis · RustFS (or any S3) · a reverse proxy — see `backend/.env.example` for all env vars.
```bash After deploy: admin → Channels → add channel (upstream URL + `vk_` key) → models auto-sync → price them in 模型管理 → done.
# 1. Create an empty database (the backend auto-migrates on start)
createdb vivid_ai
# 2. Configure and build the backend from source ## 🧱 Stack
cat > backend/.env <<'EOF'
APP_ENV=production
HTTP_ADDR=127.0.0.1:6666
POSTGRES_DSN=host=127.0.0.1 user=postgres password=YOUR_PASSWORD dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
REDIS_ADDR=127.0.0.1:6379
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_BUCKET=vivid-ai
RUSTFS_ACCESS_KEY=YOUR_AK
RUSTFS_SECRET_KEY=YOUR_SK
CORS_ORIGINS=https://your-domain
COOKIE_SECURE=true
EOF
cd backend && go build -o bin/api ./cmd/api && ./bin/api # listens on 127.0.0.1:6666
# 3. Build the frontend (output in frontend/dist) | Layer | Tech |
cd frontend && npm install && npm run build
```
Nginx reverse proxy (issue the certificate yourself with certbot / acme.sh):
```nginx
server {
listen 443 ssl;
server_name your-domain;
ssl_certificate /path/fullchain.pem;
ssl_certificate_key /path/privkey.pem;
root /path/to/frontend/dist;
index index.html;
client_max_body_size 50m;
proxy_read_timeout 600s; # video generation can take a while
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
location ^~ /images/ { proxy_pass http://127.0.0.1:6666; }
location = /health { proxy_pass http://127.0.0.1:6666; }
location ^~ /v1/ { proxy_pass http://127.0.0.1:6666; add_header Cache-Control "no-store" always; }
}
```
> See `backend/.env.example` for the full set of environment variables.
</details>
## 🧱 Tech Stack
| Layer | Technology |
|---|---| |---|---|
| Backend | Go · gin · gorm (PostgreSQL) · go-redis · tls-client (Chrome fingerprint) | | Backend | Go · gin · gorm (PostgreSQL) · go-redis |
| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 | | Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
| Infrastructure | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx | | Infra | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
## 📦 Repository Layout ## 💬 Contact
```
backend/ Backend source (Go)
├── cmd/
│ ├── api/ Service entry point (main)
│ └── marklabel/ Ops helper (mark accounts on demand)
├── internal/
│ ├── bootstrap/ App wiring, scheduled maintenance startup
│ ├── config/ Env-var configuration loading
│ ├── http/
│ │ ├── handler/ HTTP handlers (v1-compatible API, admin, auth…)
│ │ ├── middleware/ Auth / request-id middleware
│ │ └── router/ Route registration
│ ├── model/ GORM data models
│ ├── provider/ Upstream provider clients
│ │ ├── adobe/ Adobe Firefly (tls-client fingerprint)
│ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile)
│ │ ├── runway/ Runway video + Nano Banana image
│ │ ├── grok/ Grok (console.x.ai DPoP, video / image; account state via grok.com)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
│ │ ├── custom/ Custom upstream (OpenAI-compatible v1, routed by id)
│ │ └── epay/ 易支付 / epay (mapi order + MD5-verified callback, top-ups)
│ ├── repo/ Data-access layer (users / models / accounts / logs / CDK / orders / concurrency groups…)
│ ├── service/ Business logic (scheduling, billing, account pools, keep-alive, maintenance)
│ └── storage/ RustFS / S3 media storage
├── Dockerfile Multi-stage build (compile source → slim runtime image)
└── .env.example Backend env-var template
frontend/ Frontend source (Vue 3 + Vite)
├── src/
│ ├── views/ Pages (playground / accounts / models / users / concurrency / orders / logs / overview / top-up / settings…)
│ ├── components/ Reusable components (modals / selectors / lightbox…)
│ ├── layouts/ Public / admin layouts
│ ├── utils/ Utility functions
│ └── api.js · auth.js … API client, auth, theme, credits, etc.
├── Dockerfile Nginx static hosting (HTTP :2000) + API proxy
└── default.conf.template Nginx site template (reverse proxy + caching)
docker-compose.yml Docker orchestration (Postgres / Redis / RustFS / backend / frontend)
```
## 🗺️ Roadmap
- [ ] More upstream providers
- [ ] Usage analytics / export
- [ ] Multi-language UI (i18n)
- [ ] Webhook / async callbacks
## 💬 Community / Contact
| | | | | |
|---|---| |---|---|
| 🌐 Website | **[vividai.run](https://vividai.run)** | | 🌐 Site | **[vividai.run](https://vividai.run)** |
| 👥 QQ Group | **1106849765** · [Join](https://qm.qq.com/q/976LeMFoHu) | | 👥 QQ group | **1106849765** · [join](https://qm.qq.com/q/976LeMFoHu) |
| 🐧 QQ | **1114639355** · [Add](https://qm.qq.com/q/ItgCcNA7ac) | | 🐧 QQ | **1114639355** · [add](https://qm.qq.com/q/ItgCcNA7ac) |
| 🛒 Shop | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
| ✉️ Email | vividairun@gmail.com | | ✉️ Email | vividairun@gmail.com |
## ⭐ Star History
<!-- After creating the GitHub repo, uncomment the line below and replace OWNER with your username to show the chart: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
If you find this useful, give it a ⭐ — uncomment the line above after creating the repo to show the Star History chart.
## 📄 License ## 📄 License
This project (frontend + backend) is open-source under the [MIT](LICENSE) license — free to use, modify, commercialize and redistribute. MIT — see [LICENSE](LICENSE).
<div align="center">
If this project helps you, a ⭐ Star is much appreciated!
</div>
+86 -143
View File
@@ -1,10 +1,10 @@
<div align="center"> <div align="center">
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" /> <img src="frontend/public/favicon.svg" width="84" alt="image2api" />
<h1>image2api</h1> <h1>image2api</h1>
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合七大平台,开箱即用的运营系统** **vivid 协议 AI 生图 / 生视频网关 —— 渠道化接入同构上游,开箱即用的运营系统**
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub> <sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
@@ -15,13 +15,10 @@
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org) [![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署) [![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-兼容-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-反代自理-lightgrey)](#-部署)
[![Providers](https://img.shields.io/badge/供应商-7%20平台-orange)](#-支持的模型--供应商)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署) [![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license) [![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-openai-兼容-api) · [交流群](#-交流--联系) [在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-vivid-协议-api) · [交流群](#-交流--联系)
<br/> <br/>
@@ -36,24 +33,23 @@
- [简介](#-简介) - [简介](#-简介)
- [界面预览](#-界面预览) - [界面预览](#-界面预览)
- [核心功能](#-核心功能) - [核心功能](#-核心功能)
- [支持的模型 / 供应商](#-支持的模型--供应商) - [渠道架构](#-渠道架构)
- [OpenAI 兼容 API](#-openai-兼容-api) - [vivid 协议 API](#-vivid-协议-api)
- [部署](#-部署) - [部署](#-部署)
- [技术栈](#-技术栈) - [技术栈](#-技术栈)
- [仓库结构](#-仓库结构) - [仓库结构](#-仓库结构)
- [Roadmap](#-roadmap)
- [交流 / 联系](#-交流--联系) - [交流 / 联系](#-交流--联系)
- [License](#-license) - [License](#-license)
## ✨ 简介 ## ✨ 简介
**image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台,以及**任意 OpenAI 兼容上游**的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 权重优先 + 并发感知、额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。 **image2api** 是一个同构网关:对上,把任意多个 **vivid 协议(vividai)实例**接成「渠道」统一调度;对下,对外暴露同一套 **vivid 协议 API**(`POST /v1/generate` NDJSON 流 · `POST /v1/uploads` 预签名直传 · `GET /v1/models` · `GET /v1/balance`)。没有内置供应商代码、没有账号池 —— 上游就是另一个 vividai,套娃式组网任意层叠。
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。 它不只是 API 网关:自带**积分计费、CDK 充值、在线充值(易支付)、邀请奖励、代理价体系、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)** 即基于本项目搭建。
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。 > 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
**一句话亮点** 🔌 OpenAI 兼容 · 🤖 7 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 在线充值(易支付)+ 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理) **一句话亮点** 🔁 vivid 协议渠道组网 · 📦 保存渠道即全量同步模型 · 💳 积分 + 充值 + 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
## 🖼️ 界面预览 ## 🖼️ 界面预览
@@ -63,19 +59,13 @@
<table> <table>
<tr> <tr>
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td> <td width="33%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
<td width="50%"><img src="docs/screenshots/models.png" alt="模型管理" /></td> <td width="33%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
<td width="33%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / Provider 健康 / 24h 趋势</sub></td> <td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / 渠道健康 / 24h 趋势</sub></td>
<td align="center"><b>🧩 模型管理</b><br/><sub>按模型独立配置能力、定价与权重</sub></td> <td align="center"><b>🧩 模型管理</b><br/><sub>按模型独立配置能力、定价与权重,配价即上架</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/screenshots/accounts.png" alt="账号池管理" /></td>
<td width="50%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
</tr>
<tr>
<td align="center"><b>🔑 账号池管理</b><br/><sub>多账号池 · 权重 / 并发 · 一键增删改</sub></td>
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td> <td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
</tr> </tr>
</table> </table>
@@ -83,117 +73,98 @@
## 🚀 核心功能 ## 🚀 核心功能
#### 🎨 生成能力 #### 🎨 生成能力
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考) - 生图 + 生视频一站式,支持**参考图 / 首末帧 / 视频 / 音频**等多模态参考(按模型能力声明下发)
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p)、多宽高比、视频多时长,按模型独立配置与定价 - 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p / 768p)、多宽高比、视频多时长(离散档或区间档按秒计价),按模型独立配置与定价
- 7 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码 - **配价即上架**:模型无启停开关,全部档位有价 + 绑定了启用渠道即对外可见;不定价 = 隐藏
- **模型别名**:同一模型可配多个对外 id,API 调用任意别名均可命中 - **模型别名**:同一上游模型可配多个对外 id,API 调用任意别名均可命中
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识 - **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理,按画质档位加收积分(后台可改价、可整体关闭)
#### 🔌 OpenAI 兼容 #### 🔁 渠道调度
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用) - **保存渠道即全量同步**:填上游 `base_url` + `vk_` key 保存,自动拉取上游 `/v1/models` 全部入库绑定;上游消失的模型自动解绑,无其他渠道的孤儿模型自动清理
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK - **权重 + 并发感知调度**:渠道可配权重(加权随机)与并发上限,逐渠道尝试、失败自动故障转移
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么 - **错误分类**:上游 4xx(参数/额度)直接透传中文拒绝理由、不重试;网络/5xx 换渠道重试
- 渠道 API Key 以 AES-GCM 加密落库(`CHANNEL_SECRET`),上游余额实时探测
#### 🔁 多账号池 + 智能故障转移 #### 🔌 vivid 协议 API
- 账号池调度,单账号出错不影响整体 - `POST /v1/generate` —— NDJSON 流式生成:首行回执 `jobId`,约 25s 空行心跳,终帧 `succeeded`/`failed`
- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定 - `POST /v1/uploads` —— 预签名 PUT 直传参考文件到对象存储
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错** - `GET /v1/models` —— 裸数组,含能力规格(清晰度档位 / 比例 / 时长 / 参考文件上限)
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额 - `GET /v1/balance` —— 余额查询
- **jobId 断流重连**:断流后仅带 `jobId` 重发同一请求即可续等,不重复下单、不重复扣费;进程重启后带 `jobId` 的任务也会自动续等
#### 🔗 自定义上游聚合(OpenAI 兼容) - 统一错误包 `{"error":{"code","message"}}`(4001 参数拒绝 / 4002 积分不足 / 4011 未授权 / 4293 并发上限 / 5001 服务异常);API Key 为 `vk_` + 48 hex
- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
#### 🔐 Token 自动保活
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
- 每日额度按平台重置时间自动恢复 + 重新探测真实余额
#### 💳 计费与运营 #### 💳 计费与运营
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分 - 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价 - **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账;累计充值可查 - **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码 - **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组 - **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
- 三级角色:普通用户 / 代理 / 管理员(唯一) - 三级角色:普通用户 / 代理 / 管理员
#### 🖥️ 用户前台(Vue 3) #### 🖥️ 用户前台(Vue 3)
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签) - 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
- 作品画廊**多选批量操作**:全选本页 / 批量删除(视频连带首尾帧图) / 批量下载(多个自动打包 zip,并发拉取) - 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
- 预览大图内置**复制原图 / 下载 / 关闭**按钮,卡片一键复制原图 - **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 亮 / 暗主题
- **充值 · 订单**(充值记录 / 未付可继续支付) · API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题 - **站内公告**:登录后自动弹出 Markdown 公告
- **站内公告**:登录后自动弹出 Markdown 公告,内容更新即重推
#### 🛠️ 管理后台 #### 🛠️ 管理后台
- 概览看板(趋势 / DAU / 失败 Top / 消费榜) - 概览看板(趋势 / DAU / 失败 Top / 消费榜)
- 模型管理(普通价 + 代理价 + 别名) · 账号管理(批量导入 / 去重 / 额度) · **并发分组** · **订单管理**(筛选 / 搜索 / 分页) · 全站日志 · 用户管理(设为代理 / 分配并发组 / 看累计充值 / 违禁触发次数) · CDK · 图片管理(多选批量删除 / zip 打包下载) · 展示位 · **站点公告** · 站点配置(含易支付、去AI特征开关与附加价格) - **渠道管理**(保存即同步模型 / 上游余额 / 启用开关 / 并发 / 权重)· 模型管理(普通价 + 代理价 + 别名,上架状态只读)· **并发分组** · **订单管理** · 全站日志 · 用户管理(设为代理 / 分配并发组)· CDK · 图片管理 · 展示位 · **站点公告** · 站点配置(易支付、去AI特征等)
- **违禁词管理**:后台增删违禁词(分页 + 多选批量删除),提示词命中即拦截(画图台 + API,不区分大小写),按词 / 按用户统计触发次数 - **违禁词管理**:提示词命中即拦截,按词 / 按用户统计触发次数
**🧰 工程亮点**:tls-client(Chrome JA3/JA4 指纹)稳定穿透 Cloudflare · 媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 自愈式维护轮询(恢复额度 / 刷新凭据 / 清理僵死任务并退款) · 一条命令 Docker 部署(TLS 交给你的反代)。 **🧰 工程亮点**:媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 预签名直传参考文件 · 自愈式维护轮询(僵死任务续等 / 退款、过期文件清理)· 一条命令 Docker 部署(TLS 交给你的反代)。
## 🤖 支持的模型 / 供应商 ## 🔌 渠道架构
| 供应商 | 模型(示例) | 类型 | ```
|---|---|---| 调用方 ──vivid 协议──▶ image2api ──vivid 协议──▶ 上游 vividai 实例 A (渠道, 权重 w, 并发 c)
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 | (本服务) ├─ 上游 vividai 实例 B
| **OpenAI** | gpt-image-2 | 图像 | └─ …任意多个
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | 视频 / 图像 |
| **Grok(console.x.ai)** | grok-video(imagine 文生 / 图生视频)、grok-image / grok-image-quality(imagine 文生图 / 图生图) | 视频 / 图像 |
| **Leonardo.ai** | seedream-4.5 | 图像 |
| **Krea.ai** | flux-klein-2 | 图像 |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
| **自定义上游** | 任意 OpenAI 兼容 v1 端点(按 id 路由) | 图像 / 视频 |
> 模型由管理后台动态启用并定价,可随时增删。自定义上游支持把任何 OpenAI 兼容服务接成账号,按 model id 路由调用。
## 🔌 OpenAI 兼容 API
```bash
# 文生图 —— 纯 OpenAI 参数:size 同时决定比例 + 分辨率档(长边 <1800→1K / <3500→2K / ≥3500→4K)
curl https://你的域名/v1/images/generations \
-H "Authorization: Bearer sk-xxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "a cute cat on a desk, studio lighting",
"size": "2048x2048"
}'
# 图生图 —— multipart 上传参考图(可多张 image[])
curl https://你的域名/v1/images/edits \
-H "Authorization: Bearer sk-xxxx" \
-F model="seedream-4.5" -F prompt="改成赛博朋克风格" -F image=@input.png
``` ```
图片返回 OpenAI 风格 `{ "created": ..., "data": [{ "b64_json": "..." }] }`(原始 base64,无 `data:` 前缀,服务端不留存)。**视频**走异步:`POST /v1/videos` 建任务 → 轮询 `GET /v1/videos/{id}` 至 `completed` → `GET /v1/videos/{id}/content` 取 mp4。完整参数见站内 **/docs** 文档页。 - 渠道 = 一个同构上游:`base_url` + `vk_` API Key + 启用开关 + 并发上限 + 调度权重
- 模型来自渠道同步(上游 `/v1/models`),本地只做别名映射与定价;**模型可在多个渠道间故障转移**
- 本服务对外同样说 vivid 协议 —— 所以它自己也能被接成别人的渠道,无限层叠组网
- **不支持接 OpenAI 协议上游**:上游必须是 vivid 协议实例
## 📡 vivid 协议 API
```bash
# 文生图 / 生视频 —— NDJSON 流,首行回执 jobId
curl -N https://你的域名/v1/generate \
-H "Authorization: Bearer vk_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
# → {"jobId":"…","status":"running"} …空行心跳… {"status":"succeeded","outputs":[{"url":"…"}]}
# 断流重连:只带 jobId 重发,不重复下单
curl -N https://你的域名/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<上文 jobId>"}'
# 参考文件:先拿预签名上传会话,PUT 直传后拿 ref key
curl https://你的域名/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
# 模型清单(裸数组)与余额
curl https://你的域名/v1/models -H "Authorization: Bearer vk_xxxx"
curl https://你的域名/v1/balance -H "Authorization: Bearer vk_xxxx"
```
完整参数、错误码表与 Python 示例见站内 **/docs** 文档页。
## 🚀 部署 ## 🚀 部署
> 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。 > 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`(端口用 `WEB_PORT` 改;要改密码 / 密钥 / `CORS_ORIGINS`(反代走 HTTPS 时把 `COOKIE_SECURE` 设为 `true`),直接改 `docker-compose.yml` 里对应值即可)。 **Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`。
也可**从源码手动构建**,自备 **PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理**: > ⚠️ 部署前把 `docker-compose.yml` 里的密码 / 密钥改掉 —— 特别是 **`CHANNEL_SECRET`**(渠道上游 Key 的加密密钥,变更后已存渠道密钥无法解密)。
也可**从源码手动构建**,自备 PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理:
```bash ```bash
# 1. 创建空库(后端启动自动建表) # 1. 创建空库(后端启动自动建表)
createdb vivid_ai createdb vivid_ai
# 2. 配置并从源码构建后端 # 2. 配置并从源码构建后端 —— 完整环境变量见 backend/.env.example
cat > backend/.env <<'EOF'
APP_ENV=production
HTTP_ADDR=127.0.0.1:6666
POSTGRES_DSN=host=127.0.0.1 user=postgres password=你的密码 dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
REDIS_ADDR=127.0.0.1:6379
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_BUCKET=vivid-ai
RUSTFS_ACCESS_KEY=你的AK
RUSTFS_SECRET_KEY=你的SK
CORS_ORIGINS=https://你的域名
COOKIE_SECURE=true
EOF
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666 cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
# 3. 构建前端(产物 frontend/dist) # 3. 构建前端(产物 frontend/dist)
@@ -212,7 +183,6 @@ server {
index index.html; index index.html;
client_max_body_size 50m; client_max_body_size 50m;
proxy_read_timeout 600s; # 视频生成耗时长 proxy_read_timeout 600s; # 视频生成耗时长
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; } location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; } location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
@@ -222,13 +192,13 @@ server {
} }
``` ```
> 完整环境变量见 `backend/.env.example`。 部署后:后台「渠道」页新建渠道(上游 URL + `vk_` key)→ 保存即自动同步全部上游模型 → 模型管理里配价 → 画图台 / 对外 API 立即可用。
## 🧱 技术栈 ## 🧱 技术栈
| 层 | 技术 | | 层 | 技术 |
|---|---| |---|---|
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis · tls-client(Chrome 指纹) | | 后端 | Go · gin · gorm(PostgreSQL)· go-redis |
| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 | | 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx | | 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
@@ -236,54 +206,34 @@ server {
``` ```
backend/ 后端源码(Go) backend/ 后端源码(Go)
├── cmd/ ├── cmd/api/ 服务入口(main)
│ ├── api/ 服务入口(main)
│ └── marklabel/ 运维小工具(按需标记账号)
├── internal/ ├── internal/
│ ├── bootstrap/ 应用装配、定时维护任务启动 │ ├── bootstrap/ 应用装配、定时维护任务启动
│ ├── config/ 环境变量配置加载 │ ├── config/ 环境变量配置加载
│ ├── http/ │ ├── http/
│ │ ├── handler/ HTTP 处理器(v1 兼容接口、后台、鉴权…) │ │ ├── handler/ HTTP 处理器(/v1 vivid 协议、渠道管理、鉴权…)
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件 │ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
│ │ └── router/ 路由注册 │ │ └── router/ 路由注册
│ ├── model/ GORM 数据模型 │ ├── model/ GORM 数据模型(渠道 / 模型 / 用户 / 事件…)
│ ├── provider/ 各上游供应商客户端 │ ├── upstream/ vivid 协议上游客户端(NDJSON / 上传 / 模型 / 余额)
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹) │ ├── repo/ 数据访问层
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile) │ ├── service/ 业务逻辑(渠道调度、计费、维护、v1 job 生命周期)
│ │ ├── runway/ Runway 视频 + Nano Banana 图像 │ └── storage/ RustFS / S3 媒体存储(预签名 PUT/GET)
│ │ ├── grok/ Grok(console.x.ai DPoP,视频 / 图像;账号态走 grok.com) ├── Dockerfile 多阶段构建
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
│ │ ├── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
│ │ └── epay/ 易支付(mapi 下单 + 回调 MD5 验签,积分充值)
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK / 订单 / 并发组…)
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
│ └── storage/ RustFS / S3 媒体存储
├── Dockerfile 多阶段构建(源码编译 → 精简运行镜像)
└── .env.example 后端环境变量模板 └── .env.example 后端环境变量模板
frontend/ 前端源码(Vue 3 + Vite) frontend/ 前端源码(Vue 3 + Vite)
├── src/ ├── src/
│ ├── views/ 页面(画图台 / 账号 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…) │ ├── views/ 页面(画图台 / 渠道 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…) │ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
│ ├── layouts/ 公共 / 后台布局 │ ├── layouts/ 公共 / 后台布局
│ ├── utils/ 工具函数
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等 │ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代 ├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
└── default.conf.template Nginx 站点模板(反代 + 缓存策略) └── default.conf.template Nginx 站点模板(反代 + 缓存策略)
docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端) docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端)
.env.example → backend/.env 后端环境变量模板
``` ```
## 🗺️ Roadmap
- [ ] 更多上游供应商接入
- [ ] 用量统计 / 导出
- [ ] 多语言界面(i18n)
- [ ] Webhook / 异步回调
## 💬 交流 / 联系 ## 💬 交流 / 联系
| | | | | |
@@ -294,13 +244,6 @@ docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端
| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** | | 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
| ✉️ 邮箱 | vividairun@gmail.com | | ✉️ 邮箱 | vividairun@gmail.com |
## ⭐ Star History
<!-- 建好 GitHub 仓库后,取消下面这行注释并把 OWNER 换成你的用户名即可显示趋势图: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。
## 📄 License ## 📄 License
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。 本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
+18 -4
View File
@@ -1,8 +1,8 @@
# Vivid AI backend config template. Copy to .env and fill in real values. # image2api backend config template. Copy to .env and fill in real values.
# Real environment variables override .env. # Real environment variables override .env.
APP_ENV=development APP_ENV=development
APP_TITLE=Vivid AI APP_TITLE=image2api
HTTP_ADDR=:6666 HTTP_ADDR=:6666
# PostgreSQL (database must already exist; tables auto-migrate on boot) # PostgreSQL (database must already exist; tables auto-migrate on boot)
@@ -13,8 +13,22 @@ REDIS_ADDR=127.0.0.1:6379
REDIS_PASSWORD= REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
# RustFS / S3-compatible object storage (generated media + reference uploads)
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_BUCKET=vivid-ai
RUSTFS_ACCESS_KEY=YOUR_AK
RUSTFS_SECRET_KEY=YOUR_SK
# Channel upstream API keys are stored AES-GCM encrypted with this secret.
# Set a random value in production and keep it stable — changing it makes
# previously saved channel keys undecryptable.
CHANNEL_SECRET=change-me-channel-secret
# Frontend dev origins allowed for CORS # Frontend dev origins allowed for CORS
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional: override generated media root (defaults to ../../ai-gateway/data/generated) # Set to true when serving the site over HTTPS (session cookie Secure flag)
# GENERATED_ROOT= COOKIE_SECURE=false
# SESSION_COOKIE_NAME=vivid_session
# GENERATED_ROOT= # optional: override generated media root
+19 -47
View File
@@ -1,55 +1,27 @@
# Vivid AI Backend # image2api backend
Go backend for `vivid-ai`, using: Go backend for `image2api` — a vivid-protocol gateway that routes generation
jobs across same-protocol upstream channels. Stack: Gin · GORM (PostgreSQL) ·
go-redis.
- Gin ## Architecture sketch
- GORM
- PostgreSQL
- Redis
## Current scope - `internal/upstream` — vivid-protocol client (NDJSON generate + jobId resume,
`/v1/uploads`, `/v1/models`, `/v1/balance`, error classification)
This is an in-progress rewrite. The current skeleton already includes: - `internal/service/channel.go` — channel CRUD, upstream model sync (save =
full sync), AES-GCM key storage
- app bootstrap - `internal/service/v1.go` — channel-weighted dispatch, billing, job lifecycle,
- PostgreSQL and Redis initialization stale-job resume
- GORM auto-migrations - `internal/http/handler/v1.go` — public vivid endpoints
- session storage in Redis (`/v1/generate` `/v1/uploads` `/v1/models` `/v1/balance`)
- image access control for `/images/:user/:name`
- public site endpoint: `/admin/api/site`
- public showcase endpoint: `/admin/api/showcase`
- session-based auth endpoint: `/admin/api/auth/me`
## Environment ## Environment
Set these before running: See `.env.example`. Required: `POSTGRES_DSN`, `REDIS_ADDR`, `RUSTFS_*`.
Important: `CHANNEL_SECRET` encrypts stored channel API keys (AES-GCM) — set a
random value and keep it stable; rotating it makes saved keys undecryptable.
```powershell ```bash
$env:POSTGRES_DSN="host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai" cp .env.example .env # fill in values
$env:REDIS_ADDR="127.0.0.1:6379" go build -o bin/api ./cmd/api && ./bin/api
$env:HTTP_ADDR=":6061"
``` ```
Optional:
```powershell
$env:APP_ENV="development"
$env:APP_TITLE="Vivid AI"
$env:SESSION_COOKIE_NAME="vivid_session"
$env:CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173"
```
## Run
```powershell
go run ./cmd/api
```
## Notes
- Generated media defaults to `../../ai-gateway/data/generated` relative to the backend working directory.
- Private images require either:
- session cookie
- bearer session token
- bearer API key
- Showcase images are public.
+12 -1
View File
@@ -68,6 +68,10 @@ func NewApp(ctx context.Context) (*App, error) {
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil { `ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
return nil, fmt.Errorf("cdk marketing index: %w", err) return nil, fmt.Errorf("cdk marketing index: %w", err)
} }
// 配价即上架(vivid 同款):models 不再有启停开关,删遗留列。
if err := db.WithContext(ctx).Exec(`ALTER TABLE model_configs DROP COLUMN IF EXISTS enabled`).Error; err != nil {
return nil, fmt.Errorf("drop model_configs.enabled: %w", err)
}
if err := seedDefaults(ctx, db); err != nil { if err := seedDefaults(ctx, db); err != nil {
return nil, fmt.Errorf("seed defaults: %w", err) return nil, fmt.Errorf("seed defaults: %w", err)
} }
@@ -115,6 +119,13 @@ func NewApp(ctx context.Context) (*App, error) {
channelSecret = cfg.PostgresDSN channelSecret = cfg.PostgresDSN
} }
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret) channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
// Sweep provider-era model rows: anything not bound to a channel can never
// be routed under the channel scheduler.
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
log.Printf("purge orphan models: %v", err)
} else if n > 0 {
log.Printf("purged %d unbound model(s) left over from the provider era", n)
}
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient) v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo) showcaseSvc := service.NewShowcaseService(showcaseRepo)
@@ -152,7 +163,7 @@ func NewApp(ctx context.Context) (*App, error) {
// Background self-healing sweep (order expiry, stale-pending cleanup+refund, // Background self-healing sweep (order expiry, stale-pending cleanup+refund,
// log/media retention). // log/media retention).
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo) maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc, showcaseRepo, orderRepo)
loopCtx, loopCancel := context.WithCancel(context.Background()) loopCtx, loopCancel := context.WithCancel(context.Background())
go maintenanceSvc.Run(loopCtx) go maintenanceSvc.Run(loopCtx)
+39 -2
View File
@@ -38,7 +38,7 @@ func (h *ChannelAdminHandler) Save(c *gin.Context) {
return return
} }
body.BaseURL = strings.TrimSpace(body.BaseURL) body.BaseURL = strings.TrimSpace(body.BaseURL)
ch, err := h.channels.Save(c.Request.Context(), body) ch, warning, err := h.channels.Save(c.Request.Context(), body)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"}) c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
@@ -47,7 +47,7 @@ func (h *ChannelAdminHandler) Save(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID}) c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID, "warning": warning})
} }
func (h *ChannelAdminHandler) Delete(c *gin.Context) { func (h *ChannelAdminHandler) Delete(c *gin.Context) {
@@ -88,6 +88,25 @@ func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": items}) c.JSON(http.StatusOK, gin.H{"data": items})
} }
// SyncModels manually re-runs the full upstream model sync on a saved channel
// (new models bound, disappeared ones unbound, orphans deleted). Fetch
// failures are errors here — unlike the save path which degrades to a warning.
func (h *ChannelAdminHandler) SyncModels(c *gin.Context) {
var body struct {
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
return
}
msg, err := h.channels.SyncModels(c.Request.Context(), body.ID)
if err != nil {
writeUpstreamErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": msg})
}
// ImportModels binds the ticked upstream models to the channel (creating local // ImportModels binds the ticked upstream models to the channel (creating local
// model rows on first import). // model rows on first import).
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) { func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
@@ -106,6 +125,24 @@ func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
// UnbindModel detaches one upstream model from a channel; the local model row
// is deleted too when no other channel still binds it.
func (h *ChannelAdminHandler) UnbindModel(c *gin.Context) {
var body struct {
ID string `json:"id"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" || strings.TrimSpace(body.Model) == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "id and model required"})
return
}
if err := h.channels.UnbindModel(c.Request.Context(), body.ID, body.Model); err != nil {
writeUpstreamErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// writeUpstreamErr translates upstream-protocol errors into sensible admin // writeUpstreamErr translates upstream-protocol errors into sensible admin
// responses (auth → 400 with the upstream's message, temporary → 502). // responses (auth → 400 with the upstream's message, temporary → 502).
func writeUpstreamErr(c *gin.Context, err error) { func writeUpstreamErr(c *gin.Context, err error) {
@@ -461,7 +461,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
} }
out := make([]gin.H, 0, len(models)) out := make([]gin.H, 0, len(models))
for _, m := range models { for _, m := range models {
if m.Type != "video" || !m.Enabled { if m.Type != "video" {
continue continue
} }
out = append(out, modelPreset(m)) out = append(out, modelPreset(m))
@@ -547,7 +547,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
} }
// publicModels — GET /api/models for the public site (docs/landing pickers). // publicModels — GET /api/models for the public site (docs/landing pickers).
// Enabled models only, in the same shape as before minus the provider id. // Fully-priced models only (配价即上架), same shape as before.
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) { func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
models, err := h.admin.Models(c.Request.Context()) models, err := h.admin.Models(c.Request.Context())
if err != nil { if err != nil {
@@ -555,7 +555,8 @@ func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
} }
items := make([]gin.H, 0, len(models)) items := make([]gin.H, 0, len(models))
for _, m := range models { for _, m := range models {
if !m.Enabled { // 配价即上架:未配齐价的模型对前台不可见(绑定/配价都满足才进 /v1)。
if !service.ModelFullyPriced(&m) {
continue continue
} }
items = append(items, gin.H{ items = append(items, gin.H{
+2
View File
@@ -146,7 +146,9 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/channels", handlers.ChannelAdmin.Save) authed.POST("/channels", handlers.ChannelAdmin.Save)
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete) authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels) authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels)
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels) authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
authed.POST("/channels/unbind-model", handlers.ChannelAdmin.UnbindModel)
authed.GET("/providers", handlers.AdminRead.Providers) authed.GET("/providers", handlers.AdminRead.Providers)
authed.GET("/images", handlers.AdminRead.Images) authed.GET("/images", handlers.AdminRead.Images)
authed.DELETE("/images", handlers.AdminRead.DeleteImage) authed.DELETE("/images", handlers.AdminRead.DeleteImage)
+1 -2
View File
@@ -126,7 +126,6 @@ type ModelConfig struct {
Name string `gorm:"size:255;not null"` Name string `gorm:"size:255;not null"`
Alias string `gorm:"column:alias;size:255;not null;default:''"` Alias string `gorm:"column:alias;size:255;not null;default:''"`
Provider string `gorm:"size:100;index;not null"` Provider string `gorm:"size:100;index;not null"`
Enabled bool `gorm:"not null;default:true"`
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用 FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
Ratios datatypes.JSON `gorm:"type:jsonb"` Ratios datatypes.JSON `gorm:"type:jsonb"`
Prices datatypes.JSONMap `gorm:"type:jsonb"` Prices datatypes.JSONMap `gorm:"type:jsonb"`
@@ -195,7 +194,7 @@ type Channel struct {
BaseURL string `gorm:"size:500;not null"` BaseURL string `gorm:"size:500;not null"`
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
Enabled bool `gorm:"not null;default:true;index"` Enabled bool `gorm:"not null;default:true;index"`
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = default. // Concurrency caps simultaneous in-flight jobs on this channel. 0 = unlimited.
Concurrency int `gorm:"not null;default:0"` Concurrency int `gorm:"not null;default:0"`
// Weight biases weighted-random channel order: weight 2 is picked first at // Weight biases weighted-random channel order: weight 2 is picked first at
// roughly 2x the rate of weight 1. // roughly 2x the rate of weight 1.
+25
View File
@@ -80,6 +80,15 @@ func (r *ChannelRepository) Unbind(ctx context.Context, channelID, modelID strin
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
} }
// UnbindExcept removes every binding of a channel whose upstream model name is
// NOT in the keep list (upstream_model, falling back to model_id for legacy
// rows) — the "true sync" counterpart used after saving a channel.
func (r *ChannelRepository) UnbindExcept(ctx context.Context, channelID string, upstreamIDs []string) error {
return r.db.WithContext(ctx).
Where("channel_id = ? AND NOT (COALESCE(NULLIF(upstream_model, ''), model_id) IN ?)", channelID, upstreamIDs).
Delete(&model.ChannelModel{}).Error
}
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error { func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
} }
@@ -158,6 +167,22 @@ func (r *ChannelRepository) UpstreamModelMap(ctx context.Context, channelID stri
return out, nil return out, nil
} }
// RoutableModelIDs returns the set of local model ids bound to at least one
// ENABLED channel — the "can actually dispatch" set behind the sellable flag.
func (r *ChannelRepository) RoutableModelIDs(ctx context.Context) (map[string]struct{}, error) {
var ids []string
err := r.db.WithContext(ctx).
Table("channel_models").
Select("DISTINCT channel_models.model_id").
Joins("JOIN channels ON channels.id = channel_models.channel_id AND channels.enabled = ?", true).
Scan(&ids).Error
out := make(map[string]struct{}, len(ids))
for _, id := range ids {
out[id] = struct{}{}
}
return out, err
}
// ChannelIDsByModel lists channel ids bound to a model (any enabled state). // ChannelIDsByModel lists channel ids bound to a model (any enabled state).
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) { func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
var ids []string var ids []string
+38 -5
View File
@@ -184,7 +184,7 @@ type DashboardWindow struct {
Pending int64 `json:"pending"` Pending int64 `json:"pending"`
Image int64 `json:"image"` Image int64 `json:"image"`
Video int64 `json:"video"` Video int64 `json:"video"`
API int64 `json:"api"` // source = 'v1' (OpenAI-compatible key) API int64 `json:"api"` // source = 'v1' (vk_ external API key)
Web int64 `json:"web"` // everything else (web / playground) Web int64 `json:"web"` // everything else (web / playground)
Spent float64 `json:"spent"` Spent float64 `json:"spent"`
} }
@@ -430,12 +430,41 @@ const stalePendingWhere = "status = ? AND ((kind = ? AND ts < ?) OR (kind <> ? A
// StaleEvent identifies a purged pending event so the caller can refund the // StaleEvent identifies a purged pending event so the caller can refund the
// credits debited up-front AND attribute the failure to the account the // credits debited up-front AND attribute the failure to the account the
// (now-abandoned) generation was using. // (now-abandoned) generation was using. UpstreamJob/Kind/Model/File/Source are
// what the channel-resume path needs to pick the job back up.
type StaleEvent struct { type StaleEvent struct {
ID string `gorm:"column:id"` ID string `gorm:"column:id"`
UserID string `gorm:"column:user_id"` UserID string `gorm:"column:user_id"`
AccountID string `gorm:"column:account_id"` AccountID string `gorm:"column:account_id"`
UpstreamJob string `gorm:"column:upstream_job"`
Kind string `gorm:"column:kind"`
Model string `gorm:"column:model"`
File string `gorm:"column:file"`
Source string `gorm:"column:source"`
Cost float64 `gorm:"column:cost"` Cost float64 `gorm:"column:cost"`
TS time.Time `gorm:"column:ts"`
}
// ResumableStale returns pending events past their per-kind deadline that still
// carry an upstream job id — a process restart orphaned the local goroutine but
// the upstream job may still be running, so these get resumed (not purged).
// Events older than maxResumeAge are excluded and fall through to PurgeStale.
func (r *EventRepository) ResumableStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
if maxAge <= 0 {
maxAge = 600 * time.Second
}
if videoMaxAge <= 0 {
videoMaxAge = maxAge
}
cutoff := time.Now().Add(-maxAge)
videoCutoff := time.Now().Add(-videoMaxAge)
resumeFloor := time.Now().Add(-maxResumeAge)
var stale []StaleEvent
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
Where(stalePendingWhere+" AND upstream_job <> '' AND ts >= ?", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
Select("id", "user_id", "account_id", "upstream_job", "kind", "model", "file", "source", "cost", "ts").
Scan(&stale).Error
return stale, err
} }
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so // PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
@@ -446,7 +475,10 @@ type StaleEvent struct {
// //
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的 // videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
// 期限扫会在它出片前就判死并退款。 // 期限扫会在它出片前就判死并退款。
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge time.Duration) ([]StaleEvent, error) { //
// Events carrying an upstream job id and younger than resumeFloor are skipped —
// ResumableStale resumes them against the channel instead of killing them.
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
if maxAge <= 0 { if maxAge <= 0 {
maxAge = 600 * time.Second maxAge = 600 * time.Second
} }
@@ -455,13 +487,14 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
} }
cutoff := time.Now().Add(-maxAge) cutoff := time.Now().Add(-maxAge)
videoCutoff := time.Now().Add(-videoMaxAge) videoCutoff := time.Now().Add(-videoMaxAge)
resumeFloor := time.Now().Add(-maxResumeAge)
var stale []StaleEvent var stale []StaleEvent
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Snapshot who/what to refund BEFORE flipping status, so a concurrent // Snapshot who/what to refund BEFORE flipping status, so a concurrent
// sweep can't double-count (the UPDATE in the same tx removes them from // sweep can't double-count (the UPDATE in the same tx removes them from
// the pending set). // the pending set).
if err := tx.Model(&model.EventLog{}). if err := tx.Model(&model.EventLog{}).
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff). Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
Select("id", "user_id", "account_id", "cost"). Select("id", "user_id", "account_id", "cost").
Scan(&stale).Error; err != nil { Scan(&stale).Error; err != nil {
return err return err
@@ -470,7 +503,7 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
return nil return nil
} }
return tx.Model(&model.EventLog{}). return tx.Model(&model.EventLog{}).
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff). Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
Updates(map[string]any{ Updates(map[string]any{
"status": "failed", "status": "failed",
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"), "error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
+4 -1
View File
@@ -84,15 +84,18 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
routable, _ := s.channels.RoutableModelIDs(ctx)
out := make([]map[string]any, 0, len(items)) out := make([]map[string]any, 0, len(items))
for _, item := range items { for _, item := range items {
_, bound := routable[item.ID]
out = append(out, map[string]any{ out = append(out, map[string]any{
"id": item.ID, "id": item.ID,
"alias": item.Alias, "alias": item.Alias,
"type": item.Type, "type": item.Type,
"name": item.Name, "name": item.Name,
"provider": item.Provider, "provider": item.Provider,
"enabled": item.Enabled, // 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
"sellable": bound && ModelFullyPriced(&item),
"ratios": repo.JSONStrings(item.Ratios), "ratios": repo.JSONStrings(item.Ratios),
"prices": map[string]any(item.Prices), "prices": map[string]any(item.Prices),
"resolutions": repo.JSONStrings(item.Resolutions), "resolutions": repo.JSONStrings(item.Resolutions),
+2 -10
View File
@@ -391,7 +391,6 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID), Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
Alias: alias, Alias: alias,
Provider: provider, Provider: provider,
Enabled: boolValueWithDefault(body["enabled"], true),
Ratios: jsonArray(body["ratios"]), Ratios: jsonArray(body["ratios"]),
Prices: prices, Prices: prices,
Resolutions: resolutions, Resolutions: resolutions,
@@ -440,13 +439,6 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
patch["provider"] = provider patch["provider"] = provider
} }
} }
// Only touch `enabled` when the caller explicitly sends a non-null value;
// mirrors Python models_store.update ("enabled" in fields and is not None).
// Without this guard a PATCH that omits the field would default it to false
// and silently disable the model.
if raw, ok := body["enabled"]; ok && raw != nil {
patch["enabled"] = boolValueWithDefault(raw, true)
}
if _, ok := body["ratios"]; ok { if _, ok := body["ratios"]; ok {
patch["ratios"] = jsonArray(body["ratios"]) patch["ratios"] = jsonArray(body["ratios"])
} }
@@ -661,8 +653,8 @@ func boolValueWithDefault(v any, fallback bool) bool {
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices: // resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
// an image model's quality tiers ARE its price keys (the admin form never sends // an image model's quality tiers ARE its price keys (the admin form never sends
// `resolutions` for images), returned in canonical 1K/2K/4K order. gpt-image-2, // `resolutions` for images), returned in canonical 1K/2K/4K order. A model that
// for example, only ever has a "1K" price, so it resolves to exactly ["1K"]. // only ever has a "1K" price, for example, resolves to exactly ["1K"].
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON { func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
out := []string{} out := []string{}
for _, r := range []string{"1K", "2K", "4K"} { for _, r := range []string{"1K", "2K", "4K"} {
+210 -14
View File
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -120,14 +121,20 @@ type SaveChannelDto struct {
Weight int `json:"weight"` Weight int `json:"weight"`
} }
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, error) { // Save upserts a channel then auto-syncs its upstream models (same semantics
// as vivid: upstream /v1/models is the truth). The returned warning is a
// non-fatal sync message — "" means fully synced.
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, string, error) {
name := strings.TrimSpace(d.Name) name := strings.TrimSpace(d.Name)
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/") base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
if base == "" { if base == "" {
return nil, errors.New("base_url required") return nil, "", errors.New("base_url required")
} }
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
return nil, errors.New("base_url must start with http:// or https://") return nil, "", errors.New("base_url must start with http:// or https://")
}
if d.Concurrency < 0 || d.Concurrency > math.MaxInt32 {
return nil, "", errors.New("并发数需在 0-2147483647 之间(0 = 不限制)")
} }
weight := d.Weight weight := d.Weight
if weight < 1 { if weight < 1 {
@@ -135,11 +142,11 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
} }
if d.ID == "" { if d.ID == "" {
if strings.TrimSpace(d.APIKey) == "" { if strings.TrimSpace(d.APIKey) == "" {
return nil, errors.New("api_key required") return nil, "", errors.New("api_key required")
} }
enc, err := s.encrypt(d.APIKey) enc, err := s.encrypt(d.APIKey)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
ch := &model.Channel{ ch := &model.Channel{
ID: "ch-" + randomUpper(10), ID: "ch-" + randomUpper(10),
@@ -153,7 +160,10 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
if ch.Name == "" { if ch.Name == "" {
ch.Name = base ch.Name = base
} }
return ch, s.channels.Create(ctx, ch) if err := s.channels.Create(ctx, ch); err != nil {
return nil, "", err
}
return ch, syncWarn(s.syncChannelModels(ctx, ch.ID)), nil
} }
patch := map[string]any{ patch := map[string]any{
"name": name, "name": name,
@@ -165,11 +175,122 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") { if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
enc, err := s.encrypt(key) enc, err := s.encrypt(key)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
patch["api_key_enc"] = enc patch["api_key_enc"] = enc
} }
return s.channels.Update(ctx, d.ID, patch) ch, err := s.channels.Update(ctx, d.ID, patch)
if err != nil {
return nil, "", err
}
return ch, syncWarn(s.syncChannelModels(ctx, d.ID)), nil
}
// syncWarn folds the sync error into the save-path warning (vivid parity: a
// failed upstream fetch degrades to a non-fatal warning instead of blocking
// the channel save).
func syncWarn(warn string, err error) string {
if err != nil {
return err.Error()
}
return warn
}
// SyncModels manually triggers a full upstream-model sync for a saved channel
// (same truth semantics as the save path). Unlike Save, an unreachable upstream
// or empty model list is a hard error, not a warning.
func (s *ChannelService) SyncModels(ctx context.Context, channelID string) (string, error) {
if _, err := s.channels.Get(ctx, strings.TrimSpace(channelID)); err != nil {
return "", err
}
warn, err := s.syncChannelModels(ctx, channelID)
if err != nil {
return "", err
}
if warn != "" {
return warn, nil
}
return "已同步上游模型", nil
}
// syncChannelModels runs after Save: upstream /v1/models is the truth — every
// sellable upstream model is upserted locally (alias = upstream id) and bound;
// bindings on this channel not in the upstream list are removed, and orphan
// local models are deleted. An unreachable upstream or empty list is an error
// (the save path degrades it to a warning); warn carries non-fatal skips.
func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string) (string, error) {
ch, err := s.channels.Get(ctx, channelID)
if err != nil {
return "", fmt.Errorf("模型同步失败:%w", err)
}
key := s.decrypt(ch.APIKeyEnc)
if key == "" {
return "", errors.New("模型同步失败:无法解密上游密钥")
}
fctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
models, err := s.up.Models(fctx, ch.BaseURL, key)
if err != nil {
return "", fmt.Errorf("模型同步失败:%w", err)
}
if len(models) == 0 {
return "", errors.New("上游未返回任何模型,已跳过同步")
}
specs := make([]ChannelModelSpec, 0, len(models))
upstreamIDs := make([]string, 0, len(models))
var skipped []string
for _, m := range models {
spec, serr := normalizeRemoteModel(m)
if serr != nil {
skipped = append(skipped, m.ID+":"+serr.Error())
continue
}
specs = append(specs, spec)
upstreamIDs = append(upstreamIDs, spec.ID)
}
if len(upstreamIDs) == 0 {
return "", fmt.Errorf("上游模型均规格异常,已跳过同步:%s", strings.Join(skipped, ";"))
}
if err := s.ImportModels(ctx, channelID, specs); err != nil {
return "", fmt.Errorf("模型同步失败:%w", err)
}
if err := s.channels.UnbindExcept(ctx, channelID, upstreamIDs); err != nil {
return "", fmt.Errorf("模型同步失败:%w", err)
}
if orphans, oerr := s.channels.OrphanModelIDs(ctx); oerr == nil {
for _, mid := range orphans {
_, _ = s.models.Delete(ctx, mid)
}
}
if len(skipped) > 0 {
return fmt.Sprintf("已同步上游模型,跳过 %d 个异常项:%s", len(skipped), strings.Join(skipped, ";")), nil
}
return "", nil
}
// normalizeRemoteModel validates an upstream /v1/models item into an import
// spec (alias = upstream id).
func normalizeRemoteModel(m upstream.RemoteModel) (ChannelModelSpec, error) {
spec := ChannelModelSpec{
ID: strings.TrimSpace(m.ID),
Kind: strings.TrimSpace(m.Kind),
Qualities: m.Qualities,
Duration: m.Duration,
RefImages: m.RefImages,
RefVideos: m.RefVideos,
RefAudios: m.RefAudios,
RefVideoSeconds: m.RefVideoSeconds,
RefAudioSeconds: m.RefAudioSeconds,
}
if spec.ID == "" {
return spec, errors.New("模型 id 为空")
}
switch spec.Kind {
case "image", "video", "text":
default:
return spec, fmt.Errorf("未知类型 %q", spec.Kind)
}
return spec, nil
} }
// Delete removes a channel, its bindings, and any local model that ends up // Delete removes a channel, its bindings, and any local model that ends up
@@ -205,8 +326,9 @@ type FetchModelsDto struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
} }
// FetchModels lists the upstream's sellable models and flags which are already // FetchModels lists the upstream's sellable models; when a saved channel id is
// imported locally. // given, items already bound to that channel are flagged Bound so the admin UI
// can render them as a bound/unbound toggle.
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) { func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey) base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
if err != nil { if err != nil {
@@ -216,9 +338,75 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(d.ID) != "" {
if bound, berr := s.channels.UpstreamModelMap(ctx, d.ID); berr == nil && len(bound) > 0 {
set := make(map[string]struct{}, len(bound))
for _, upID := range bound {
set[upID] = struct{}{}
}
for i := range items {
if _, ok := set[items[i].ID]; ok {
items[i].Bound = true
}
}
}
}
return items, nil return items, nil
} }
// UnbindModel detaches one upstream model from a channel; when the local model
// row ends up bound to no channel at all it is deleted too (same orphan
// semantics as deleting a whole channel).
func (s *ChannelService) UnbindModel(ctx context.Context, channelID, upstreamModelID string) error {
upID := strings.TrimSpace(upstreamModelID)
if upID == "" {
return errors.New("model id required")
}
m, err := s.channels.UpstreamModelMap(ctx, channelID)
if err != nil {
return err
}
var localID string
for mid, up := range m {
if up == upID {
localID = mid
break
}
}
if localID == "" {
return nil // not bound — idempotent
}
if err := s.channels.Unbind(ctx, channelID, localID); err != nil {
return err
}
if rest, _ := s.channels.ChannelIDsByModel(ctx, localID); len(rest) == 0 {
_, _ = s.models.Delete(ctx, localID)
}
return nil
}
// PurgeOrphanModels sweeps model_configs rows left over from the provider era:
// an unbound model can never be routed, so any row that didn't come through a
// channel import (provider != "channel") is deleted at startup. Channel-typed
// orphans keep their pricing config for a later rebind.
func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
ids, err := s.channels.OrphanModelIDs(ctx)
if err != nil {
return 0, err
}
n := 0
for _, id := range ids {
m, gerr := s.models.Get(ctx, id)
if gerr != nil || m == nil || m.Provider == "channel" {
continue
}
if _, derr := s.models.Delete(ctx, id); derr == nil {
n++
}
}
return n, nil
}
// ChannelModelSpec is one model the admin ticks for import (same shape as the // ChannelModelSpec is one model the admin ticks for import (same shape as the
// upstream /v1/models item plus an optional local alias). // upstream /v1/models item plus an optional local alias).
type ChannelModelSpec struct { type ChannelModelSpec struct {
@@ -262,7 +450,11 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if upID == "" { if upID == "" {
continue continue
} }
localID := strings.TrimSpace(spec.Alias) alias := strings.TrimSpace(spec.Alias)
if alias == upID {
return fmt.Errorf("「%s」别名不能与上游模型同名,留空即可", upID)
}
localID := alias
if localID == "" { if localID == "" {
localID = upID localID = upID
} }
@@ -281,7 +473,6 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
Name: localID, Name: localID,
Type: spec.Kind, Type: spec.Kind,
Provider: "channel", Provider: "channel",
Enabled: true,
Ratios: jsonArray([]string{"16:9"}), Ratios: jsonArray([]string{"16:9"}),
Prices: datatypes.JSONMap{}, Prices: datatypes.JSONMap{},
CreatedAt: time.Now(), CreatedAt: time.Now(),
@@ -303,8 +494,8 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
} }
// modelPatchFromSpec maps an upstream model spec onto model_configs columns. // modelPatchFromSpec maps an upstream model spec onto model_configs columns.
// Prices/enabled/alias are intentionally untouched on update so admin pricing // Prices/alias are intentionally untouched on update so admin pricing survives
// survives re-import. // re-import.
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any { func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
patch := map[string]any{ patch := map[string]any{
"type": spec.Kind, "type": spec.Kind,
@@ -392,6 +583,11 @@ func (s *ChannelService) ChannelsForModelAnyState(ctx context.Context, modelID s
} }
// Decrypt exposes the upstream key for dispatch. // Decrypt exposes the upstream key for dispatch.
// Get loads one channel row by id (resume path needs the owning channel).
func (s *ChannelService) Get(ctx context.Context, id string) (*model.Channel, error) {
return s.channels.Get(ctx, id)
}
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) } func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
// Upstream returns the shared protocol client (dispatch needs it for // Upstream returns the shared protocol client (dispatch needs it for
+26 -6
View File
@@ -20,23 +20,24 @@ type MaintenanceService struct {
users *repo.UserRepository users *repo.UserRepository
settings *repo.SiteSettingRepository settings *repo.SiteSettingRepository
store *storage.Client store *storage.Client
inflight *InflightRegistry v1 *V1Service
showcase *repo.ShowcaseRepository showcase *repo.ShowcaseRepository
orders *repo.OrderRepository orders *repo.OrderRepository
interval time.Duration interval time.Duration
stalePending time.Duration stalePending time.Duration
stalePendingVid time.Duration stalePendingVid time.Duration
maxResumeAge time.Duration
mediaPruneEvery time.Duration mediaPruneEvery time.Duration
lastMediaPrune time.Time lastMediaPrune time.Time
} }
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService { func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, v1 *V1Service, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
return &MaintenanceService{ return &MaintenanceService{
events: events, events: events,
users: users, users: users,
settings: settings, settings: settings,
store: store, store: store,
inflight: inflight, v1: v1,
showcase: showcase, showcase: showcase,
orders: orders, orders: orders,
interval: 60 * time.Second, interval: 60 * time.Second,
@@ -44,6 +45,9 @@ func NewMaintenanceService(events *repo.EventRepository, users *repo.UserReposit
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的 // 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
// 10 分钟去扫会在它出片前判死并退款。 // 10 分钟去扫会在它出片前判死并退款。
stalePendingVid: 30 * time.Minute, stalePendingVid: 30 * time.Minute,
// 带上游 jobId 的逾期任务先尝试续等而不是判死;超过 3 小时总时限
// (vivid 同款)才放弃走判死退款。
maxResumeAge: 3 * time.Hour,
mediaPruneEvery: 60 * time.Second, mediaPruneEvery: 60 * time.Second,
} }
} }
@@ -75,10 +79,26 @@ func (m *MaintenanceService) tick(ctx context.Context) {
} }
} }
// 1. Fail long-pending events so they stop blocking the per-user gate, and // 1a. Pending events with an upstream job id that outlived their local
// goroutine (process restart) get resumed against the owning channel —
// the upstream job keeps running even while we were down.
if m.v1 != nil {
if resumable, err := m.events.ResumableStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
log.Printf("maintenance: resumable_stale: %v", err)
} else {
for _, e := range resumable {
m.v1.ResumeStaleJob(ctx, e)
}
if len(resumable) > 0 {
log.Printf("maintenance: resuming %d stale job(s) on their channels", len(resumable))
}
}
}
// 1b. Fail long-pending events so they stop blocking the per-user gate, and
// refund the credits debited up-front for each abandoned generation (the // refund the credits debited up-front for each abandoned generation (the
// normal failure-refund path never ran for a process-restart orphan). // normal failure-refund path never ran for a process-restart orphan).
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil { if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
log.Printf("maintenance: purge_stale: %v", err) log.Printf("maintenance: purge_stale: %v", err)
} else if len(purged) > 0 { } else if len(purged) > 0 {
refunded := 0 refunded := 0
@@ -87,7 +107,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
// Stop the generation goroutine if it's still running, so it doesn't // Stop the generation goroutine if it's still running, so it doesn't
// keep grinding for minutes and surface a late "success" on this // keep grinding for minutes and surface a late "success" on this
// just-abandoned event. // just-abandoned event.
if m.inflight != nil && m.inflight.Cancel(e.ID) { if m.v1 != nil && m.v1.Inflight().Cancel(e.ID) {
cancelled++ cancelled++
} }
if e.UserID == "" || e.Cost <= 0 { if e.UserID == "" || e.Cost <= 0 {
+120 -16
View File
@@ -64,8 +64,8 @@ const videoGenBudget = 30 * time.Minute
// imageGenBudget caps one image generation end-to-end. // imageGenBudget caps one image generation end-to-end.
const imageGenBudget = 8 * time.Minute const imageGenBudget = 8 * time.Minute
// defaultChannelConcurrency applies when a channel's Concurrency is 0. // Channel concurrency semantics: 0 = unlimited (the Lua slot script passes
const defaultChannelConcurrency = 5 // max=0 straight through), matching vivid's channel design.
// maxChannelFailover caps how many channels one request may burn on // maxChannelFailover caps how many channels one request may burn on
// retryable/temporary errors before giving up, so an upstream-wide blip can't // retryable/temporary errors before giving up, so an upstream-wide blip can't
@@ -100,11 +100,11 @@ type V1Service struct {
conc *ConcurrencyService conc *ConcurrencyService
} }
// chanAcquire takes one per-channel slot (capped at max; 0 = default), tagged // chanAcquire takes one per-channel slot (capped at max; 0 = unlimited), tagged
// with the generation's eventID. // with the generation's eventID.
func (s *V1Service) chanAcquire(ctx context.Context, channelID, eventID string, max int) bool { func (s *V1Service) chanAcquire(ctx context.Context, channelID, eventID string, max int) bool {
if max < 1 { if max < 0 {
max = defaultChannelConcurrency max = 0
} }
return s.conc.Acquire(ctx, "conc:c:"+channelID, max, eventID) return s.conc.Acquire(ctx, "conc:c:"+channelID, max, eventID)
} }
@@ -159,6 +159,16 @@ func (r *InflightRegistry) Add(eventID string, cancel context.CancelFunc) {
} }
} }
// TryAdd registers an event only when nothing is tracked for it — the resume
// sweep uses it so two ticks can't double-attach to the same upstream job.
func (r *InflightRegistry) TryAdd(eventID string, cancel context.CancelFunc) bool {
if eventID == "" {
return false
}
_, loaded := r.m.LoadOrStore(eventID, cancel)
return !loaded
}
// Done deregisters an event (called on normal completion). // Done deregisters an event (called on normal completion).
func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) } func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) }
@@ -342,9 +352,9 @@ type V1ModelDur struct {
Options []int64 `json:"options,omitempty"` Options []int64 `json:"options,omitempty"`
} }
// SellableModels — GET /v1/models. Only enabled models bound to at least one // SellableModels — GET /v1/models. 配价即上架(无启停开关):绑定到至少一个
// enabled channel AND fully priced are sellable (a model with an unpriced tier // enabled 渠道 AND 全部档位有价才是可售模型(缺价的档位提交时会扣费拒绝,
// would charge-reject at submit time — hide it instead). // 直接不下发)。kind 不限——image/video/text 同构透出,下游才能连导。
func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) { func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
items, err := s.models.List(ctx) items, err := s.models.List(ctx)
if err != nil { if err != nil {
@@ -352,9 +362,6 @@ func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
} }
out := make([]V1ModelItem, 0, len(items)) out := make([]V1ModelItem, 0, len(items))
for _, item := range items { for _, item := range items {
if !item.Enabled {
continue
}
if !s.modelSellable(ctx, &item) { if !s.modelSellable(ctx, &item) {
continue continue
} }
@@ -393,6 +400,12 @@ func (s *V1Service) modelSellable(ctx context.Context, item *model.ModelConfig)
if err != nil || len(channels) == 0 { if err != nil || len(channels) == 0 {
return false return false
} }
return ModelFullyPriced(item)
}
// ModelFullyPriced — 上架门槛(无启停开关,配价即上架):每个清晰度档位在
// Prices 里有价;视频模型还要求时长有价(离散档逐档,或区间档按秒价)。
func ModelFullyPriced(item *model.ModelConfig) bool {
for _, res := range repo.JSONStrings(item.Resolutions) { for _, res := range repo.JSONStrings(item.Resolutions) {
if _, ok := jsonMapFloat(item.Prices, res); !ok { if _, ok := jsonMapFloat(item.Prices, res); !ok {
return false return false
@@ -564,6 +577,10 @@ func (s *V1Service) generateOnChannel(ctx context.Context, eventID string, ch mo
if upstreamJob == "" { if upstreamJob == "" {
upstreamJob = job upstreamJob = job
_ = s.events.SetUpstreamJob(ctx, eventID, job) _ = s.events.SetUpstreamJob(ctx, eventID, job)
// Stamp the owning channel the moment the upstream accepts the job —
// a restart between submit and finish would otherwise leave a
// pending row that can't tell which channel to resume on.
_ = s.events.SetAccount(ctx, eventID, ch.ID, ch.Name)
} }
}) })
if err != nil { if err != nil {
@@ -881,9 +898,6 @@ func (s *V1Service) CreateJob(ctx context.Context, principal *APIPrincipal, in V
} }
return nil, err return nil, err
} }
if !modelItem.Enabled {
return nil, ErrUnknownModel
}
kind := modelItem.Type kind := modelItem.Type
if kind != "image" && kind != "video" { if kind != "image" && kind != "video" {
return nil, ErrUnknownModel return nil, ErrUnknownModel
@@ -1052,6 +1066,96 @@ func (s *V1Service) runV1Job(ctx context.Context, principal *APIPrincipal, event
_ = s.maybeGrantInviteReward(ctx, principal) _ = s.maybeGrantInviteReward(ctx, principal)
} }
// ResumeStaleJob re-attaches to an upstream job whose local goroutine died with
// a process restart. Called by the maintenance sweep; TryAdd guards against
// double-attach across ticks. Terminal failure (upstream job lost/failed,
// channel gone or key broken) fails the event and refunds; transient failure
// leaves it pending for the next sweep (events older than maxResumeAge get
// purged instead of resumed).
func (s *V1Service) ResumeStaleJob(ctx context.Context, ev repo.StaleEvent) {
if ev.UpstreamJob == "" || ev.AccountID == "" {
return
}
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), videoGenBudget)
if !s.inflight.TryAdd(ev.ID, cancel) {
cancel()
return
}
go s.resumeUpstreamJob(rctx, ev)
}
func (s *V1Service) resumeUpstreamJob(ctx context.Context, ev repo.StaleEvent) {
defer s.inflight.Done(ev.ID)
fail := func(msg string) {
s.refundStale(ctx, ev)
elapsed := int(time.Since(ev.TS).Milliseconds())
_ = s.events.UpdateStatus(ctx, ev.ID, "failed", msg, elapsed)
}
ch, err := s.channels.Get(ctx, ev.AccountID)
if err != nil || ch == nil {
fail("上游渠道已删除,任务无法续等")
return
}
apiKey := s.channels.Decrypt(*ch)
if apiKey == "" {
fail("上游渠道密钥不可用,任务无法续等")
return
}
base := strings.TrimRight(ch.BaseURL, "/")
resultURL, credits, err := s.up.Generate(ctx, base, apiKey, upstream.GenParams{JobID: ev.UpstreamJob}, int64(videoGenBudget/time.Second), nil)
if err != nil {
switch upstream.KindOf(err) {
case upstream.KindJobFailed, upstream.KindInvalid, upstream.KindAuth:
fail(upstreamUserError(err).Error())
default:
// Retryable/transport/timeout — the upstream job may still run;
// leave the event pending for the next sweep.
log.Printf("[v1] resume upstream job %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
}
return
}
data, ct, err := s.up.Download(ctx, resultURL)
if err != nil {
log.Printf("[v1] resume download %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
return
}
rel := strings.TrimSpace(ev.File)
if rel == "" {
rel = filepath.ToSlash(filepath.Join("tmp", ev.ID+"."+resultExt(ct, ev.Kind)))
}
if err := s.store.Put(ctx, rel, data, ct); err != nil {
fail("storage upload failed: " + err.Error())
return
}
_ = s.events.SetFile(ctx, ev.ID, rel)
if err := s.events.UpdateStatus(ctx, ev.ID, "success", "", int(time.Since(ev.TS).Milliseconds())); err != nil {
return
}
s.channels.BumpSuccess(ctx, ch.ID, float64(credits))
if ev.Model != "" {
_ = s.models.IncrementGenerationCount(ctx, ev.Model)
}
if ev.UserID != "" {
_ = s.users.IncrementGenerationCount(ctx, ev.UserID)
}
log.Printf("[v1] resumed upstream job %s → event %s succeeded", ev.UpstreamJob, ev.ID)
}
// refundStale returns the up-front charge for an abandoned/resume-dead event,
// claimed exactly once via the refunded flag.
func (s *V1Service) refundStale(ctx context.Context, ev repo.StaleEvent) {
if ev.UserID == "" || ev.Cost <= 0 {
return
}
claimed, err := s.events.MarkRefunded(ctx, ev.ID)
if err != nil || !claimed {
return
}
if _, err := s.users.RefundCredits(ctx, ev.UserID, ev.Cost); err != nil {
log.Printf("[v1] refund stale event %s (user %s, %.0f): %v", ev.ID, ev.UserID, ev.Cost, err)
}
}
func resultExt(contentType, kind string) string { func resultExt(contentType, kind string) string {
switch { switch {
case strings.Contains(contentType, "webp"): case strings.Contains(contentType, "webp"):
@@ -1300,7 +1404,7 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
} }
return nil, "", "", 0, err return nil, "", "", 0, err
} }
if !modelItem.Enabled || modelItem.Type != "image" { if modelItem.Type != "image" {
return nil, "", "", 0, ErrUnknownModel return nil, "", "", 0, ErrUnknownModel
} }
// Fail fast before charging if no channel serves this model. // Fail fast before charging if no channel serves this model.
@@ -1361,7 +1465,7 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
} }
return nil, "", "", "", 0, err return nil, "", "", "", 0, err
} }
if !modelItem.Enabled || modelItem.Type != "video" { if modelItem.Type != "video" {
return nil, "", "", "", 0, ErrUnknownModel return nil, "", "", "", 0, ErrUnknownModel
} }
// Validate duration against the model's spec (options list or min/max range). // Validate duration against the model's spec (options list or min/max range).
+3
View File
@@ -95,6 +95,9 @@ type RemoteModel struct {
// Combined reference duration caps in seconds; 0 = unlimited. // Combined reference duration caps in seconds; 0 = unlimited.
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"` RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"` RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
// Bound is set by the admin fetch-models endpoint only: true when this
// upstream id is already bound to that channel locally.
Bound bool `json:"bound,omitempty"`
} }
// RemoteDuration mirrors the duration spec of a vivid model item. // RemoteDuration mirrors the duration spec of a vivid model item.
+2
View File
@@ -82,6 +82,8 @@ services:
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026}
CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183} CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183}
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
# 渠道上游 API Key 的 AES-GCM 加密密钥——务必配置随机值;丢失/变更后已存的渠道密钥无法解密
CHANNEL_SECRET: ${CHANNEL_SECRET:-change-me-channel-secret}
volumes: volumes:
- generated:/app/data/generated - generated:/app/data/generated
depends_on: depends_on:
+2
View File
@@ -72,6 +72,8 @@ services:
RUSTFS_SECRET_KEY: vividai-secret-change-me RUSTFS_SECRET_KEY: vividai-secret-change-me
CORS_ORIGINS: http://localhost:2000 CORS_ORIGINS: http://localhost:2000
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
# 渠道上游 API Key 的 AES-GCM 加密密钥——生产环境务必改成随机值并保持不变
CHANNEL_SECRET: change-me-channel-secret
volumes: volumes:
- generated:/app/data/generated - generated:/app/data/generated
depends_on: depends_on:
+8 -24
View File
@@ -1,7 +1,8 @@
# ai-gateway frontend # image2api frontend
Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old Vue 3 + Vite frontend for `image2api`: user-facing generation UI (playground,
single-file `static/admin.html`. gallery, docs, billing) plus the admin console (channels, models, users,
orders, logs, config).
## Develop ## Develop
@@ -10,27 +11,10 @@ npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
``` ```
The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend. The dev server proxies `/admin`, `/health`, `/images`, `/v1` to the backend.
Start the backend separately: Start the Go backend separately (see `backend/README.md`).
```bash ```bash
# from repo root # from ../backend
python app.py # http://0.0.0.0:6060 go build -o bin/api ./cmd/api && ./bin/api # http://127.0.0.1:6666
``` ```
If the backend runs elsewhere, set `VITE_BACKEND` before `npm run dev`:
```bash
VITE_BACKEND=http://192.168.1.10:6060 npm run dev
```
## Build
```bash
npm run build # outputs static assets to ./dist
npm run preview # serve the production build locally
```
When hosting `dist/` on a different origin than the API, set `VITE_API_BASE`
(e.g. `VITE_API_BASE=http://api-host:6060`) at build time, and add that frontend
origin to the backend's `CORS_ORIGINS` env var.
+1 -1
View File
@@ -50,7 +50,7 @@ watch(genMsg, (v) => {
if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500) if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500)
}) })
const kindModels = computed(() => const kindModels = computed(() =>
(allModels.value || []).filter((m) => m.enabled !== false && m.type === gen.kind), (allModels.value || []).filter((m) => m.sellable && m.type === gen.kind),
) )
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null) const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1']) const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
+91 -88
View File
@@ -9,20 +9,20 @@ const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
const channels = ref([]) const channels = ref([])
const loading = ref(false) const loading = ref(false)
const err = ref('') const err = ref('')
const warn = ref('') // 保存后自动同步的非致命告警
const okMsg = ref('') // 手动同步成功提示
const syncing = ref(false)
// create/edit form // create/edit form
const form = ref(null) const form = ref(null)
const saving = ref(false) const saving = ref(false)
const formErr = ref('') const formErr = ref('')
// fetch-models → pick → import // fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑)
const fetchTarget = ref(null) // channel snapshot being imported into const fetchTarget = ref(null) // channel snapshot being imported into
const fetching = ref(false) const fetching = ref(false)
const fetchErr = ref('') const fetchErr = ref('')
const specs = ref([]) const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
const managedIds = ref(new Set())
async function load() { async function load() {
loading.value = true loading.value = true
@@ -33,17 +33,6 @@ async function load() {
loading.value = false loading.value = false
} }
async function loadManaged() {
const r = await api('/managed-models')
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
}
function modelSummary(c) {
const names = c.models || []
if (!names.length) return ''
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
}
function openAdd() { function openAdd() {
formErr.value = '' formErr.value = ''
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true } form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
@@ -61,12 +50,14 @@ async function save() {
if (saving.value) return if (saving.value) return
saving.value = true saving.value = true
formErr.value = '' formErr.value = ''
okMsg.value = ''
const r = await api('/channels', jsonBody('POST', { const r = await api('/channels', jsonBody('POST', {
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key, id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1, enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
})) }))
saving.value = false saving.value = false
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return } if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
warn.value = r.data?.warning || ''
form.value = null form.value = null
load() load()
} }
@@ -78,7 +69,8 @@ async function toggle(c) {
id: c.id, name: c.name, base_url: c.base_url, api_key: '', id: c.id, name: c.name, base_url: c.base_url, api_key: '',
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1, enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
})) }))
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' } if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败'; return }
warn.value = r.data?.warning || ''
} }
async function del(c) { async function del(c) {
@@ -88,19 +80,39 @@ async function del(c) {
channels.value = channels.value.filter((x) => x.id !== c.id) channels.value = channels.value.filter((x) => x.id !== c.id)
} }
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。 // 已保存渠道手动「同步上游模型」:直接全量同步(新模型入库绑定,上游消失的
// 解绑/清孤儿),拉取失败直接报错;新渠道则只用表单地址+key 预览不落库。
async function syncModels(c) {
if (syncing.value) return
syncing.value = true
err.value = ''
warn.value = ''
okMsg.value = ''
try {
const r = await api('/channels/sync-models', jsonBody('POST', { id: c.id }))
if (!r.ok) { err.value = r.data?.detail || '同步失败'; return }
const msg = r.data?.message || '已同步上游模型'
if (msg === '已同步上游模型') okMsg.value = msg
else warn.value = msg
load()
} finally {
syncing.value = false
}
}
// 表单内拉模型:编辑态直接全量同步;新渠道用表单里的地址+key 预览(导入时自动保存)。
function fetchFromForm() { function fetchFromForm() {
const f = form.value const f = form.value
if (!f) return if (!f) return
if (f.id) { syncModels(f); return }
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return } if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return } if (!f.api_key) { formErr.value = '请填写 API Key'; return }
openFetch({ id: f.id || '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled }) openFetch({ id: '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
} }
async function openFetch(c) { async function openFetch(c) {
fetchTarget.value = c fetchTarget.value = c
specs.value = [] specs.value = []
picked.value = new Set()
fetchErr.value = '' fetchErr.value = ''
fetching.value = true fetching.value = true
const r = await api('/channels/fetch-models', jsonBody('POST', { const r = await api('/channels/fetch-models', jsonBody('POST', {
@@ -108,15 +120,45 @@ async function openFetch(c) {
})) }))
fetching.value = false fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return } if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) })) specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false }))
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id))
} }
function togglePick(id) { // 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。
const next = new Set(picked.value) // 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。
next.has(id) ? next.delete(id) : next.add(id) async function toggleBind(s) {
picked.value = next if (s.busy) return
if (!s.bound && s.alias && s.alias === s.id) {
fetchErr.value = `「${s.id}」别名不能与上游模型同名,留空即可`
return
}
s.busy = true
fetchErr.value = ''
try {
const target = fetchTarget.value
if (!s.bound) {
if (!target.id) {
const sr = await api('/channels', jsonBody('POST', {
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
}))
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
if (sr.data?.warning) warn.value = sr.data.warning
target.id = sr.data?.id
form.value = null
}
const { bound, busy, ...rest } = s
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models: [rest] }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
s.bound = true
} else {
const r = await api('/channels/unbind-model', jsonBody('POST', { id: target.id, model: s.id }))
if (!r.ok) { fetchErr.value = r.data?.detail || '解绑失败'; return }
s.bound = false
}
load()
} finally {
s.busy = false
}
} }
function specText(s) { function specText(s) {
@@ -135,38 +177,7 @@ function specText(s) {
return parts.join(' · ') || '-' return parts.join(' · ') || '-'
} }
async function doImport() { onMounted(load)
if (importing.value || !picked.value.size) return
const models = specs.value
.filter((s) => picked.value.has(s.id))
.map((s) => {
const { imported, ...rest } = s
return rest
})
const target = fetchTarget.value
importing.value = true
try {
if (!target.id) {
// 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。
const sr = await api('/channels', jsonBody('POST', {
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
}))
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
target.id = sr.data?.id
form.value = null
}
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
fetchTarget.value = null
load()
loadManaged()
} finally {
importing.value = false
}
}
onMounted(() => { load(); loadManaged() })
</script> </script>
<template> <template>
@@ -174,7 +185,7 @@ onMounted(() => { load(); loadManaged() })
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap"> <div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div> <div>
<div class="text-sm font-medium">渠道</div> <div class="text-sm font-medium">渠道</div>
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p> <p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 保存时自动同步上游模型 · 余额实时探测</p>
</div> </div>
<button @click="openAdd" class="btn-primary"> <button @click="openAdd" class="btn-primary">
<Icon name="plus" class="w-4 h-4" /> 添加渠道 <Icon name="plus" class="w-4 h-4" /> 添加渠道
@@ -182,6 +193,8 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p> <p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
<p v-if="warn" class="text-xs text-amber-300">{{ warn }}</p>
<p v-if="okMsg" class="text-xs text-emerald-300">{{ okMsg }}</p>
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -189,8 +202,6 @@ onMounted(() => { load(); loadManaged() })
<thead> <thead>
<tr> <tr>
<th>渠道</th> <th>渠道</th>
<th>上游地址</th>
<th>模型</th>
<th>并发</th> <th>并发</th>
<th>配比</th> <th>配比</th>
<th class="text-right">成功调用</th> <th class="text-right">成功调用</th>
@@ -207,14 +218,7 @@ onMounted(() => { load(); loadManaged() })
<div class="font-medium text-white/85">{{ c.name }}</div> <div class="font-medium text-white/85">{{ c.name }}</div>
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div> <div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
</td> </td>
<td> <td class="tabular-nums">{{ c.concurrency || '不限' }}</td>
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
</td>
<td>
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
</td>
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
<td class="tabular-nums">{{ c.weight || 1 }}</td> <td class="tabular-nums">{{ c.weight || 1 }}</td>
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td> <td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td> <td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
@@ -228,13 +232,13 @@ onMounted(() => { load(); loadManaged() })
</td> </td>
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td> <td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
<td class="text-right whitespace-nowrap"> <td class="text-right whitespace-nowrap">
<button @click="openFetch(c)" class="op" title="拉取上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button> <button @click="syncModels(c)" :disabled="syncing" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button> <button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button> <button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</td> </td>
</tr> </tr>
<tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr> <tr v-if="loading"><td colspan="9" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
<tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr> <tr v-else-if="!channels.length"><td colspan="9" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -265,8 +269,8 @@ onMounted(() => { load(); loadManaged() })
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="lbl">并发数</label> <label class="lbl">并发数</label>
<input v-model.number="form.concurrency" type="number" min="0" max="100" class="field" /> <input v-model.number="form.concurrency" type="number" min="0" max="2147483647" class="field" />
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</p> <p class="mt-1 text-[11px] text-white/35">0 = 不限</p>
</div> </div>
<div> <div>
<label class="lbl">配比</label> <label class="lbl">配比</label>
@@ -286,7 +290,7 @@ onMounted(() => { load(); loadManaged() })
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs"> <button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
{{ fetching ? '拉取中…' : '获取模型列表' }} {{ fetching ? '拉取中…' : '获取模型列表' }}
</button> </button>
<span class="text-[11px] text-white/35">{{ form.id ? '用已保存的地址与密钥拉取' : '用上方的地址与 API Key 拉取;导入时会先自动保存渠道' }}</span> <span class="text-[11px] text-white/35">{{ form.id ? '直接全量同步上游模型到模型列表' : '用上方的地址与 API Key 预览;导入时会先自动保存渠道' }}</span>
</div> </div>
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p> <p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
</div> </div>
@@ -297,7 +301,7 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
</div> </div>
<!-- 拉取上游模型 → 勾选导入 --> <!-- 拉取上游模型 → 勾选即导入绑定 -->
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null"> <div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
<div class="card !shadow-2xl my-12 w-full max-w-2xl"> <div class="card !shadow-2xl my-12 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between"> <div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
@@ -306,15 +310,16 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
<div class="p-5"> <div class="p-5">
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div> <div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
<template v-else> <template v-else>
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p> <p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。注意:保存渠道会按上游清单全量重同步,手动解绑的模型若仍在上游列表中会被重新接入。</p>
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div> <div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto"> <div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)" <div v-for="s in specs" :key="s.id" @click="toggleBind(s)"
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors" class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
:class="!picked.has(s.id) && 'opacity-50'"> :class="[!s.bound && 'opacity-50', s.busy && 'pointer-events-none']">
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" /> <span class="mt-1 inline-block h-3.5 w-3.5 shrink-0 rounded-full ring-1 transition-all"
:class="s.bound ? 'bg-sky-400 ring-sky-300' : 'bg-transparent ring-white/25'"></span>
<div class="flex-1 min-w-0 space-y-1.5"> <div class="flex-1 min-w-0 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<code class="font-mono text-xs text-white/85">{{ s.id }}</code> <code class="font-mono text-xs text-white/85">{{ s.id }}</code>
@@ -323,13 +328,14 @@ onMounted(() => { load(); loadManaged() })
{{ KIND_LABEL[s.kind] || s.kind }} {{ KIND_LABEL[s.kind] || s.kind }}
</span> </span>
<span class="text-[11px] text-white/40">{{ specText(s) }}</span> <span class="text-[11px] text-white/40">{{ specText(s) }}</span>
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span> <span v-if="s.busy" class="ml-auto text-[10px] text-white/40">处理中…</span>
<span v-else-if="s.bound" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已接入</span>
</div> </div>
<div class="flex items-center gap-2" @click.stop> <div class="flex items-center gap-2" @click.stop>
<span class="text-[11px] text-white/35 shrink-0">别名</span> <span class="text-[11px] text-white/35 shrink-0">别名</span>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)" <input v-model.trim="s.alias" type="text" maxlength="64" :disabled="s.bound"
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40" class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
placeholder="留空 = 与上游同名" /> :placeholder="s.bound ? '已绑定,到模型管理改别名' : '留空 = 与上游同名'" />
</div> </div>
</div> </div>
</div> </div>
@@ -338,9 +344,6 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2"> <div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="fetchTarget = null" class="btn-ghost">关闭</button> <button @click="fetchTarget = null" class="btn-ghost">关闭</button>
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
</button>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -15,7 +15,7 @@ const models = ref([])
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源) const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
onMounted(async () => { onMounted(async () => {
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')]) const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false) if (r.ok) models.value = (r.data?.data || []).filter((m) => m.sellable)
if (p.ok) presets.value = presetMap(p.data?.data) if (p.ok) presets.value = presetMap(p.data?.data)
}) })
@@ -30,7 +30,7 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
function pubName(m) { function pubName(m) {
return m?.alias || m?.id || '' return m?.alias || m?.id || ''
} }
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2') const sampleImage = computed(() => pubName(imageModels.value[0]) || 'image-model')
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model') const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K') const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p') const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
+2 -2
View File
@@ -42,7 +42,7 @@ async function refresh() {
api('/showcase'), api('/showcase'),
]) ])
stats.value = s.data || { generated_count: 0, recent: [] } stats.value = s.data || { generated_count: 0, recent: [] }
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false) managed.value = (m.data?.data || []).filter((x) => x.sellable)
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] } showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
const top3 = (showcase.value.hero || []).slice(0, 3) const top3 = (showcase.value.hero || []).slice(0, 3)
const sameSet = heroDeck.value.length === top3.length const sameSet = heroDeck.value.length === top3.length
@@ -92,7 +92,7 @@ const avgLabel = computed(() => {
return Math.round(avgElapsed.value / 1000) + 's' return Math.round(avgElapsed.value / 1000) + 's'
}) })
// ---- capability cards, derived from the enabled models ---- // ---- capability cards, derived from the sellable models ----
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 } function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
const durationRange = computed(() => { const durationRange = computed(() => {
const secs = [] const secs = []
+1 -1
View File
@@ -278,7 +278,7 @@ const sourcePill = (s) => ({
<span v-else class="text-white/25">—</span> <span v-else class="text-white/25">—</span>
</td> </td>
<td class="px-3 py-3.5 align-middle"> <td class="px-3 py-3.5 align-middle">
<span class="chip ring-1" :class="statusPill(e.status)"> <span class="chip ring-1" :class="statusPill(e.status)" :title="e.error || ''">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span> <span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>
{{ statusLabel(e.status) }} {{ statusLabel(e.status) }}
</span> </span>
+25 -60
View File
@@ -14,7 +14,7 @@ const editing = ref(null) // null = add, object = edit
const testing = ref(null) // model being tested, or null const testing = ref(null) // model being tested, or null
const kindFilter = ref('') // '' | 'image' | 'video' const kindFilter = ref('') // '' | 'image' | 'video'
const statusFilter = ref('') // '' | 'enabled' | 'disabled' const saleFilter = ref('') // '' | 'sellable' | 'unsellable'
const search = ref('') const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' } const TYPE_LABEL = { image: '生图', video: '生视频' }
@@ -42,20 +42,6 @@ async function loadModels() {
function openEdit(m) { editing.value = { ...m }; showForm.value = true } function openEdit(m) { editing.value = { ...m }; showForm.value = true }
function onSaved() { showForm.value = false; loadModels() } function onSaved() { showForm.value = false; loadModels() }
async function toggleEnabled(m) {
// Optimistic: flip the switch instantly, persist in the background, revert on
// failure. Avoids the lag of awaiting the PATCH + a full table reload before
// the toggle visibly moves.
const cur = m.enabled !== false
const next = !cur
m.enabled = next
const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: next }),
})
if (!r.ok) m.enabled = cur
}
async function remove(m) { async function remove(m) {
if (!confirm(`确认删除模型 ${m.id}?`)) return if (!confirm(`确认删除模型 ${m.id}?`)) return
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' }) await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
@@ -66,16 +52,16 @@ const stats = computed(() => {
const total = models.value.length const total = models.value.length
const image = models.value.filter((m) => m.type === 'image').length const image = models.value.filter((m) => m.type === 'image').length
const video = models.value.filter((m) => m.type === 'video').length const video = models.value.filter((m) => m.type === 'video').length
const enabled = models.value.filter((m) => m.enabled !== false).length const sellable = models.value.filter((m) => m.sellable).length
return { total, image, video, enabled, disabled: total - enabled } return { total, image, video, sellable }
}) })
const filtered = computed(() => { const filtered = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
return models.value.filter((m) => { return models.value.filter((m) => {
if (kindFilter.value && m.type !== kindFilter.value) return false if (kindFilter.value && m.type !== kindFilter.value) return false
if (statusFilter.value === 'enabled' && m.enabled === false) return false if (saleFilter.value === 'sellable' && !m.sellable) return false
if (statusFilter.value === 'disabled' && m.enabled !== false) return false if (saleFilter.value === 'unsellable' && m.sellable) return false
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
return true return true
}) })
@@ -101,8 +87,8 @@ onMounted(loadModels)
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div> <div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div> <div class="text-[11px] uppercase tracking-wider text-emerald-300/80">上架</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div> <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.sellable }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
</div> </div>
</div> </div>
@@ -115,23 +101,20 @@ onMounted(loadModels)
</div> </div>
<div class="w-px h-5 bg-white/10"></div> <div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button> <button @click="saleFilter = ''" class="fp" :class="saleFilter === '' && 'fp-on'">全部</button>
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'"> <button @click="saleFilter = 'sellable'" class="fp" :class="saleFilter === 'sellable' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用 <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>上架
</button> </button>
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'"> <button @click="saleFilter = 'unsellable'" class="fp" :class="saleFilter === 'unsellable' && 'fp-white'">
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用 <span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>未上架
</button> </button>
</div> </div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名 / Provider…" /> <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" />
</div> </div>
<button @click="loadModels" class="btn-soft"> <button @click="loadModels" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button> </button>
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
</router-link>
</div> </div>
<!-- Table --> <!-- Table -->
@@ -139,7 +122,8 @@ onMounted(loadModels)
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div> <div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span> <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,先到「渠道管理」拉取上游模型导入' }}</span> <span v-if="models.length" class="text-sm">没有匹配的模型</span>
<router-link v-else to="/admin/channels" class="text-sm text-sky-300 underline underline-offset-2">还没有模型,先到「渠道管理」勾选上游模型自动导入</router-link>
</div> </div>
<table v-else class="w-full text-sm table-fixed"> <table v-else class="w-full text-sm table-fixed">
@@ -150,7 +134,7 @@ onMounted(loadModels)
<col /> <!-- capability --> <col /> <!-- capability -->
<col class="w-20" /> <!-- weight --> <col class="w-20" /> <!-- weight -->
<col class="w-24" /> <!-- generation count --> <col class="w-24" /> <!-- generation count -->
<col class="w-20" /> <!-- status switch --> <col class="w-20" /> <!-- sellable badge -->
<col class="w-36" /> <!-- actions --> <col class="w-36" /> <!-- actions -->
</colgroup> </colgroup>
<thead> <thead>
@@ -161,7 +145,7 @@ onMounted(loadModels)
<th class="text-left px-3 py-3 font-medium">能力</th> <th class="text-left px-3 py-3 font-medium">能力</th>
<th class="text-right px-3 py-3 font-medium">权重</th> <th class="text-right px-3 py-3 font-medium">权重</th>
<th class="text-right px-3 py-3 font-medium">生图次数</th> <th class="text-right px-3 py-3 font-medium">生图次数</th>
<th class="text-left px-3 py-3 font-medium">状态</th> <th class="text-left px-3 py-3 font-medium">上架</th>
<th class="text-right px-5 py-3 font-medium">操作</th> <th class="text-right px-5 py-3 font-medium">操作</th>
</tr> </tr>
</thead> </thead>
@@ -268,13 +252,15 @@ onMounted(loadModels)
{{ (m.generation_count || 0).toLocaleString('en-US') }} {{ (m.generation_count || 0).toLocaleString('en-US') }}
</td> </td>
<!-- Status as a real toggle so admins read it as on/off, not a <!-- 配价即上架(无开关):全档位有价 + 绑定了启用渠道才显示「已上架」 -->
button to delete or whatever. -->
<td class="px-3 py-3.5 align-middle"> <td class="px-3 py-3.5 align-middle">
<button class="sw" :class="m.enabled !== false && 'sw-on'" <span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)"> :class="m.sellable
<span class="sw-thumb"></span> ? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
</button> : 'bg-white/[0.05] text-white/40 ring-white/10'"
:title="m.sellable ? '全部档位有价且有启用渠道,对前台可见' : '配齐全部档位价格并绑定启用渠道后自动上架'">
{{ m.sellable ? '已上架' : '未上架' }}
</span>
</td> </td>
<!-- Actions — small soft buttons, danger variant for delete --> <!-- Actions — small soft buttons, danger variant for delete -->
@@ -378,27 +364,6 @@ onMounted(loadModels)
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
} }
/* --- iOS-style toggle for 启用/停用 */
.sw {
position: relative;
width: 2.25rem; height: 1.3rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.12);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.18s ease;
}
.sw-thumb {
position: absolute;
top: 2px; left: 2px;
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
border-radius: 9999px;
background: white;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
transition: transform 0.18s ease;
}
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */ /* --- danger variant for 删除 (kept for any other .btn-soft callers) */
.btn-soft.danger { .btn-soft.danger {
color: rgb(253 164 175); color: rgb(253 164 175);
+8 -9
View File
@@ -68,7 +68,7 @@ const displayItems = computed(() => [...tasks.value, ...history.value].slice(0,
// ---- derived ---- // ---- derived ----
const models = computed(() => const models = computed(() =>
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value), allModels.value.filter((m) => m.sellable && m.type === mode.value),
) )
const modelOptions = computed(() => const modelOptions = computed(() =>
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })), models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
@@ -116,9 +116,8 @@ const maxRefs = computed(() => {
const extra = familyPreset.value ? 0 : maxVideos.value + maxAudios.value const extra = familyPreset.value ? 0 : maxVideos.value + maxAudios.value
return Math.max(a, b) + extra return Math.max(a, b) + extra
} }
// Image-to-image: honor the model's configured max (gpt-image-2=3, // Image-to-image: honor the model's configured max (e.g. 3 or 6 refs, set per
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on // model). Fall back to 1 when image_to_image is on but no count was set.
// but no count was set.
const m = Number(model.value?.max_reference_images || 0) const m = Number(model.value?.max_reference_images || 0)
if (m > 0) return m if (m > 0) return m
return model.value?.image_to_image ? 1 : 0 return model.value?.image_to_image ? 1 : 0
@@ -231,7 +230,7 @@ function setMode(m) {
mode.value = m mode.value = m
clearRefs() // 切换生图/生视频清空参考文件 clearRefs() // 切换生图/生视频清空参考文件
// pick a default model of the new kind, if any // pick a default model of the new kind, if any
const first = allModels.value.find((x) => x.enabled !== false && x.type === m) const first = allModels.value.find((x) => x.sellable && x.type === m)
modelId.value = first?.id || '' modelId.value = first?.id || ''
applyModelDefaults() applyModelDefaults()
} }
@@ -789,17 +788,17 @@ onMounted(async () => {
if (qPrompt) prompt.value = qPrompt if (qPrompt) prompt.value = qPrompt
let selected = null let selected = null
if (qModel) { if (qModel) {
selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false) selected = allModels.value.find((m) => m.id === qModel && m.sellable)
} }
// If the draft already points at a still-available model AND no fresher // If the draft already points at a still-available model AND no fresher
// intent came in from the URL, keep the draft as-is. Otherwise fall back to // intent came in from the URL, keep the draft as-is. Otherwise fall back to
// the first usable model. // the first usable model.
const draftModel = !qModel && modelId.value const draftModel = !qModel && modelId.value
? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false) ? allModels.value.find((m) => m.id === modelId.value && m.sellable)
: null : null
if (!selected && !draftModel) { if (!selected && !draftModel) {
selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image') selected = allModels.value.find((m) => m.sellable && m.type === 'image')
|| allModels.value.find((m) => m.enabled !== false) || allModels.value.find((m) => m.sellable)
} }
// Always re-apply defaults — even when restoring the persisted draft model — // Always re-apply defaults — even when restoring the persisted draft model —
// so a stale ratio/resolution that the model no longer supports (e.g. a saved // so a stale ratio/resolution that the model no longer supports (e.g. a saved
+1 -1
View File
@@ -26,7 +26,7 @@ onMounted(async () => {
} }
}) })
// ---- API Key (REAL: minted + verified server-side; OpenAI-compatible) ---- // ---- API Key (REAL: minted + verified server-side; vivid /v1 protocol) ----
// The full plaintext is returned only once, right after minting. On reload we // The full plaintext is returned only once, right after minting. On reload we
// only have the server's masked preview (the plaintext is never stored). // only have the server's masked preview (the plaintext is never stored).
const apiKey = ref('') // full plaintext — present only right after minting const apiKey = ref('') // full plaintext — present only right after minting