123 Commits

Author SHA1 Message Date
boos c3293e007f docs(nit): AI Studio 第一阶段(聚合图像/数字人)设计 spec
NIT 精英班专属 AI Studio 第一阶段:聚合 picture_agent(图像)+ digital_human(数字人)。
学员下游 key 配置 + surface 认证扩展(接受 student JWT)+ 积分轨计费 + 统一入口前端。
不碰 money_printer,视频/文本创作/PPT 留后续阶段。前置依赖第一期报考闭环。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:43:51 +08:00
boos a1b7b60ad4 docs(nit): 第一期报考闭环 A+B+C 设计 spec
NIT 报考学习平台第一期设计:独立 students 表、线下缴费上传凭证人工审核、
开课权限状态标记、平台超管后台、模块内嵌复用现有 axum/sqlx/JWT/workspace 基建。
不含学习中心/OCR/校区负责人多级权限/内容保护(留后续期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:24:05 +08:00
boos bfd804f719 feat(billing): UsageRecords dual-track ledger view
api.ts 新增 userApi.getBillingLedger / getBillingSummary 及 Billing* 类型;
UsageRecords.tsx 增加 USD/积分 双轨切换 tab、积分汇总卡与明细表(切到积分轨时懒加载)。
随文件一并提交此前累积的支付/Comfy Cloud/工作流前端 API 改动。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 03:04:11 +08:00
boos e7f3da5961 feat(billing): user billing ledger + summary endpoints
新增 GET /api/user/billing/ledger 与 /api/user/billing/summary 两个用户侧接口,
基于 Task11 的 list_user_ledger / summarize_user_ledger 返回当前用户的明细与聚合。
随文件一并提交此前累积的充值/支付/市场相关改动及测试辅助。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 02:56:12 +08:00
boos 5cfd533d89 feat(billing): user ledger list + summary queries 2026-06-17 01:49:46 +08:00
boos 0b7abed79a docs: 细化计费第3期(消费明细)TDD Task11-14 2026-06-17 01:44:28 +08:00
boos a89edf6f75 feat(payment): comfy 登录态/充值确认/记录管理 + Task9 积分到账调用适配
含本会话累积的支付路由功能(firebase-login 回执登录、recharge-records
列表/确认/清空、诊断卡)及 admin_confirm_user_recharge 传 config 的调用适配,
使计费 Task9(comfy 充值改积分)调用方编译完整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:13:16 +08:00
boos 5d42671172 feat(billing): comfy recharge to points + admin credit adjust
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:11:44 +08:00
boos ca9e45c59c feat(billing): workflow points gating + charge
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 20:58:02 +08:00
boos be7538a1da feat(billing): workflow_points_cost config 2026-06-16 20:32:10 +08:00
boos cfe2098b73 feat(billing): points track apply_points_delta + balance 2026-06-16 20:24:50 +08:00
boos bd464016a8 docs: 细化计费第2期(积分轨)TDD 任务 Task6-10 2026-06-16 20:20:22 +08:00
boos 7e8430934e feat(billing): USD admin adjust writes credit_ledger
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:46:56 +08:00
boos ab2fd271e8 feat(billing): USD recharge writes credit_ledger 2026-06-16 17:40:43 +08:00
boos 4dd2be4cc3 feat(billing): USD consume writes credit_ledger in one tx
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:34:21 +08:00
boos fd94303433 feat(billing): credit_ledger schema + record_ledger_entry 2026-06-16 17:25:59 +08:00
boos 91dcbc5c53 docs: 计费闭环实现计划(第1期详细TDD + 后5期概要)
第1期地基: credit_ledger + user_credits 表, USD 轨三处记账
(消费/充值/管理员调整)同事务接入 ledger。每步含完整测试与代码。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:17:54 +08:00
boos 278b0c66b8 docs: 计费闭环系统设计 spec
双轨额度(USD 池 + 工作流积分池)、ledger-first 统一流水账、
消费明细/充值档位/超额预警/全量审计四块功能、A+C 混合积分计费、
Comfy 余额对账校准。分 6 期实现。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:12:02 +08:00
boos d697146941 feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
2026-06-10 19:54:32 +08:00
boos d1f3f4e93d feat(payment): Stripe Checkout payment module
- payment_orders 建表迁移,支持多 provider(stripe)
- Stripe Checkout Session 创建、Webhook 验签、到账
- 支付成功自动为 API Key 增加额度
- payment.enabled 开关,关闭时不影响旧 Comfy Checkout
2026-06-10 19:54:27 +08:00
boos df97633300 fix(recharge): per-amount distinct checkout URLs
- 前端 /0/0/0 各自携带独立 checkoutUrl
- 后端按金额解析独立 Checkout URL 配置
- 同时校验 optionId 和 amountUsd 防止金额错配
- 移除旧单一固定 checkout URL 兜底
2026-06-10 19:54:20 +08:00
boos 3d54c7d529 fix: support user recharge record cancellation 2026-06-09 17:22:23 +08:00
boos 55b3d4d99a feat: enhance new website experiment motion 2026-06-09 16:59:04 +08:00
boos 59106e6a96 feat: refine BoosAPI console and user workspace 2026-06-09 16:48:18 +08:00
boos aaba6f152e perf: optimize proxy streaming and database access 2026-06-07 00:00:24 +08:00
boos f3d2d50f9a fix: make website boot animation resilient 2026-06-06 22:38:31 +08:00
boos 16162095f7 style: polish admin operations pages 2026-06-06 22:19:42 +08:00
boos 6629d9d0a3 fix: stabilize console topbar layout 2026-06-06 22:09:44 +08:00
boos 3c6f061709 fix: improve assistant thinking and image progress 2026-06-06 22:02:45 +08:00
boos d7d14e077e fix: stabilize website boot loader 2026-06-06 21:54:25 +08:00
boos 3b3292b934 feat: add admin operations war room 2026-06-06 21:50:23 +08:00
boos f7916a42e5 feat: enhance website flow and user workbench 2026-06-06 21:44:58 +08:00
boos f725de28bd feat: polish website and console dashboards 2026-06-06 21:15:33 +08:00
boos 97113964c8 fix: remove sidebar identity card 2026-06-06 21:01:20 +08:00
boos b429695cba fix: stabilize topbar layout 2026-06-06 20:57:33 +08:00
boos d6bf7352a0 feat: support local avatar uploads 2026-06-06 20:52:34 +08:00
boos d800ed1f9f fix: keep admin sidebar actions visible 2026-06-06 20:44:34 +08:00
boos 4855db3fc8 feat: polish user auth entrance 2026-06-06 20:38:18 +08:00
boos 6cd0a8caca fix: stream real agent reasoning 2026-06-06 20:26:31 +08:00
boos 44dc89a0dd refactor: share console page header 2026-06-06 20:18:28 +08:00
boos 823ac3c25d fix: show only generated outputs in workspace 2026-06-06 20:03:02 +08:00
boos 5ecea182bc feat: polish console shell and notification settings 2026-06-06 19:56:56 +08:00
boos cd843465b2 feat: show conversation content in usage logs 2026-06-06 19:36:42 +08:00
boos ef0f91e683 perf: speed up app loading 2026-06-06 19:21:59 +08:00
boos 9c1e5ae293 fix: keep video assistant reasoning label stable 2026-06-06 19:02:41 +08:00
boos 00b3365f5a feat: refine website gateway os hero 2026-06-06 18:59:10 +08:00
boos d5babf5f3f fix: support website color schemes 2026-06-06 18:45:12 +08:00
boos 4b757b8250 feat: add light website intro motion 2026-06-06 18:29:10 +08:00
boos def8d7a204 chore: speed up local build workflow 2026-06-06 18:10:16 +08:00
boos 25c6fdbd6c revert website high contrast redesign 2026-06-06 17:58:10 +08:00
boos 47202e6848 redesign website with high contrast motion 2026-06-06 17:54:38 +08:00
boos 1b2880739a polish auth entry surfaces 2026-06-06 17:42:14 +08:00
boos 188e457e7f polish topbar user experience 2026-06-06 17:31:41 +08:00
boos 13756adc9d polish about page experience 2026-06-06 17:25:31 +08:00
boos f026582008 fix: persist runtime proxy token 2026-06-06 17:11:51 +08:00
boos b3eb86d5ea chore: remove update center 2026-06-06 16:55:15 +08:00
boos 301d086cb6 fix: show runtime logs in program logs 2026-06-06 16:40:55 +08:00
boos 023384b14b style: refine website section spacing 2026-06-06 16:18:11 +08:00
boos d315f0ffe2 feat: polish website model radar 2026-06-06 16:14:05 +08:00
boos f0385c9c8c chore: install vercel agent skills 2026-06-06 16:07:34 +08:00
boos 339f91d014 feat: sync upstream analytics schema support 2026-06-06 16:07:13 +08:00
boos 0d231ec467 feat: enhance website model radar motion 2026-06-06 15:46:51 +08:00
boos be1fc1df48 feat: optimize routes and registration UX 2026-06-06 15:37:37 +08:00
boos dd874bae30 feat: refresh BossAI console frontend 2026-06-06 15:25:04 +08:00
boos 45aa741d9c feat: refine BossAI console and website design 2026-06-06 15:09:53 +08:00
boos 49c398db5e feat: polish BossAI website motion 2026-06-06 14:56:33 +08:00
boos 4d748dd938 feat: refine BossAI website product story 2026-06-06 06:27:28 +08:00
boos b40c522749 feat: add gsap motion to BossAI website 2026-06-06 06:18:40 +08:00
boos d467e27fd8 fix: guard embedded frontend assets 2026-06-06 06:11:15 +08:00
boos 5ef017c54e feat: serve website at root path 2026-06-06 05:59:48 +08:00
boos 03046152fd feat: isolate BossAI website with Next.js 2026-06-06 05:30:10 +08:00
boos d75daf72d6 feat: add premium public landing page 2026-06-06 05:06:37 +08:00
boos d39100b56d feat: optimize money printer factory 2026-06-06 04:56:02 +08:00
boos 2e4c5801b6 chore: tighten repository hygiene 2026-06-06 04:40:25 +08:00
boos 870b2a2da3 feat: surface admin settings attention states 2026-06-06 03:57:15 +08:00
boos 4448567368 feat: optimize admin settings page 2026-06-06 03:49:30 +08:00
boos fae0f607aa test: restore project verification suite 2026-06-06 03:35:29 +08:00
boos e4630c4cb6 fix: harden workflow smoke issues 2026-06-06 03:23:39 +08:00
boos e72e323313 feat: refine money printer comfy template mode 2026-06-06 03:09:08 +08:00
boos 2b836b3e1e Add built-in Cloud video template fallback 2026-06-06 02:58:16 +08:00
boos 598b2f48d6 Handle Comfy Cloud video job status 2026-06-05 22:01:03 +08:00
boos 3f22d7b1ab Make short video factory use Comfy templates 2026-06-05 21:35:12 +08:00
boos 5ce5ea1687 Route Comfy workflows through video API 2026-06-03 11:39:14 +08:00
boos f8741b544e Filter MoneyPrinter Comfy video templates 2026-06-03 11:29:55 +08:00
boos c316a78511 Default MoneyPrinter videos to BoosAPI route 2026-06-03 11:03:04 +08:00
boos 68ae9acb27 Clarify bundled Picture Agent template errors 2026-06-03 11:01:47 +08:00
boos 13c7d8aa4e Remove stale Picture Agent remote template state 2026-06-03 10:59:11 +08:00
boos fb5fc9f1ae Use bundled Linux Do picture templates 2026-06-03 10:55:43 +08:00
boos 5765ea48b0 Clarify Picture Agent template refresh state 2026-06-02 17:38:54 +08:00
boos 55bd3f41f8 Follow Picture Agent gallery data URL 2026-06-02 17:30:15 +08:00
boos 938597b766 Fix Picture Agent loading fallback 2026-06-02 17:23:36 +08:00
boos fe0e3faa22 Generate MoneyPrinter Comfy prompts from form 2026-06-02 17:18:23 +08:00
boos d0f26118ff Normalize MoneyPrinter Comfy workflow templates 2026-06-02 17:11:15 +08:00
boos c98d9a2c6d Load Comfy templates for MoneyPrinter 2026-06-02 17:00:46 +08:00
boos 1293ebae68 Use Comfy API for MoneyPrinter video generation 2026-06-02 16:47:48 +08:00
boos b29c7ffa7a Add native MoneyPrinter short video factory 2026-06-02 15:26:42 +08:00
boos 056e9fb651 Improve digital human template UI 2026-06-02 12:17:05 +08:00
boos fa5a2419d8 Add Comfy digital human jobs 2026-06-02 11:07:31 +08:00
boos 7b34153235 Proxy external template requests 2026-06-01 11:21:56 +08:00
boos 04792bd3f7 Improve offline template loading 2026-06-01 11:02:36 +08:00
boos bb31118792 Reference local Workflow UI repo directly 2026-05-28 17:19:32 +08:00
boos 5027767ca2 Use local Workflow UI source for BossAI deploy 2026-05-28 17:16:12 +08:00
boos fd930757f9 Move BossAI deploy worktree out of tmp 2026-05-28 17:03:08 +08:00
boos eb6ed11cff Add BossAI Debian deploy script 2026-05-28 16:59:51 +08:00
boos 931bcf3fb9 Update BossAI workflow and workspace experience 2026-05-28 10:32:20 +08:00
boos e0bd9b4194 Embed workflow UI and isolate workspace assets 2026-05-26 17:00:10 +08:00
boos 0f9fe4a81d chore: prune legacy node backend and mix workflow models 2026-05-26 15:18:33 +08:00
boos 6869504119 chore: add optional bun frontend build 2026-05-25 22:05:03 +08:00
boos 3f9f9f7665 docs: remove readme screenshots 2026-05-25 21:43:46 +08:00
boos a1f3400af3 docs: add hardware requirements 2026-05-25 21:41:47 +08:00
boos da686ecda4 chore: vendor workflow ui as submodule 2026-05-25 21:19:49 +08:00
boos 1ba293c262 feat: polish workflow studio and workspace assets 2026-05-25 20:42:38 +08:00
boos cb35d5fc6e feat: add workflow studio and postgres support 2026-05-25 16:03:07 +08:00
boos 7cdb02ef1d feat: add workspace and claude messages support 2026-05-22 05:57:37 +08:00
boos c1074ddf71 Integrate ComfyUI agents and video workflows 2026-05-22 04:46:15 +08:00
boos 3c12738138 fix: allow comfyui assets through proxy 2026-05-22 01:27:57 +08:00
boos 8de175a8c5 feat: add comfyui agent attachments 2026-05-22 01:16:37 +08:00
boos 7c6364a160 fix: enable multi tenant comfy workspace 2026-05-22 00:44:57 +08:00
boos 5ef3f8aea8 fix: route video workspace to comfyui proxy 2026-05-21 17:27:41 +02:00
boos 9e7ce80574 fix: complete account token admin routes 2026-05-21 17:16:24 +02:00
boos 65d2f1bbe5 fix: restore account health tokens and dashboard data 2026-05-21 17:07:18 +02:00
boos 0207ba748f fix: use user api key for comfyui agent 2026-05-21 16:55:32 +02:00
boos d0912e44b4 fix: route video agent to comfyui and ignore openspec 2026-05-21 16:44:30 +02:00
457 changed files with 136038 additions and 31418 deletions
+6 -5
View File
@@ -1,18 +1,19 @@
# Cargo build acceleration.
#
# The biggest single speedup is the `mold` linker (~3-5x faster link
# time on debug builds). It isn't installed by default on this host
# (no apt-get, see build-and-run.sh notes), so the mold block below is
# commented out — uncomment it after running:
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
# when available, so the block below is kept as an optional manual setup for
# direct `cargo build` / `cargo run` usage:
#
# sudo apt-get install -y mold
#
# (or build it from source: https://github.com/rui314/mold)
#
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
# Uncomment once mold is on PATH:
# Optional: uncomment once mold is on PATH if you want direct cargo commands
# to use it without going through build-and-run.sh.
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+16
View File
@@ -2,3 +2,19 @@ comfy_source/venv
comfy_source/.git
comfy_source/__pycache__
**/__pycache__
target/
node_modules/
dist/
release/
data/
logs/
tmp/
.agents/
.cache/
.codex/
.claude/
.comfyui/
boosapi
bossai
boosapi.db
docker/*.tar
+12 -1
View File
@@ -1,13 +1,15 @@
/target
**/*.rs.bk
Cargo.lock
.env
.env.*
data/
node_modules/
website/.next/
website/out/
dist/
release/
*.log
*.tsbuildinfo
logs/
tmp/
!tmp/.gitkeep
@@ -19,4 +21,13 @@ docker/.env
.comfyui/
.claude/
.codex/
.agents/
.cache/
openspec/
boosapi
bossai
docker/*.tar
boosapi.db-shm
boosapi.db-wal
.tours/
bun.lock
+4 -4
View File
@@ -1,4 +1,4 @@
[submodule "comfy_source"]
path = comfy_source
url = https://github.com/comfyanonymous/ComfyUI.git
branch = master
[submodule "vendor/ComfyUI_frontend"]
path = vendor/ComfyUI_frontend
url = https://github.com/Comfy-Org/ComfyUI_frontend.git
branch = main
Generated
+4177
View File
File diff suppressed because it is too large Load Diff
+19 -14
View File
@@ -4,15 +4,6 @@ version = "1.3.0"
edition = "2021"
description = "AI API Gateway — Rust rewrite of BoosAPI"
[features]
# Default off so stock builds keep using the legacy embedded ComfyUI
# singleton (`state.comfyui`). Enable once the per-user pool has been
# validated in staging (see openspec/changes/comfyui-multi-tenant-api-only,
# rollout step 9.2). Once 9.4 lands, this flag and the legacy path
# disappear together.
default = []
multi_tenant_comfy = []
[dependencies]
# Web framework
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
@@ -22,11 +13,14 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
# Async runtime
async-stream = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"postgres",
"any",
"migrate",
"chrono",
] }
@@ -67,6 +61,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
# Streaming
futures = "0.3"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
tokio-stream = "0.1"
bytes = "1"
@@ -74,11 +69,12 @@ bytes = "1"
once_cell = "1"
parking_lot = "0.12"
mime = "0.3"
# WebSocket client (used by /comfy/* WS proxy to forward upstream to per-user containers)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] }
async-trait = "0.1"
urlencoding = "2"
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
include_dir = "0.7"
mime_guess = "2"
[dev-dependencies]
rstest = "0.24"
@@ -92,6 +88,15 @@ opt-level = 3
lto = true
codegen-units = 1
# Faster production-like build for staging/manual verification. This keeps
# optimizations on but avoids the slow LTO + single-codegen-unit release path.
[profile.fast-release]
inherits = "release"
opt-level = 2
lto = false
codegen-units = 256
incremental = true
# Dev-profile speedups: line-tables-only debuginfo (much smaller .debug
# sections → much faster link) and 256 codegen units (more parallelism
# during codegen, slightly worse runtime — fine for dev). Dependencies
+73
View File
@@ -66,6 +66,31 @@
## 3. 生产环境 (Production)
### 单二进制生产包
推荐用项目脚本生成生产包:
```bash
./build-and-run.sh --production
```
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置:
```bash
./boosapi --check-config
./boosapi --migrate-only
./boosapi --config config.toml
```
### 推荐:Docker Compose 部署
1. **准备 docker-compose.yml**:
@@ -161,8 +186,54 @@
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
### Workflow 后端配置
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
外部完整版 ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native 执行器:
```toml
[comfyui]
backend = "boosapi"
```
说明:
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
---
## 5. 运维操作 (Ops)
@@ -179,6 +250,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
cp data/boosapi.db data/boosapi.db.bak
```
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
### 查看日志
如果使用 Systemd:
```bash
+231 -25
View File
@@ -2,14 +2,14 @@
# BoosAPI
**AI 聚合网关 + ComfyUI Agent — 将分散的中转站整合为一个统一入口**
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
<p>
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
<br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br>
<strong>内置 ComfyUI 视频助手</strong> — 自然语言描述脚本,自动生成角色、场景、TTS、ComfyUI 工作流。
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
<br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p>
@@ -53,21 +53,33 @@
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
| 🎨 想用 ComfyUI 但不想配工作流 | **对话助手**,自然语言描述即可生成图像/视频 |
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
---
## 2026-06 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
- **通知隔离**:普通用户顶部通知只读取用户公告;管理员通知面板继续聚合签到、余额、令牌、代理、状态与公告事件。
- **后台信息架构**:侧栏按接入管理、创作应用、系统管理、我的账户精简;外部监控入口已移除,系统日志入口保留在系统管理分组。
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
## ✨ 核心功能
### 🎨 内置 ComfyUI 视频助手(Rust)
### 🎨 Workflow Studio 与视频助手(Rust)
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音
- **ComfyUI 工作流导出**:完整 workflow JSON,可直接导入 ComfyUI
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
@@ -154,6 +166,103 @@
---
## 🖥️ 硬件配置建议
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|----------|-----|------|------|-----|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
推荐的 SaaS 网关部署规格:
```txt
4 核 CPU
8GB 内存
50GB SSD
无需 GPU
```
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
## 🎛️ Workflow API 后端模式
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
| backend | 用途 |
|---------|------|
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
| `external` | 代理你自己部署的完整版 ComfyUI |
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
配置示例:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
```toml
[comfyui]
backend = "boosapi"
```
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
2. 模板图里最好保存节点映射:
- `prompt`:提示词节点和字段
- `negativePrompt`:反向提示词节点和字段,可选
- `ratio` / `resolution` / `duration`:画幅、分辨率和时长节点字段,可选
3. 在模型路由里新增一个模型,例如 `comfy-wan-video`。
4. 让该路由的上游地址或实际模型映射指向模板:
- 上游地址:`comfy://<workflow_public_id>`
- 或模型映射:`comfy:<workflow_public_id>`
之后用户可以直接调用:
```bash
curl http://127.0.0.1:4000/v1/videos \
-H "Authorization: Bearer <BoosAPI downstream key>" \
-H "Content-Type: application/json" \
-d '{
"model": "comfy-wan-video",
"prompt": "cinematic product video, smooth camera movement",
"duration": 5,
"ratio": "9:16",
"resolution": "1080p"
}'
```
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始
### Docker Compose(推荐)
@@ -178,14 +287,14 @@ services:
restart: unless-stopped
EOF
# AUTH_TOKEN = 管理后台登录令牌
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=sk-your-proxy-token
docker compose up -d
```
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
> [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
@@ -209,8 +318,8 @@ docker compose up -d
│ ├── db/ # 数据库模型与查询
│ ├── routes/
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
│ │ ├── comfyui_agent.rs # ComfyUI 视频助手 SSE 聊天
│ │ ├── comfyui/ # ComfyUI 代理(prompt/history/view)
│ │ ├── comfyui_agent.rs # 视频/工作流助手 SSE 聊天
│ │ ├── comfyui/ # 外部 ComfyUI 代理(prompt/history/view)
│ │ ├── proxy/ # OpenAI 兼容代理入口
│ │ ├── auth.rs # 用户注册/登录 JWT
│ │ ├── checkin.rs # 自动签到
@@ -228,14 +337,16 @@ docker compose up -d
│ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移
├── src/web/ # 前端源码(React 19 + Vite)
├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
│ │ ├── ComfyUIAgent.tsx # 视频助手聊天界面
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ...
│ └── components/
├── website/ # 独立官网(Next.js 16 App Router + React 19)
└── dist/web/ # 前端构建产物(由 Rust serve)
```
@@ -254,16 +365,30 @@ BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
# 数据库
# 数据库(默认 SQLite)
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
# 上游 API(ComfyUI Agent 回退用,默认本地模式时不需要设置)
# PostgreSQL 准备/迁移
# 当前版本保留 SQLite 作为运行主路径;可先把 SQLite 核心数据迁移到 PostgreSQL。
# 完整 PostgreSQL 业务查询适配需要后续切换运行时 SQL 方言。
# 示例:
# boosapi migrate-sqlite-to-postgres \
# --sqlite-url 'sqlite:data/boosapi.db?mode=rwc' \
# --postgres-url 'postgres://boosapi:password@127.0.0.1:5432/boosapi'
# 目标库已有数据时默认拒绝;确认覆盖时追加 --force-truncate。
# 上游 API(视频/工作流助手回退用,默认本地模式时不需要设置)
# 设置 BOOSAPI_UPSTREAM_BASE 后切换到 fallback-remote 模式;BOOSAPI_UPSTREAM_KEY 缺省时仍用内置 comfyui-agent key
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-your-key
# 前端静态目录(默认 dist/web)
BOOSAPI_WEB_DIR=dist/web
# Workflow 后端
BOOSAPI_COMFYUI__BACKEND=cloud
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
```
## 🏗️ 技术栈
@@ -272,13 +397,15 @@ BOOSAPI_WEB_DIR=dist/web
|----|------|
| 后端框架 | **Axum 0.8**(Rust) |
| 运行时 | **Tokio**(异步运行时) |
| 数据库 | **SQLite** + **SQLx**(编译期 SQL 检查) |
| 数据库 | **SQLite** 运行主路径 + **PostgreSQL** schema/迁移准备(SQLx) |
| 并行计算 | **Rayon**(CPU 密集任务) |
| 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| ComfyUI Agent | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) |
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 管理台前端 | React + Vite(仅构建工具) |
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
| 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s |
@@ -286,6 +413,8 @@ BOOSAPI_WEB_DIR=dist/web
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
---
## 🛠️ 本地开发
@@ -293,11 +422,20 @@ BOOSAPI_WEB_DIR=dist/web
### 后端(Rust)
```bash
# 编译并运行
cargo run --release
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
./build-and-run.sh --check
# 仅检查编译
cargo check
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
./build-and-run.sh
# 只重启/编译后端,不碰 Vite/Next
./build-and-run.sh --backend-only
# 接近生产性能但比 release 快
./build-and-run.sh --fast-release
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
./build-and-run.sh --production
# 运行测试
cargo test
@@ -308,9 +446,15 @@ export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
export BOOSAPI_UPSTREAM_KEY=sk-boos4721
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
```
构建加速说明:
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
### 前端(React SPA)
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
@@ -322,14 +466,76 @@ npm install
# 开发模式(热更新,需要 Node.js)
cd src/web && npx vite --host 0.0.0.0
# 构建生产版本
npm run build
# 类型检查与构建生产版本
npm run typecheck:web
npm run build:web
# 输出到 dist/web/,Rust 后端自动 serve
```
Workflow UI 前端资产单独更新:
```bash
# Comfy Cloud 前端
npm run workflow-ui:install:cloud
# External / RunningHub 前端
npm run workflow-ui:install:external
```
可选使用 Bun 加速主前端构建:
```bash
# 一次性使用 Bun 构建主前端
./build-and-run.sh --bun
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
```
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js)
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
```bash
# 首次安装
npm --prefix website install
# 本地开发官网
npm run website:dev
# 类型检查与生产构建
npm run website:typecheck
npm run website:build
```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建
```bash
./build-and-run.sh --production
```
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
仓库卫生说明:
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
### Docker 部署
```bash
+140 -151
View File
@@ -2,206 +2,195 @@
# BoosAPI
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point**
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
<p>
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
<br>
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization.
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
<br>
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
<br>
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
</p>
<p align="center">
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</p>
<p align="center">
<a href="README.md">中文</a> |
<a href="README_EN.md"><strong>English</strong></a>
<a href="README_EN.md"><strong>English</strong></a> |
<a href="DEPLOY.md">Deployment</a>
</p>
</div>
---
## 📖 Introduction
## Introduction
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming.
BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes.
Supported upstreams:
### Supported Upstreams
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
## Key Features
| Pain Point | BoosAPI Solution |
|------------|-----------------|
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
| 📊 Scattered balances | **Central dashboard** with alerts |
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
| 🤷 What models are available? | **Auto model discovery**, zero config |
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
### Unified API Gateway
---
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
- Admin and user dashboards with API key management and model visibility controls
## ✨ Features
### Workflow Studio
### 🎨 Built-in ComfyUI Chat Assistant
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline
- **Multi-view generation**: front/back/left/right directional image generation
- **Scene/video generation**: text description directly generates video content
- **SSE real-time streaming**: generation progress feedback
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
| backend | Purpose |
|---------|---------|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
| `external` | Proxy a full ComfyUI instance you deploy yourself |
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
### 🌐 Unified Proxy Gateway
Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
- Compatible with **OpenAI** and **Claude** downstream formats
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
- SSE streaming with automatic format conversion
### Assistants
### 🧠 Smart Routing Engine
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
- Volcengine video task templates for create-task and query-task flows
- Auto-discovers models from all upstream stations
- Four-tier cost signals: measured → configured → catalog → fallback
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
- Auto cooldown on failure (default 10 min), seamless channel switching
## Hardware Guide
### 📡 Multi-Platform Management
| Mode | CPU | Memory | Disk | GPU |
|------|-----|--------|------|-----|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
| Platform | Adapter |
|----------|---------|
| New API | `new-api` |
| One API | `one-api` |
| OneHub | `onehub` |
| DoneHub | `done-hub` |
| Veloera | `veloera` |
| AnyRouter | `anyrouter` |
| Sub2API | `sub2api` |
Recommended SaaS gateway server:
### 👥 Account & Token Management
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
- Encrypted credential storage, auto-renewal, cascading disable
### 💰 Balance Management
- Scheduled refresh (default hourly), income/expense trends
- Fallback estimation when API is unavailable
### 👥 SaaS Multi-Tenant
- **Organizations**: Create orgs, each with independent members and quotas
- **Member Invitations**: Email-based invites with admin/member roles
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
- **Role Control**: Owner / Admin / Member with granular permissions
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
- **Invite Links**: Token-based, 48h expiry
### 🔔 Alerts & Notifications
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
### 🔌 REST API Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/organizations` | GET/POST | List / Create org |
| `/api/organizations/plans` | GET | List available plans |
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
| `/api/organizations/:id/members` | GET | List members |
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
| `/api/invitations/:token` | GET | Public invite lookup |
| `/api/invitations/:token/accept` | POST | Accept invitation |
| `/api/users/register` | POST | Register new user |
| `/api/users/login` | POST | Login (returns JWT) |
| `/api/users/me` | GET/PATCH | Profile / Update |
| `/api/user-api-keys` | GET/POST | List / Create API key |
---
## 🚀 Quick Start
### Docker Compose (Recommended)
```bash
mkdir boosapi && cd boosapi
cat > docker-compose.yml << 'EOF'
services:
boosapi:
image: 1467078763/boosapi:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
CHECKIN_CRON: "0 8 * * *"
BALANCE_REFRESH_CRON: "0 * * * *"
PORT: ${PORT:-4000}
DATA_DIR: /app/data
TZ: ${TZ:-Asia/Shanghai}
restart: unless-stopped
EOF
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d
```txt
4 CPU cores
8GB RAM
50GB SSD
No GPU required
```
Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
## Quick Start
---
```bash
cp config.toml.example config.toml
./build-and-run.sh
```
## 🏗️ Tech Stack
For a production bundle with embedded web assets:
```bash
./build-and-run.sh --production
```
The production output is intended to run with only:
```txt
boosapi
config.toml
```
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
## Workflow Backend Configuration
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
External ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native executor:
```toml
[comfyui]
backend = "boosapi"
```
## Environment Variables
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
```bash
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
```
## Tech Stack
| Layer | Tech |
|-------|------|
| Backend | Fastify |
| Frontend | React 18 + Vite |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
| Visualization | VChart |
| Container | Docker + Docker Compose |
| Test | Vitest |
| Backend | Rust, Axum 0.8, Tokio |
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
| Proxy engine | Token Router + Proxy Conductor |
| Protocols | OpenAI / Claude / Gemini compatible transformers |
| Frontend | React 19 + Vite |
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
| Container | Docker / Docker Compose |
---
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
## 🛠️ Local Development
## Local Development
```bash
npm install
npm run db:migrate
npm run dev
cargo check
cargo run
pnpm install
pnpm run dev:web
```
Build checks:
```bash
npm run build # Build web + server
npm test # Run tests
cargo check
pnpm run typecheck:web
npx vite build
```
---
## 📜 License
## License
[MIT](LICENSE)
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+470 -83
View File
@@ -6,46 +6,226 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
# ── 参数 ──────────────────────────────────────────────────────────────────────
RELEASE=0
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
FRONTEND_ONLY=0
BACKEND_ONLY=0
CHECK_ONLY=0
NO_RUN=0
REBUILD_IMAGE=0
FORCE_FRONTEND=0
SKIP_FRONTEND=0
PRODUCTION_BUNDLE=0
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
BUILD_TIMINGS=()
usage() {
cat <<'EOF'
用法: ./build-and-run.sh [选项]
选项:
--dev 使用 dev 模式编译后端(默认,最快)
--release 使用 release 模式编译后端(更慢但性能好)
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
--profile <name> 指定构建 profile:dev / release / fast-release
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
--prod 同 --production
--production-dir <dir>
指定生产产物目录(默认:dist/production)
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
--backend-check 同 --check
--frontend-only 只构建前端
--backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存
--skip-frontend 跳过前端构建(沿用现有 dist/web)
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
--no-run 构建后不启动服务
--rebuild-image 强制重新构建多租户 ComfyUI 镜像(默认:已存在则跳过)
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
-h, --help 显示帮助
示例:
./build-and-run.sh --release
./build-and-run.sh --fast-release
./build-and-run.sh --check
./build-and-run.sh --bun
./build-and-run.sh --production
./build-and-run.sh --production --production-dir /opt/boosapi-release
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--release) RELEASE=1 ;;
--dev) BUILD_PROFILE="dev" ;;
--release) BUILD_PROFILE="release" ;;
--fast-release) BUILD_PROFILE="fast-release" ;;
--production|--prod)
PRODUCTION_BUNDLE=1
BUILD_PROFILE="release"
NO_RUN=1
;;
--production-dir)
shift
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
PRODUCTION_DIR="$1"
;;
--check|--backend-check)
CHECK_ONLY=1
BACKEND_ONLY=1
NO_RUN=1
;;
--profile)
shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
BUILD_PROFILE="$1"
;;
--frontend-only) FRONTEND_ONLY=1 ;;
--backend-only) BACKEND_ONLY=1 ;;
--force-frontend) FORCE_FRONTEND=1 ;;
--skip-frontend) SKIP_FRONTEND=1 ;;
--bun) FRONTEND_PM="bun" ;;
--no-run) NO_RUN=1 ;;
--rebuild-image) REBUILD_IMAGE=1 ;;
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
# ── 工具函数 ──────────────────────────────────────────────────────────────────
if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--frontend-only 和 --backend-only 不能同时使用"
fi
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
fail "--production 不能和 --frontend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
fail "--production 不能和 --check 同时使用"
fi
case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;;
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
esac
case "$FAST_LINKER" in
auto|off|none|mold|lld) ;;
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
esac
timer_start() {
date +%s
}
record_timing() {
local label="$1"
local started="$2"
local ended
ended="$(date +%s)"
BUILD_TIMINGS+=("${label}: $((ended - started))s")
}
print_timing_summary() {
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
log "构建耗时:"
local item
for item in "${BUILD_TIMINGS[@]}"; do
log " - $item"
done
}
append_rustflags() {
local flags="$1"
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
fi
}
build_cargo_args() {
CARGO_BASE_ARGS=()
CARGO_FEATURE_ARGS=()
if [ -n "$CARGO_JOBS" ]; then
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
fi
if [ -n "$CARGO_FEATURES" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
fi
}
configure_fast_linker() {
local mode="$FAST_LINKER"
case "$(uname -s 2>/dev/null || true)" in
Linux) ;;
*)
[ "$mode" = "auto" ] && return 0
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
;;
esac
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
return 0
fi
if [ "$mode" = "auto" ]; then
if command -v mold >/dev/null 2>&1; then
mode="mold"
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
mode="lld"
else
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
return 0
fi
fi
case "$mode" in
mold)
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=mold"
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
lld)
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
fi
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=lld"
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
esac
}
server_port() {
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
@@ -113,7 +293,7 @@ stop_previous_server() {
while IFS= read -r pid; do
[ -n "$pid" ] && binary_pids+=("$pid")
done < <(pgrep -f "target/(debug|release)/boosapi" 2>/dev/null || true)
done < <(pgrep -f "target/(debug|release|fast-release)/boosapi" 2>/dev/null || true)
stop_pids "boosapi binary" "${binary_pids[@]}"
@@ -129,110 +309,317 @@ stop_previous_server() {
fi
}
# ── 同步 ComfyUI 子模块 + 应用本地补丁 ─────────────────────────────────────────
# comfy_source/ 是 vanilla ComfyUI 的 git 子模块,本地补丁存放在
# comfy_patches/overlay/,由 comfy_patches/apply.sh 覆盖到子模块上。
# Dockerfile 把整个 comfy_source/ 复制进镜像,所以这一步必须在 docker
# build 之前完成,否则镜像里只会有 vanilla 代码。
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return 0
fi
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
log "未找到 pnpm,尝试通过 corepack 自动启用..."
corepack enable
corepack prepare pnpm@latest --activate
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
}
if [ -f "$ROOT_DIR/.gitmodules" ] && [ -d "$ROOT_DIR/.git" ]; then
if [ ! -f "$ROOT_DIR/comfy_source/main.py" ]; then
log "初始化 ComfyUI 子模块..."
git submodule update --init --recursive
ok "子模块已初始化"
fi
if [ -d "$ROOT_DIR/comfy_patches/overlay" ]; then
log "应用 ComfyUI 本地补丁 (comfy_patches/overlay → comfy_source/)..."
"$ROOT_DIR/comfy_patches/apply.sh"
ok "本地补丁已应用"
install_frontend_dependencies() {
local frontend_pm="$1"
if [ "$frontend_pm" = "bun" ]; then
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (bun install)..."
bun install
fi
return 0
fi
# ── 构建前端 ──────────────────────────────────────────────────────────────────
if [ "$BACKEND_ONLY" -eq 0 ]; then
log "构建前端 (Vite)..."
if ! command -v npx >/dev/null 2>&1; then
fail "未找到 npx,请先安装 Node.js"
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
ensure_pnpm
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (pnpm install)..."
pnpm install --frozen-lockfile
fi
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
npx vite build
}
build_frontend() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)"
record_timing "前端检查" "$started"
return 0
fi
local frontend_pm="$FRONTEND_PM"
case "$frontend_pm" in
auto)
if command -v bun >/dev/null 2>&1; then
frontend_pm="bun"
else
frontend_pm="npm"
fi
;;
npm|bun) ;;
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
esac
install_frontend_dependencies "$frontend_pm"
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'build-script-v2\n'
printf 'frontend_pm=%s\n' "$frontend_pm"
printf 'node='
node --version 2>/dev/null || true
printf 'npx='
npx --version 2>/dev/null || true
printf 'bun='
bun --version 2>/dev/null || true
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "前端无变化,跳过 Vite 构建 → dist/web/"
record_timing "前端检查" "$started"
return 0
fi
log "构建前端 (Vite via ${frontend_pm})..."
# 直接调用 vite build;桌面端构建链路已移除。
if [ "$frontend_pm" = "bun" ]; then
bunx --bun vite build
else
pnpm exec vite build
fi
mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/"
record_timing "前端构建" "$started"
}
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
record_timing "官网检查" "$started"
return 0
fi
# ── 构建后端 ──────────────────────────────────────────────────────────────────
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
record_timing "官网检查" "$started"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
record_timing "官网构建" "$started"
}
ensure_workflow_ui_for_embedding() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)"
record_timing "Workflow UI 检查" "$started"
return 0
fi
if [ -f "$ROOT_DIR/dist/workflow-ui-external/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui-cloud/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
record_timing "Workflow UI 检查" "$started"
return 0
fi
command -v git >/dev/null 2>&1 || fail "未找到 git,无法构建 Workflow UI"
command -v node >/dev/null 2>&1 || fail "未找到 node,无法构建 Workflow UI"
ensure_pnpm
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
pnpm run workflow-ui:install
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
record_timing "Workflow UI 构建" "$started"
}
build_backend() {
local started
started="$(timer_start)"
if [ "$FRONTEND_ONLY" -eq 0 ]; then
if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链"
fi
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。
if ! command -v mold >/dev/null 2>&1; then
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
configure_fast_linker
build_cargo_args
if [ "$CHECK_ONLY" -eq 1 ]; then
if [ "$BUILD_PROFILE" = "release" ]; then
log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
else
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
fi
ok "后端检查完成"
record_timing "后端检查" "$started"
return 0
fi
if [ "$RELEASE" -eq 1 ]; then
log "构建后端 (cargo build --release)..."
cargo build --release
if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi"
else
log "构建后端 (cargo build)..."
cargo build
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi"
fi
record_timing "后端构建" "$started"
}
build_production_bundle() {
local started
started="$(timer_start)"
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
rm -rf "$PRODUCTION_DIR"
mkdir -p "$PRODUCTION_DIR"
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
chmod +x "$PRODUCTION_DIR/boosapi"
if command -v strip >/dev/null 2>&1; then
log "压缩生产二进制符号表 (strip)..."
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
fi
# ── 构建多租户 ComfyUI 镜像 ───────────────────────────────────────────────────
# 多租户 ComfyUI 镜像 (boosapi-comfyui:multi-tenant) 由 ComfyUIPool 在每次
# 拉新用户容器时使用。镜像很重(~700MB),不必每次 ./build-and-run.sh 都
# 重打。优先级:
# 1. 已存在同名镜像 → 跳过(--rebuild-image 强制重建)
# 2. 有 docker/boosapi-comfyui-multi-tenant-*.tar 备份 → docker load
# 3. 都没有 → docker build
#
# `docker info` 用来确认 daemon 真的在跑,避免在只装了 client 的环境里
# 卡 build。失败的话整段跳过,API 仍可走 legacy 单实例路径。
ok "生产产物已生成 → $PRODUCTION_DIR"
log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
record_timing "生产打包" "$started"
}
if [ "$FRONTEND_ONLY" -eq 0 ]; then
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
IMAGE="boosapi-comfyui:multi-tenant"
SAVED_TAR="$(ls "$ROOT_DIR"/docker/boosapi-comfyui-multi-tenant-*.tar 2>/dev/null | head -1 || true)"
DOCKERFILE="$ROOT_DIR/docker/Dockerfile.comfyui.multi-tenant"
if [ "$REBUILD_IMAGE" -eq 0 ] && docker image inspect "$IMAGE" >/dev/null 2>&1; then
log "镜像 $IMAGE 已存在,跳过构建(--rebuild-image 强制重建)"
elif [ "$REBUILD_IMAGE" -eq 0 ] && [ -n "$SAVED_TAR" ]; then
log "从 $SAVED_TAR 加载 $IMAGE..."
docker load -i "$SAVED_TAR"
ok "镜像已加载 → $IMAGE"
elif [ -f "$DOCKERFILE" ]; then
log "构建多租户 ComfyUI 镜像 ($IMAGE)..."
docker build \
-f "$DOCKERFILE" \
-t "$IMAGE" \
"$ROOT_DIR"
ok "ComfyUI 多租户镜像构建完成 → $IMAGE"
else
log "未找到 $DOCKERFILE,跳过镜像构建"
fi
else
log "docker 不可用(未安装或 daemon 未运行),跳过多租户 ComfyUI 镜像准备"
fi
cleanup_started_server() {
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
log "停止本次启动的后端进程: $STARTED_PID"
kill -TERM "$STARTED_PID" 2>/dev/null || true
wait "$STARTED_PID" 2>/dev/null || true
fi
}
# ── 启动服务 ──────────────────────────────────────────────────────────────────
# ── 构建 / 启动 ────────────────────────────────────────────────────────────────
if [ "$NO_RUN" -eq 1 ] || [ "$FRONTEND_ONLY" -eq 1 ]; then
log "跳过启动(--no-run 或 --frontend-only)"
if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend
build_public_website
print_timing_summary
log "跳过启动(--frontend-only)"
exit 0
fi
# 启动前清理旧后端进程和端口占用,避免 exec 后绑定端口失败。
if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend
build_public_website
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding
fi
fi
build_backend
if [ "$CHECK_ONLY" -eq 1 ]; then
print_timing_summary
log "跳过启动(--check)"
exit 0
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
build_production_bundle
print_timing_summary
exit 0
fi
if [ "$NO_RUN" -eq 1 ]; then
print_timing_summary
log "跳过启动(--no-run)"
exit 0
fi
print_timing_summary
stop_previous_server
log "启动服务..."
if [ "$BACKEND_ONLY" -eq 1 ]; then
exec "$BINARY"
fi
"$BINARY" &
STARTED_PID=$!
trap cleanup_started_server INT TERM EXIT
ok "后端已启动,PID: $STARTED_PID"
ok "前后端已就绪(profile: $BUILD_PROFILE),按 Ctrl+C 停止服务"
wait "$STARTED_PID"
+97 -11
View File
@@ -14,12 +14,95 @@ import os
import requests
# ── Config ───────────────────────────────────────────────────────────────
BOOSAPI_BASE = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000")
BOOSAPI_BASE_RAW = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000")
BOOSAPI_KEY = os.environ.get("BOOSAPI_KEY", "sk-boos4721")
HEADERS = {
"Authorization": f"Bearer {BOOSAPI_KEY}",
"Content-Type": "application/json",
}
FALLBACK_CHAT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"]
FALLBACK_IMAGE_MODELS = ["gpt-image-2", "gpt-image-1", "dall-e-3", "dall-e-2"]
FALLBACK_ALL_MODELS = FALLBACK_IMAGE_MODELS + FALLBACK_CHAT_MODELS
def normalize_boosapi_root(base):
base = str(base or "http://127.0.0.1:4000").strip().rstrip("/")
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
return base or "http://127.0.0.1:4000"
BOOSAPI_ROOT = normalize_boosapi_root(BOOSAPI_BASE_RAW)
BOOSAPI_V1 = f"{BOOSAPI_ROOT}/v1"
def raise_for_status_with_body(resp):
try:
resp.raise_for_status()
except requests.HTTPError as e:
body = (resp.text or "").strip()
if body:
print(f"[BoosAPI] HTTP {resp.status_code} response: {body[:2000]}")
raise requests.HTTPError(f"{e}; response body: {body[:1000]}", response=resp)
raise
def _ordered_unique(values):
seen = set()
result = []
for value in values:
value = str(value or "").strip()
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result
def fetch_boosapi_models():
"""Fetch models exposed by BoosAPI's own /v1/models route."""
try:
resp = requests.get(f"{BOOSAPI_V1}/models", headers=HEADERS, timeout=8)
resp.raise_for_status()
data = resp.json()
models = []
for item in data.get("data", []):
if isinstance(item, dict):
models.append(item.get("id") or item.get("name"))
elif isinstance(item, str):
models.append(item)
return _ordered_unique(models)
except Exception as e:
print(f"[BoosAPI] model list fallback: {e}")
return None
def model_choices(kind="all"):
models = fetch_boosapi_models()
if models is None:
if kind == "image":
return FALLBACK_IMAGE_MODELS
if kind == "chat":
return FALLBACK_CHAT_MODELS
return FALLBACK_ALL_MODELS
if kind == "image":
image_like = [
model for model in models
if any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(image_like + models) or FALLBACK_IMAGE_MODELS
if kind == "chat":
chat_like = [
model for model in models
if not any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(chat_like + models) or FALLBACK_CHAT_MODELS
return _ordered_unique(models) or FALLBACK_ALL_MODELS
def default_model(kind="all"):
choices = model_choices(kind)
return choices[0] if choices else FALLBACK_ALL_MODELS[0]
# ── Node Mappings ────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {}
@@ -37,7 +120,7 @@ class BoosAPIImageGen:
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (["dall-e-3", "dall-e-2", "gpt-image-2"], {"default": "gpt-image-2"}),
"model": (model_choices("image"), {"default": default_model("image")}),
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
"quality": (["standard", "hd"], {"default": "standard"}),
"n": ("INT", {"default": 1, "min": 1, "max": 4}),
@@ -50,10 +133,11 @@ class BoosAPIImageGen:
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "metadata")
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def generate(self, prompt, model, size, quality, n, style="vivid"):
url = f"{BOOSAPI_BASE}/v1/images/generations"
url = f"{BOOSAPI_V1}/images/generations"
payload = {
"model": model,
"prompt": prompt,
@@ -65,7 +149,7 @@ class BoosAPIImageGen:
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status()
raise_for_status_with_body(resp)
data = resp.json()
images = data.get("data", [])
# Return first image as b64
@@ -104,7 +188,7 @@ class BoosAPILLM:
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": ("STRING", {"default": "gpt-5.5"}),
"model": (model_choices("chat"), {"default": default_model("chat")}),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}),
@@ -114,10 +198,11 @@ class BoosAPILLM:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "chat"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def chat(self, prompt, model, system_prompt, temperature, max_tokens):
url = f"{BOOSAPI_BASE}/v1/chat/completions"
url = f"{BOOSAPI_V1}/chat/completions"
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
@@ -132,10 +217,10 @@ class BoosAPILLM:
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status()
raise_for_status_with_body(resp)
data = resp.json()
text = data["choices"][0]["message"]["content"]
return (text,)
return {"ui": {"text": (text,)}, "result": (text,)}
except Exception as e:
print(f"[BoosAPI] LLM chat failed: {e}")
raise
@@ -153,7 +238,7 @@ class BoosAPITextToImage:
return {
"required": {
"positive": ("STRING", {"multiline": True, "default": ""}),
"model": (["gpt-image-2", "dall-e-3"], {"default": "gpt-image-2"}),
"model": (model_choices("image"), {"default": default_model("image")}),
},
"optional": {
"negative": ("STRING", {"multiline": True, "default": ""}),
@@ -163,6 +248,7 @@ class BoosAPITextToImage:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def generate(self, positive, model, negative="", size="1024x1024"):
@@ -171,7 +257,7 @@ class BoosAPITextToImage:
if negative:
prompt = f"{positive}\n避免: {negative}"
url = f"{BOOSAPI_BASE}/v1/images/generations"
url = f"{BOOSAPI_V1}/images/generations"
payload = {
"model": model,
"prompt": prompt,
@@ -181,7 +267,7 @@ class BoosAPITextToImage:
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status()
raise_for_status_with_body(resp)
data = resp.json()
import base64
-136
View File
@@ -1,136 +0,0 @@
# comfy_patches
Local patches that ride on top of vanilla ComfyUI (the `comfy_source/` git
submodule).
`overlay/` mirrors the ComfyUI tree — every file under it is copied
verbatim over the matching path inside `comfy_source/` by
`apply.sh`. Run that whenever you bump the submodule or after a fresh
clone with `--recurse-submodules`.
## Why an overlay instead of a fork
Pinning a fork would mean rebasing it every time we bump ComfyUI. An
overlay keeps the upstream submodule clean: the patches are visible in
git, easy to diff against vanilla, and trivial to drop once they're
upstreamed.
## Patch inventory
| File | Type | Notes |
|---|---|
| `comfy_api_nodes/util/_helpers.py` | BoosAPI integration | `get_auth_header` falls back to `BOOSAPI_BUILTIN_KEY`; `default_base_url()` reads `--comfy-api-base`. Required for the multi-tenant pool — the per-user containers authenticate to BoosAPI through this. |
| `app/frontend_management.py` + `server.py` + `openapi.yaml` | Feature | Adds `comfy_package_versions` to `/system_stats` so the admin UI can surface drift across `comfy*` packages. Candidate for upstreaming. |
| `comfy_extras/nodes_audio.py` | Hardening | Guards Save/Preview/Trim/Split nodes against `audio is None` (happens when source video has no audio track). Candidate for upstreaming. |
| `requirements.txt` | Bump | `comfyui-workflow-templates==0.9.77` (vanilla pins 0.9.75). |
| `tests-unit/app_test/frontend_manager_test.py` | Test | Covers the new `get_comfy_package_versions()` path. |
| `tests-unit/comfy_test/model_detection_test.py` | Test | Adjusted assertions for the audio-node guards. |
| `README.md`, `SECURITY.md` | Docs | Local edits. Not load-bearing. |
## Apply
```bash
./comfy_patches/apply.sh
```
Idempotent — safe to re-run. Aborts if `comfy_source/` is dirty (so you
don't silently clobber upstream work-in-progress).
## Upgrading vanilla ComfyUI
This is the SOP whenever you want to bump the `comfy_source/` submodule
to a newer upstream commit / tag.
```bash
cd /root/metapi
# 1. Pull the new upstream commit into the submodule.
# Use --remote to follow the branch tracked in .gitmodules; pin to a
# specific tag with `git -C comfy_source checkout <tag>` instead.
git submodule update --remote comfy_source
# 2. Re-apply the local overlay on top.
# apply.sh aborts if comfy_source/ is dirty — commit or stash first.
./comfy_patches/apply.sh
# 3. Rebuild the multi-tenant image.
# --no-run skips starting the boosapi binary; we only want the image.
./build-and-run.sh --no-run
# 4. Smoke-test the new image without touching the live pool.
docker run --rm -p 18188:8188 \
-e BOOSAPI_BUILTIN_KEY=test \
-e BOOSAPI_BASE=http://host.docker.internal:8787 \
-e BOOSAPI_KEY=test \
boosapi-comfyui:multi-tenant &
sleep 5
curl -s http://localhost:18188/system_stats | jq .comfy_package_versions
# Expect a JSON object with comfy / comfyui_frontend_package versions.
# Ctrl-C the container when done; --rm cleans it up.
```
### When upstream changes a file we patch
If `apply.sh` finds that vanilla has changed under one of the overlay
paths, it copies our overlay over anyway — vanilla's new content is
silently lost. To detect this before bumping:
```bash
# Diff our overlay against the new vanilla, file by file.
for f in $(cd comfy_patches/overlay && find . -type f); do
diff -u "comfy_source/$f" "comfy_patches/overlay/$f" \
| head -40
done
```
For each diff that shows upstream behaviour we want to keep (not just
formatting), edit `comfy_patches/overlay/<file>` to merge the
intent — then re-run `apply.sh`. The patch-inventory table above is
the canonical list of files to check.
### Backout
If the new upstream breaks something:
```bash
# Roll the submodule back to whatever commit was pinned in HEAD.
git submodule update comfy_source
./comfy_patches/apply.sh
./build-and-run.sh --no-run
```
The previous image is still in `docker images` (we don't auto-prune),
so you can also fall back to it via tag pin in production until the
upgrade is fixed.
### Restoring from the saved image
A known-good build of the image is exported at
`docker/boosapi-comfyui-multi-tenant-20260520.tar` (754 MiB). To
restore on this or any other host:
```bash
docker load -i docker/boosapi-comfyui-multi-tenant-20260520.tar
# → boosapi-comfyui:multi-tenant
```
Use this when a rebuild fails (network, dependency drift) and you need
the running pool back fast — load the tar, the per-user containers
will pick the image up on next launch.
## Runtime contract: ComfyUI calls our API, not the upstream's
The point of the `_helpers.py` overlay is to redirect every "call the
ComfyUI cloud" code path inside the per-user container to BoosAPI:
- `--comfy-api-base http://host.docker.internal:8787/v1` is forwarded
by `docker/comfyui-entrypoint.sh` from the `BOOSAPI_BASE` env var.
- `BOOSAPI_BUILTIN_KEY` / `BOOSAPI_KEY` become the auth header that
ComfyUI's API nodes send. BoosAPI verifies it, looks up the
`token_routes` row for the requested model, picks a `route_channels`
row, and forwards to the real upstream account.
So the per-user ComfyUI instances never talk to upstream platforms
directly — every API node call funnels through BoosAPI's routing
layer (the same path serving `/v1/chat/completions` for the rest of
the product).
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Apply BoosAPI overlay patches on top of the vanilla ComfyUI submodule.
#
# Run after `git submodule update --init` or after bumping the submodule
# pointer. Idempotent — files are overwritten with the overlay copy.
#
# Refuses to clobber a dirty submodule working tree so you don't lose
# upstream work-in-progress.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OVERLAY="$ROOT/comfy_patches/overlay"
TARGET="$ROOT/comfy_source"
if [ ! -d "$TARGET" ]; then
echo "comfy_source/ does not exist — did you forget --recurse-submodules?" >&2
exit 1
fi
# Refuse to apply over a dirty submodule (anything other than our overlay diff).
# The overlay files _will_ show as modified after we apply, so we only check
# for OTHER changes by comparing against the upstream tree.
if [ -d "$TARGET/.git" ] || [ -f "$TARGET/.git" ]; then
cd "$TARGET"
# Stash any pre-existing local changes that aren't part of the overlay.
# We list overlay paths and exclude them from the dirty check.
overlay_paths=$(cd "$OVERLAY" && find . -type f -printf '%P\n')
unexpected=$(git status --porcelain | awk '{print $2}' | grep -vxF -f <(printf '%s\n' "$overlay_paths") || true)
if [ -n "$unexpected" ]; then
echo "comfy_source/ has unexpected local changes — aborting:" >&2
echo "$unexpected" >&2
echo "" >&2
echo "Commit/stash inside the submodule, or rm -rf and re-init it." >&2
exit 1
fi
cd "$ROOT"
fi
count=0
while IFS= read -r -d '' src; do
rel="${src#"$OVERLAY/"}"
dst="$TARGET/$rel"
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
count=$((count + 1))
done < <(find "$OVERLAY" -type f -print0)
echo "Applied $count overlay file(s) to comfy_source/."
-479
View File
@@ -1,479 +0,0 @@
<div align="center">
# ComfyUI
**The most powerful and modular AI engine for content creation.**
[![Website][website-shield]][website-url]
[![Dynamic JSON Badge][discord-shield]][discord-url]
[![Twitter][twitter-shield]][twitter-url]
[![Matrix][matrix-shield]][matrix-url]
<br>
[![][github-release-shield]][github-release-link]
[![][github-release-date-shield]][github-release-link]
[![][github-downloads-shield]][github-downloads-link]
[![][github-downloads-latest-shield]][github-downloads-link]
[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white
[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat
[website-url]: https://www.comfy.org/
<!-- Workaround to display total user from https://github.com/badges/shields/issues/4500#issuecomment-2060079995 -->
[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total
[discord-url]: https://www.comfy.org/discord
[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI
[twitter-url]: https://x.com/ComfyUI
[github-release-shield]: https://img.shields.io/github/v/release/comfyanonymous/ComfyUI?style=flat&sort=semver
[github-release-link]: https://github.com/comfyanonymous/ComfyUI/releases
[github-release-date-shield]: https://img.shields.io/github/release-date/comfyanonymous/ComfyUI?style=flat
[github-downloads-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/total?style=flat
[github-downloads-latest-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/latest/total?style=flat&label=downloads%40latest
[github-downloads-link]: https://github.com/comfyanonymous/ComfyUI/releases
<img width="1590" height="795" alt="ComfyUI Screenshot" src="https://github.com/user-attachments/assets/36e065e0-bfae-4456-8c7f-8369d5ea48a2" />
<br>
</div>
ComfyUI is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Its powerful and modular node graph interface empowers creatives to generate images, videos, 3D models, audio, and more...
- ComfyUI natively supports the latest open-source state of the art models.
- API nodes provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc.
- It is available on Windows, Linux, and macOS, locally with our desktop application or on our cloud.
- The most sophisticated workflows can be exposed through a simple UI thanks to App Mode.
- It integrates seamlessly into production pipelines with our API endpoints.
## Get Started
### Local
#### [Desktop Application](https://www.comfy.org/download)
- The easiest way to get started.
- Available on Windows & macOS.
#### [Windows Portable Package](#installing)
- Get the latest commits and completely portable.
- Available on Windows.
#### [Manual Install](#manual-install-windows-linux)
Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
### Cloud
#### [Comfy Cloud](https://www.comfy.org/cloud)
- Our official paid cloud version for those who can't afford local hardware.
## Examples
See what ComfyUI can do with the [newer template workflows](https://comfy.org/workflows) or old [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/).
## Features
- Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything.
- NOTE: There are many more models supported than the list below, if you want to see what is supported see our templates list inside ComfyUI.
- Image Models
- SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/))
- [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/)
- [Stable Cascade](https://comfyanonymous.github.io/ComfyUI_examples/stable_cascade/)
- [SD3 and SD3.5](https://comfyanonymous.github.io/ComfyUI_examples/sd3/)
- Pixart Alpha and Sigma
- [AuraFlow](https://comfyanonymous.github.io/ComfyUI_examples/aura_flow/)
- [HunyuanDiT](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_dit/)
- [Flux](https://comfyanonymous.github.io/ComfyUI_examples/flux/)
- [Lumina Image 2.0](https://comfyanonymous.github.io/ComfyUI_examples/lumina2/)
- [HiDream](https://comfyanonymous.github.io/ComfyUI_examples/hidream/)
- [Qwen Image](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/)
- [Hunyuan Image 2.1](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_image/)
- [Flux 2](https://comfyanonymous.github.io/ComfyUI_examples/flux2/)
- [Z Image](https://comfyanonymous.github.io/ComfyUI_examples/z_image/)
- Ernie Image
- Image Editing Models
- [Omnigen 2](https://comfyanonymous.github.io/ComfyUI_examples/omnigen/)
- [Flux Kontext](https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-kontext-image-editing-model)
- [HiDream E1.1](https://comfyanonymous.github.io/ComfyUI_examples/hidream/#hidream-e11)
- [Qwen Image Edit](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/#edit-model)
- Video Models
- [Stable Video Diffusion](https://comfyanonymous.github.io/ComfyUI_examples/video/)
- [Mochi](https://comfyanonymous.github.io/ComfyUI_examples/mochi/)
- [LTX-Video](https://comfyanonymous.github.io/ComfyUI_examples/ltxv/)
- [Hunyuan Video](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/)
- [Wan 2.1](https://comfyanonymous.github.io/ComfyUI_examples/wan/)
- [Wan 2.2](https://comfyanonymous.github.io/ComfyUI_examples/wan22/)
- [Hunyuan Video 1.5](https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video-1-5)
- Audio Models
- [Stable Audio](https://comfyanonymous.github.io/ComfyUI_examples/audio/)
- [ACE Step](https://comfyanonymous.github.io/ComfyUI_examples/audio/)
- 3D Models
- [Hunyuan3D 2.0](https://docs.comfy.org/tutorials/3d/hunyuan3D-2)
- Asynchronous Queue system
- Many optimizations: Only re-executes the parts of the workflow that changes between executions.
- Smart memory management: can automatically run large models on GPUs with as low as 1GB vram with smart offloading.
- Works even if you don't have a GPU with: ```--cpu``` (slow)
- Can load ckpt and safetensors: All in one checkpoints or standalone diffusion models, VAEs and CLIP models.
- Safe loading of ckpt, pt, pth, etc.. files.
- Embeddings/Textual inversion
- [Loras (regular, locon and loha)](https://comfyanonymous.github.io/ComfyUI_examples/lora/)
- [Hypernetworks](https://comfyanonymous.github.io/ComfyUI_examples/hypernetworks/)
- Loading full workflows (with seeds) from generated PNG, WebP and FLAC files.
- Saving/Loading workflows as Json files.
- Nodes interface can be used to create complex workflows like one for [Hires fix](https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/) or much more advanced ones.
- [Area Composition](https://comfyanonymous.github.io/ComfyUI_examples/area_composition/)
- [Inpainting](https://comfyanonymous.github.io/ComfyUI_examples/inpaint/) with both regular and inpainting models.
- [ControlNet and T2I-Adapter](https://comfyanonymous.github.io/ComfyUI_examples/controlnet/)
- [Upscale Models (ESRGAN, ESRGAN variants, SwinIR, Swin2SR, etc...)](https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/)
- [GLIGEN](https://comfyanonymous.github.io/ComfyUI_examples/gligen/)
- [Model Merging](https://comfyanonymous.github.io/ComfyUI_examples/model_merging/)
- [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/)
- Latent previews with [TAESD](#how-to-show-high-quality-previews)
- Works fully offline: core will never download anything unless you want to.
- Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview) disable with: `--disable-api-nodes`
- [Config file](extra_model_paths.yaml.example) to set the search paths for models.
Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/)
## Release Process
ComfyUI follows a weekly release cycle targeting Monday but this regularly changes because of model releases or large changes to the codebase. There are three interconnected repositories:
1. **[ComfyUI Core](https://github.com/comfyanonymous/ComfyUI)**
- Releases a new major stable version (e.g., v0.7.0) roughly every 2 weeks.
- Starting from v0.4.0 patch versions will be used for fixes backported onto the current stable release.
- Minor versions will be used for releases off the master branch.
- Patch versions may still be used for releases on the master branch in cases where a backport would not make sense.
- Commits outside of the stable release tags may be very unstable and break many custom nodes.
- Serves as the foundation for the desktop release
2. **[ComfyUI Desktop](https://github.com/Comfy-Org/desktop)**
- Builds a new release using the latest stable core version
3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)**
- Every 2+ weeks frontend updates are merged into the core repository
- Features are frozen for the upcoming core release
- Development continues for the next release cycle
## Shortcuts
| Keybind | Explanation |
|------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| `Ctrl` + `Enter` | Queue up current graph for generation |
| `Ctrl` + `Shift` + `Enter` | Queue up current graph as first for generation |
| `Ctrl` + `Alt` + `Enter` | Cancel current generation |
| `Ctrl` + `Z`/`Ctrl` + `Y` | Undo/Redo |
| `Ctrl` + `S` | Save workflow |
| `Ctrl` + `O` | Load workflow |
| `Ctrl` + `A` | Select all nodes |
| `Alt `+ `C` | Collapse/uncollapse selected nodes |
| `Ctrl` + `M` | Mute/unmute selected nodes |
| `Ctrl` + `B` | Bypass selected nodes (acts like the node was removed from the graph and the wires reconnected through) |
| `Delete`/`Backspace` | Delete selected nodes |
| `Ctrl` + `Backspace` | Delete the current graph |
| `Space` | Move the canvas around when held and moving the cursor |
| `Ctrl`/`Shift` + `Click` | Add clicked node to selection |
| `Ctrl` + `C`/`Ctrl` + `V` | Copy and paste selected nodes (without maintaining connections to outputs of unselected nodes) |
| `Ctrl` + `C`/`Ctrl` + `Shift` + `V` | Copy and paste selected nodes (maintaining connections from outputs of unselected nodes to inputs of pasted nodes) |
| `Shift` + `Drag` | Move multiple selected nodes at the same time |
| `Ctrl` + `D` | Load default graph |
| `Alt` + `+` | Canvas Zoom in |
| `Alt` + `-` | Canvas Zoom out |
| `Ctrl` + `Shift` + LMB + Vertical drag | Canvas Zoom in/out |
| `P` | Pin/Unpin selected nodes |
| `Ctrl` + `G` | Group selected nodes |
| `Q` | Toggle visibility of the queue |
| `H` | Toggle visibility of history |
| `R` | Refresh graph |
| `F` | Show/Hide menu |
| `.` | Fit view to selection (Whole graph when nothing is selected) |
| Double-Click LMB | Open node quick search palette |
| `Shift` + Drag | Move multiple wires at once |
| `Ctrl` + `Alt` + LMB | Disconnect all wires from clicked slot |
`Ctrl` can also be replaced with `Cmd` instead for macOS users
# Installing
## Windows Portable
There is a portable standalone build for Windows that should work for running on Nvidia GPUs or for running on your CPU only on the [releases page](https://github.com/comfyanonymous/ComfyUI/releases).
### [Direct link to download](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z)
Simply download, extract with [7-Zip](https://7-zip.org) or with the windows explorer on recent windows versions and run. For smaller models you normally only need to put the checkpoints (the huge ckpt/safetensors files) in: ComfyUI\models\checkpoints but many of the larger models have multiple files. Make sure to follow the instructions to know which subfolder to put them in ComfyUI\models\
If you have trouble extracting it, right click the file -> properties -> unblock
The portable above currently comes with python 3.13 and pytorch cuda 13.0. Update your Nvidia drivers if it doesn't start.
#### All Official Portable Downloads:
[Portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z)
[Portable for Intel GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_intel.7z)
[Portable for Nvidia GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z) (supports 20 series and above).
[Portable for Nvidia GPUs with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs).
#### How do I share models between another UI and ComfyUI?
See the [Config file](extra_model_paths.yaml.example) to set the search paths for models. In the standalone windows build you can find this file in the ComfyUI directory. Rename this file to extra_model_paths.yaml and edit it with your favorite text editor.
## [comfy-cli](https://docs.comfy.org/comfy-cli/getting-started)
You can install and start ComfyUI using comfy-cli:
```bash
pip install comfy-cli
comfy install
```
## Manual Install (Windows, Linux)
Python 3.14 works but some custom nodes may have issues. The free threaded variant works but some dependencies will enable the GIL so it's not fully supported.
Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12
torch 2.4 and above is supported but some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old.
### Instructions:
Git clone this repo.
Put your SD checkpoints (the huge ckpt/safetensors files) in: models/checkpoints
Put your VAE in: models/vae
### AMD GPUs (Linux)
AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version:
```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm7.2```
This is the command to install the nightly with ROCm 7.2 which might have some performance improvements:
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2```
### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only.
These have less hardware support than the builds above but they work on windows. You also need to install the pytorch version specific to your hardware.
RDNA 3 (RX 7000 series):
```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/```
RDNA 3.5 (Strix halo/Ryzen AI Max+ 365):
```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx1151/```
RDNA 4 (RX 9000 series):
```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx120X-all/```
### Intel GPUs (Windows and Linux)
Intel Arc GPU users can install native PyTorch with torch.xpu support using pip. More information can be found [here](https://pytorch.org/docs/main/notes/get_start_xpu.html)
1. To install PyTorch xpu, use the following command:
```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu```
This is the command to install the Pytorch xpu nightly which might have some performance improvements:
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/xpu```
### NVIDIA
Nvidia users should install stable pytorch using this command:
```pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130```
This is the command to install pytorch nightly instead which might have performance improvements.
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu132```
#### Troubleshooting
If you get the "Torch not compiled with CUDA enabled" error, uninstall torch with:
```pip uninstall torch```
And install it again with the command above.
### Dependencies
Install the dependencies by opening your terminal inside the ComfyUI folder and:
```pip install -r requirements.txt```
After this you should have everything installed and can proceed to running ComfyUI.
### Others:
#### Apple Mac silicon
You can install ComfyUI in Apple Mac silicon (M1 or M2) with any recent macOS version.
1. Install pytorch nightly. For instructions, read the [Accelerated PyTorch training on Mac](https://developer.apple.com/metal/pytorch/) Apple Developer guide (make sure to install the latest pytorch nightly).
1. Follow the [ComfyUI manual installation](#manual-install-windows-linux) instructions for Windows and Linux.
1. Install the ComfyUI [dependencies](#dependencies). If you have another Stable Diffusion UI [you might be able to reuse the dependencies](#i-already-have-another-ui-for-stable-diffusion-installed-do-i-really-have-to-install-all-of-these-dependencies).
1. Launch ComfyUI by running `python main.py`
> **Note**: Remember to add your models, VAE, LoRAs etc. to the corresponding Comfy folders, as discussed in [ComfyUI manual installation](#manual-install-windows-linux).
#### Ascend NPUs
For models compatible with Ascend Extension for PyTorch (torch_npu). To get started, ensure your environment meets the prerequisites outlined on the [installation](https://ascend.github.io/docs/sources/ascend/quick_install.html) page. Here's a step-by-step guide tailored to your platform and installation method:
1. Begin by installing the recommended or newer kernel version for Linux as specified in the Installation page of torch-npu, if necessary.
2. Proceed with the installation of Ascend Basekit, which includes the driver, firmware, and CANN, following the instructions provided for your specific platform.
3. Next, install the necessary packages for torch-npu by adhering to the platform-specific instructions on the [Installation](https://ascend.github.io/docs/sources/pytorch/install.html#pytorch) page.
4. Finally, adhere to the [ComfyUI manual installation](#manual-install-windows-linux) guide for Linux. Once all components are installed, you can run ComfyUI as described earlier.
#### Cambricon MLUs
For models compatible with Cambricon Extension for PyTorch (torch_mlu). Here's a step-by-step guide tailored to your platform and installation method:
1. Install the Cambricon CNToolkit by adhering to the platform-specific instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cntoolkit_3.7.2/cntoolkit_install_3.7.2/index.html)
2. Next, install the PyTorch(torch_mlu) following the instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cambricon_pytorch_1.17.0/user_guide_1.9/index.html)
3. Launch ComfyUI by running `python main.py`
#### Iluvatar Corex
For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step guide tailored to your platform and installation method:
1. Install the Iluvatar Corex Toolkit by adhering to the platform-specific instructions on the [Installation](https://support.iluvatar.com/#/DocumentCentre?id=1&nameCenter=2&productId=520117912052801536)
2. Launch ComfyUI by running `python main.py`
## [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager/tree/manager-v4)
**ComfyUI-Manager** is an extension that allows you to easily install, update, and manage custom nodes for ComfyUI.
### Setup
1. Install the manager dependencies:
```bash
pip install -r manager_requirements.txt
```
2. Enable the manager with the `--enable-manager` flag when running ComfyUI:
```bash
python main.py --enable-manager
```
### Command Line Options
| Flag | Description |
|------|-------------|
| `--enable-manager` | Enable ComfyUI-Manager |
| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (requires `--enable-manager`) |
| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) |
# Running
```python main.py```
### For AMD cards not officially supported by ROCm
Try running it with this command if you have issues:
For 6700, 6600 and maybe other RDNA2 or older: ```HSA_OVERRIDE_GFX_VERSION=10.3.0 python main.py```
For AMD 7600 and maybe other RDNA3 cards: ```HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py```
### AMD ROCm Tips
You can enable experimental memory efficient attention on recent pytorch in ComfyUI on some AMD GPUs using this command, it should already be enabled by default on RDNA3. If this improves speed for you on latest pytorch on your GPU please report it so that I can enable it by default.
```TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 python main.py --use-pytorch-cross-attention```
You can also try setting this env variable `PYTORCH_TUNABLEOP_ENABLED=1` which might speed things up at the cost of a very slow initial run.
# Notes
Only parts of the graph that have an output with all the correct inputs will be executed.
Only parts of the graph that change from each execution to the next will be executed, if you submit the same graph twice only the first will be executed. If you change the last part of the graph only the part you changed and the part that depends on it will be executed.
Dragging a generated png on the webpage or loading one will give you the full workflow including seeds that were used to create it.
You can use () to change emphasis of a word or phrase like: (good code:1.2) or (bad code:0.8). The default emphasis for () is 1.1. To use () characters in your actual prompt escape them like \\( or \\).
You can use {day|night}, for wildcard/dynamic prompts. With this syntax "{wild|card|test}" will be randomly replaced by either "wild", "card" or "test" by the frontend every time you queue the prompt. To use {} characters in your actual prompt escape them like: \\{ or \\}.
Dynamic prompts also support C-style comments, like `// comment` or `/* comment */`.
To use a textual inversion concepts/embeddings in a text prompt put them in the models/embeddings directory and use them in the CLIPTextEncode node like this (you can omit the .pt extension):
```embedding:embedding_filename.pt```
## How to show high-quality previews?
Use ```--preview-method auto``` to enable previews.
The default installation includes a fast latent preview method that's low-resolution. To enable higher-quality previews with [TAESD](https://github.com/madebyollin/taesd), download the [taesd_decoder.pth, taesdxl_decoder.pth, taesd3_decoder.pth and taef1_decoder.pth](https://github.com/madebyollin/taesd/) and place them in the `models/vae_approx` folder. Once they're installed, restart ComfyUI and launch it with `--preview-method taesd` to enable high-quality previews.
## How to use TLS/SSL?
Generate a self-signed certificate (not appropriate for shared/production use) and key by running the command: `openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"`
Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app will now be accessible with `https://...` instead of `http://...`.
> Note: Windows users can use [alexisrolland/docker-openssl](https://github.com/alexisrolland/docker-openssl) or one of the [3rd party binary distributions](https://wiki.openssl.org/index.php/Binaries) to run the command example above.
<br/><br/>If you use a container, note that the volume mount `-v` can be a relative path so `... -v ".\:/openssl-certs" ...` would create the key & cert files in the current directory of your command prompt or powershell terminal.
## Support and dev channel
[Discord](https://comfy.org/discord): Try the #help or #feedback channels.
[Matrix space: #comfyui_space:matrix.org](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) (it's like discord but open source).
See also: [https://www.comfy.org/](https://www.comfy.org/)
> _psst — we're hiring!_ Help build ComfyUI: [comfy.org/careers](https://www.comfy.org/careers)
## Frontend Development
As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). This repository now hosts the compiled JS (from TS/Vue) under the `web/` directory.
### Reporting Issues and Requesting Features
For any bugs, issues, or feature requests related to the frontend, please use the [ComfyUI Frontend repository](https://github.com/Comfy-Org/ComfyUI_frontend). This will help us manage and address frontend-specific concerns more efficiently.
### Using the Latest Frontend
The new frontend is now the default for ComfyUI. However, please note:
1. The frontend in the main ComfyUI repository is updated fortnightly.
2. Daily releases are available in the separate frontend repository.
To use the most up-to-date frontend version:
1. For the latest daily release, launch ComfyUI with this command line argument:
```
--front-end-version Comfy-Org/ComfyUI_frontend@latest
```
2. For a specific version, replace `latest` with the desired version number:
```
--front-end-version Comfy-Org/ComfyUI_frontend@1.2.2
```
This approach allows you to easily switch between the stable fortnightly release and the cutting-edge daily updates, or even specific versions for testing purposes.
### Accessing the Legacy Frontend
If you need to use the legacy frontend for any reason, you can access it using the following command line argument:
```
--front-end-version Comfy-Org/ComfyUI_legacy_frontend@latest
```
This will use a snapshot of the legacy frontend preserved in the [ComfyUI Legacy Frontend repository](https://github.com/Comfy-Org/ComfyUI_legacy_frontend).
# QA
### Which GPU should I buy for this?
[See this page for some recommendations](https://github.com/comfyanonymous/ComfyUI/wiki/Which-GPU-should-I-buy-for-ComfyUI)
-44
View File
@@ -1,44 +0,0 @@
# Security Policy
## Scope
ComfyUI is designed to run locally. By default, the server binds to `127.0.0.1`, meaning only the user's own machine can reach it. Our threat model assumes:
- The user installed ComfyUI through a supported channel: the desktop application, the portable build, or a manual install following the README.
- The user has not installed untrusted custom nodes. Custom nodes are arbitrary Python code and are trusted as much as any other software the user chooses to install.
- Anyone with access to the ComfyUI URL is trusted (a direct consequence of the localhost-only default).
- PyTorch and other dependencies are at the versions we ship or recommend in the README.
A report is in scope only if it affects a user operating within this threat model.
## What We Consider a Vulnerability
We want to hear about issues where a **reasonable user** — someone who does not install random untrusted nodes and who reads UI prompts and warnings before clicking through them — can be harmed by ComfyUI itself.
The clearest example: a workflow file that such a user might plausibly load and run, using only built-in nodes, that results in **untrusted code execution, arbitrary file read/write outside expected directories, or credential/data exfiltration**.
When submitting a report, please include a clear description of *why this is a problem for a typical local ComfyUI user*. Reports without this context are difficult to act on.
## What We Do Not Consider a Security Vulnerability
Please report the following through our regular [GitHub issues](https://github.com/comfyanonymous/ComfyUI/issues) instead. Filing them as security reports will likely cause them to be deprioritized or closed.
- **Issues requiring `--listen` or any non-default network exposure.** ComfyUI binds to localhost by default. If a remote attacker needs to reach the server for the attack to work, the user has chosen to expose it and is responsible for securing that deployment (firewall, reverse proxy, authentication, etc.). These are bugs, not vulnerabilities.
- **`torch.load` and related deserialization issues in old PyTorch versions.** These are upstream PyTorch issues. Our distributions ship with — and our documentation recommends — recent PyTorch versions where these are addressed.
- **Vulnerabilities that depend on outdated library versions** that we neither ship nor recommend (e.g., requiring PyTorch 2.6 or older).
- **Issues that require a specific custom node to be installed.** Custom nodes are third-party code. Report these to the maintainer of that node.
- **Crashes, hangs, or resource exhaustion from a loaded workflow.** Annoying, but not a security issue in our model. File a regular bug.
- **Social-engineering scenarios** where the user is expected to ignore an explicit UI warning or prompt.
## Reporting
If you believe you have found an issue that falls within the scope above, please report it privately via GitHub's [Report a vulnerability](https://github.com/comfyanonymous/ComfyUI/security/advisories/new) feature rather than opening a public issue.
Please include:
1. A description of the vulnerability and the affected component.
2. Reproduction steps, ideally with a minimal workflow file or proof-of-concept.
3. The ComfyUI version, install method (desktop / portable / manual), and OS.
4. An explanation of how this affects a typical local user as described in the threat model.
We will acknowledge valid reports and coordinate a fix and disclosure timeline with you.
@@ -1,440 +0,0 @@
from __future__ import annotations
import argparse
import logging
import os
import re
import sys
import tempfile
import zipfile
import importlib
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Dict, TypedDict, Optional
from aiohttp import web
from importlib.metadata import version
import requests
from typing_extensions import NotRequired
from utils.install_util import get_missing_requirements_message, get_required_packages_versions
from comfy.cli_args import DEFAULT_VERSION_STRING
import app.logger
def frontend_install_warning_message():
return f"""
{get_missing_requirements_message()}
The ComfyUI frontend is shipped in a pip package so it needs to be updated separately from the ComfyUI code.
""".strip()
def parse_version(version: str) -> tuple[int, int, int]:
return tuple(map(int, version.split(".")))
def is_valid_version(version: str) -> bool:
"""Validate if a string is a valid semantic version (X.Y.Z format)."""
pattern = r"^(\d+)\.(\d+)\.(\d+)$"
return bool(re.match(pattern, version))
def get_required_frontend_version():
return get_required_packages_versions().get("comfyui-frontend-package", None)
COMFY_PACKAGE_VERSIONS = []
def get_comfy_package_versions():
"""List installed/required versions for every comfy* package in requirements.txt."""
if COMFY_PACKAGE_VERSIONS:
return COMFY_PACKAGE_VERSIONS.copy()
out = COMFY_PACKAGE_VERSIONS
for name, required in (get_required_packages_versions() or {}).items():
if not name.startswith("comfy"):
continue
try:
installed = version(name)
except Exception:
installed = None
out.append({"name": name, "installed": installed, "required": required})
return out.copy()
def check_comfy_packages_versions():
"""Warn for every comfy* package whose installed version is below requirements.txt."""
from packaging.version import InvalidVersion, parse as parse_pep440
for pkg in get_comfy_package_versions():
installed_str = pkg["installed"]
required_str = pkg["required"]
if not installed_str or not required_str:
continue
try:
outdated = parse_pep440(installed_str) < parse_pep440(required_str)
except InvalidVersion as e:
logging.error(f"Failed to check {pkg['name']} version: {e}")
continue
if outdated:
app.logger.log_startup_warning(
f"""
________________________________________________________________________
WARNING WARNING WARNING WARNING WARNING
Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}.
{get_missing_requirements_message()}
________________________________________________________________________
""".strip()
)
else:
logging.info("{} version: {}".format(pkg["name"], installed_str))
REQUEST_TIMEOUT = 10 # seconds
class Asset(TypedDict):
url: str
class Release(TypedDict):
id: int
tag_name: str
name: str
prerelease: bool
created_at: str
published_at: str
body: str
assets: NotRequired[list[Asset]]
@dataclass
class FrontEndProvider:
owner: str
repo: str
@property
def folder_name(self) -> str:
return f"{self.owner}_{self.repo}"
@property
def release_url(self) -> str:
return f"https://api.github.com/repos/{self.owner}/{self.repo}/releases"
@cached_property
def all_releases(self) -> list[Release]:
releases = []
api_url = self.release_url
while api_url:
response = requests.get(api_url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() # Raises an HTTPError if the response was an error
releases.extend(response.json())
# GitHub uses the Link header to provide pagination links. Check if it exists and update api_url accordingly.
if "next" in response.links:
api_url = response.links["next"]["url"]
else:
api_url = None
return releases
@cached_property
def latest_release(self) -> Release:
latest_release_url = f"{self.release_url}/latest"
response = requests.get(latest_release_url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() # Raises an HTTPError if the response was an error
return response.json()
@cached_property
def latest_prerelease(self) -> Release:
"""Get the latest pre-release version - even if it's older than the latest release"""
release = [release for release in self.all_releases if release["prerelease"]]
if not release:
raise ValueError("No pre-releases found")
# GitHub returns releases in reverse chronological order, so first is latest
return release[0]
def get_release(self, version: str) -> Release:
if version == "latest":
return self.latest_release
elif version == "prerelease":
return self.latest_prerelease
else:
for release in self.all_releases:
if release["tag_name"] in [version, f"v{version}"]:
return release
raise ValueError(f"Version {version} not found in releases")
def download_release_asset_zip(release: Release, destination_path: str) -> None:
"""Download dist.zip from github release."""
asset_url = None
for asset in release.get("assets", []):
if asset["name"] == "dist.zip":
asset_url = asset["url"]
break
if not asset_url:
raise ValueError("dist.zip not found in the release assets")
# Use a temporary file to download the zip content
with tempfile.TemporaryFile() as tmp_file:
headers = {"Accept": "application/octet-stream"}
response = requests.get(
asset_url, headers=headers, allow_redirects=True, timeout=REQUEST_TIMEOUT
)
response.raise_for_status() # Ensure we got a successful response
# Write the content to the temporary file
tmp_file.write(response.content)
# Go back to the beginning of the temporary file
tmp_file.seek(0)
# Extract the zip file content to the destination path
with zipfile.ZipFile(tmp_file, "r") as zip_ref:
zip_ref.extractall(destination_path)
class FrontendManager:
CUSTOM_FRONTENDS_ROOT = str(Path(__file__).parents[1] / "web_custom_versions")
@classmethod
def get_required_frontend_version(cls) -> str:
"""Get the required frontend package version."""
return get_required_frontend_version()
@classmethod
def get_installed_templates_version(cls) -> str:
"""Get the currently installed workflow templates package version."""
try:
templates_version_str = version("comfyui-workflow-templates")
return templates_version_str
except Exception:
return None
@classmethod
def get_required_templates_version(cls) -> str:
return get_required_packages_versions().get("comfyui-workflow-templates", None)
@classmethod
def get_comfy_package_versions(cls):
"""List installed/required versions for every comfy* package in requirements.txt."""
return get_comfy_package_versions()
@classmethod
def default_frontend_path(cls) -> str:
try:
import comfyui_frontend_package
return str(importlib.resources.files(comfyui_frontend_package) / "static")
except ImportError:
logging.error(
f"""
********** ERROR ***********
comfyui-frontend-package is not installed.
{frontend_install_warning_message()}
********** ERROR ***********
""".strip()
)
sys.exit(-1)
@classmethod
def template_asset_map(cls) -> Optional[Dict[str, str]]:
"""Return a mapping of template asset names to their absolute paths."""
try:
from comfyui_workflow_templates import (
get_asset_path,
iter_templates,
)
except ImportError:
logging.error(
f"""
********** ERROR ***********
comfyui-workflow-templates is not installed.
{frontend_install_warning_message()}
********** ERROR ***********
""".strip()
)
return None
try:
template_entries = list(iter_templates())
except Exception as exc:
logging.error(f"Failed to enumerate workflow templates: {exc}")
return None
asset_map: Dict[str, str] = {}
try:
for entry in template_entries:
for asset in entry.assets:
asset_map[asset.filename] = get_asset_path(
entry.template_id, asset.filename
)
except Exception as exc:
logging.error(f"Failed to resolve template asset paths: {exc}")
return None
if not asset_map:
logging.error("No workflow template assets found. Did the packages install correctly?")
return None
return asset_map
@classmethod
def legacy_templates_path(cls) -> Optional[str]:
"""Return the legacy templates directory shipped inside the meta package."""
try:
import comfyui_workflow_templates
return str(
importlib.resources.files(comfyui_workflow_templates) / "templates"
)
except ImportError:
logging.error(
f"""
********** ERROR ***********
comfyui-workflow-templates is not installed.
{frontend_install_warning_message()}
********** ERROR ***********
""".strip()
)
return None
@classmethod
def embedded_docs_path(cls) -> str:
"""Get the path to embedded documentation"""
try:
import comfyui_embedded_docs
return str(
importlib.resources.files(comfyui_embedded_docs) / "docs"
)
except ImportError:
logging.info("comfyui-embedded-docs package not found")
return None
@classmethod
def parse_version_string(cls, value: str) -> tuple[str, str, str]:
"""
Args:
value (str): The version string to parse.
Returns:
tuple[str, str]: A tuple containing provider name and version.
Raises:
argparse.ArgumentTypeError: If the version string is invalid.
"""
VERSION_PATTERN = r"^([a-zA-Z0-9][a-zA-Z0-9-]{0,38})/([a-zA-Z0-9_.-]+)@(v?\d+\.\d+\.\d+[-._a-zA-Z0-9]*|latest|prerelease)$"
match_result = re.match(VERSION_PATTERN, value)
if match_result is None:
raise argparse.ArgumentTypeError(f"Invalid version string: {value}")
return match_result.group(1), match_result.group(2), match_result.group(3)
@classmethod
def init_frontend_unsafe(
cls, version_string: str, provider: Optional[FrontEndProvider] = None
) -> str:
"""
Initializes the frontend for the specified version.
Args:
version_string (str): The version string.
provider (FrontEndProvider, optional): The provider to use. Defaults to None.
Returns:
str: The path to the initialized frontend.
Raises:
Exception: If there is an error during the initialization process.
main error source might be request timeout or invalid URL.
"""
if version_string == DEFAULT_VERSION_STRING:
check_comfy_packages_versions()
return cls.default_frontend_path()
repo_owner, repo_name, version = cls.parse_version_string(version_string)
if version.startswith("v"):
expected_path = str(
Path(cls.CUSTOM_FRONTENDS_ROOT)
/ f"{repo_owner}_{repo_name}"
/ version.lstrip("v")
)
if os.path.exists(expected_path):
logging.info(
f"Using existing copy of specific frontend version tag: {repo_owner}/{repo_name}@{version}"
)
return expected_path
logging.info(
f"Initializing frontend: {repo_owner}/{repo_name}@{version}, requesting version details from GitHub..."
)
provider = provider or FrontEndProvider(repo_owner, repo_name)
release = provider.get_release(version)
semantic_version = release["tag_name"].lstrip("v")
web_root = str(
Path(cls.CUSTOM_FRONTENDS_ROOT) / provider.folder_name / semantic_version
)
if not os.path.exists(web_root):
try:
os.makedirs(web_root, exist_ok=True)
logging.info(
"Downloading frontend(%s) version(%s) to (%s)",
provider.folder_name,
semantic_version,
web_root,
)
logging.debug(release)
download_release_asset_zip(release, destination_path=web_root)
finally:
# Clean up the directory if it is empty, i.e. the download failed
if not os.listdir(web_root):
os.rmdir(web_root)
return web_root
@classmethod
def init_frontend(cls, version_string: str) -> str:
"""
Initializes the frontend with the specified version string.
Args:
version_string (str): The version string to initialize the frontend with.
Returns:
str: The path of the initialized frontend.
"""
try:
return cls.init_frontend_unsafe(version_string)
except Exception as e:
logging.error("Failed to initialize frontend: %s", e)
logging.info("Falling back to the default frontend.")
check_comfy_packages_versions()
return cls.default_frontend_path()
@classmethod
def template_asset_handler(cls):
assets = cls.template_asset_map()
if not assets:
return None
async def serve_template(request: web.Request) -> web.StreamResponse:
rel_path = request.match_info.get("path", "")
target = assets.get(rel_path)
if target is None:
raise web.HTTPNotFound()
return web.FileResponse(target)
return serve_template
@@ -1,98 +0,0 @@
import asyncio
import contextlib
import os
import re
import time
from collections.abc import Callable
from io import BytesIO
from yarl import URL
from comfy.cli_args import args
from comfy.model_management import processing_interrupted
from comfy_api.latest import IO
from .common_exceptions import ProcessingInterrupted
_HAS_PCT_ESC = re.compile(r"%[0-9A-Fa-f]{2}") # any % followed by 2 hex digits
_HAS_BAD_PCT = re.compile(r"%(?![0-9A-Fa-f]{2})") # any % not followed by 2 hex digits
def is_processing_interrupted() -> bool:
"""Return True if user/runtime requested interruption."""
return processing_interrupted()
def get_node_id(node_cls: type[IO.ComfyNode]) -> str:
return node_cls.hidden.unique_id
def get_auth_header(node_cls: type[IO.ComfyNode]) -> dict[str, str]:
if node_cls.hidden.auth_token_comfy_org:
return {"Authorization": f"Bearer {node_cls.hidden.auth_token_comfy_org}"}
if node_cls.hidden.api_key_comfy_org:
return {"X-API-KEY": node_cls.hidden.api_key_comfy_org}
# BoosAPI fallback: when ComfyUI runs as an embedded subprocess of
# BoosAPI, the parent injects the built-in `comfyui-agent` downstream
# key via this env var so usage flows through the standard billing
# pipeline. See src/services/comfyui_process.rs.
builtin_key = os.environ.get("BOOSAPI_BUILTIN_KEY")
if builtin_key:
return {"Authorization": f"Bearer {builtin_key}"}
return {}
def default_base_url() -> str:
return getattr(args, "comfy_api_base", "https://api.comfy.org")
async def sleep_with_interrupt(
seconds: float,
node_cls: type[IO.ComfyNode] | None,
label: str | None = None,
start_ts: float | None = None,
estimated_total: int | None = None,
*,
display_callback: Callable[[type[IO.ComfyNode], str, int, int | None], None] | None = None,
):
"""
Sleep in 1s slices while:
- Checking for interruption (raises ProcessingInterrupted).
- Optionally emitting time progress via display_callback (if provided).
"""
end = time.monotonic() + seconds
while True:
if is_processing_interrupted():
raise ProcessingInterrupted("Task cancelled")
now = time.monotonic()
if start_ts is not None and label and display_callback:
with contextlib.suppress(Exception):
display_callback(node_cls, label, int(now - start_ts), estimated_total)
if now >= end:
break
await asyncio.sleep(min(1.0, end - now))
def mimetype_to_extension(mime_type: str) -> str:
"""Converts a MIME type to a file extension."""
return mime_type.split("/")[-1].lower()
def get_fs_object_size(path_or_object: str | BytesIO) -> int:
if isinstance(path_or_object, str):
return os.path.getsize(path_or_object)
return len(path_or_object.getvalue())
def to_aiohttp_url(url: str) -> URL:
"""If `url` appears to be already percent-encoded (contains at least one valid %HH
escape and no malformed '%' sequences) and contains no raw whitespace/control
characters preserve the original encoding byte-for-byte (important for signed/presigned URLs).
Otherwise, return `URL(url)` and allow yarl to normalize/quote as needed."""
if any(c.isspace() for c in url) or any(ord(c) < 0x20 for c in url):
# Avoid encoded=True if URL contains raw whitespace/control chars
return URL(url)
if _HAS_PCT_ESC.search(url) and not _HAS_BAD_PCT.search(url):
# Preserve encoding only if it appears pre-encoded AND has no invalid % sequences
return URL(url, encoded=True)
return URL(url)
@@ -1,841 +0,0 @@
from __future__ import annotations
import av
import torchaudio
import torch
import comfy.model_management
import folder_paths
import os
import hashlib
import node_helpers
import logging
from typing_extensions import override
from comfy_api.latest import ComfyExtension, IO, UI
class EmptyLatentAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="EmptyLatentAudio",
display_name="Empty Latent Audio",
category="latent/audio",
essentials_category="Audio",
inputs=[
IO.Float.Input("seconds", default=47.6, min=1.0, max=1000.0, step=0.1),
IO.Int.Input(
"batch_size", default=1, min=1, max=4096, tooltip="The number of latent images in the batch.",
),
],
outputs=[IO.Latent.Output()],
)
@classmethod
def execute(cls, seconds, batch_size) -> IO.NodeOutput:
length = round((seconds * 44100 / 2048) / 2) * 2
latent = torch.zeros([batch_size, 64, length], device=comfy.model_management.intermediate_device())
return IO.NodeOutput({"samples":latent, "type": "audio"})
generate = execute # TODO: remove
class ConditioningStableAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="ConditioningStableAudio",
category="conditioning",
inputs=[
IO.Conditioning.Input("positive"),
IO.Conditioning.Input("negative"),
IO.Float.Input("seconds_start", default=0.0, min=0.0, max=1000.0, step=0.1),
IO.Float.Input("seconds_total", default=47.0, min=0.0, max=1000.0, step=0.1),
],
outputs=[
IO.Conditioning.Output(display_name="positive"),
IO.Conditioning.Output(display_name="negative"),
],
)
@classmethod
def execute(cls, positive, negative, seconds_start, seconds_total) -> IO.NodeOutput:
positive = node_helpers.conditioning_set_values(positive, {"seconds_start": seconds_start, "seconds_total": seconds_total})
negative = node_helpers.conditioning_set_values(negative, {"seconds_start": seconds_start, "seconds_total": seconds_total})
return IO.NodeOutput(positive, negative)
append = execute # TODO: remove
class VAEEncodeAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="VAEEncodeAudio",
search_aliases=["audio to latent"],
display_name="VAE Encode Audio",
category="latent/audio",
inputs=[
IO.Audio.Input("audio"),
IO.Vae.Input("vae"),
],
outputs=[IO.Latent.Output()],
)
@classmethod
def execute(cls, vae, audio) -> IO.NodeOutput:
if audio is None:
raise ValueError("VAEEncodeAudio: input audio is None (source video may have no audio track).")
sample_rate = audio["sample_rate"]
vae_sample_rate = getattr(vae, "audio_sample_rate", 44100)
if vae_sample_rate != sample_rate:
waveform = torchaudio.functional.resample(audio["waveform"], sample_rate, vae_sample_rate)
else:
waveform = audio["waveform"]
t = vae.encode(waveform.movedim(1, -1))
return IO.NodeOutput({"samples": t})
encode = execute # TODO: remove
def vae_decode_audio(vae, samples, tile=None, overlap=None):
if tile is not None:
audio = vae.decode_tiled(samples["samples"], tile_x=tile, tile_y=tile, overlap=overlap).movedim(-1, 1)
else:
audio = vae.decode(samples["samples"]).movedim(-1, 1)
std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
std[std < 1.0] = 1.0
audio /= std
vae_sample_rate = getattr(vae, "audio_sample_rate_output", getattr(vae, "audio_sample_rate", 44100))
return {"waveform": audio, "sample_rate": vae_sample_rate if "sample_rate" not in samples else samples["sample_rate"]}
class VAEDecodeAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="VAEDecodeAudio",
search_aliases=["latent to audio"],
display_name="VAE Decode Audio",
category="latent/audio",
inputs=[
IO.Latent.Input("samples"),
IO.Vae.Input("vae"),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, vae, samples) -> IO.NodeOutput:
return IO.NodeOutput(vae_decode_audio(vae, samples))
decode = execute # TODO: remove
class VAEDecodeAudioTiled(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="VAEDecodeAudioTiled",
search_aliases=["latent to audio"],
display_name="VAE Decode Audio (Tiled)",
category="latent/audio",
inputs=[
IO.Latent.Input("samples"),
IO.Vae.Input("vae"),
IO.Int.Input("tile_size", default=512, min=32, max=8192, step=8),
IO.Int.Input("overlap", default=64, min=0, max=1024, step=8),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, vae, samples, tile_size, overlap) -> IO.NodeOutput:
return IO.NodeOutput(vae_decode_audio(vae, samples, tile_size, overlap))
class SaveAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveAudio",
search_aliases=["export flac"],
display_name="Save Audio (FLAC)",
category="audio",
essentials_category="Audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="flac") -> IO.NodeOutput:
if audio is None:
raise ValueError("SaveAudio: input audio is None (source video may have no audio track).")
return IO.NodeOutput(
ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=format)
)
save_flac = execute # TODO: remove
class SaveAudioMP3(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveAudioMP3",
search_aliases=["export mp3"],
display_name="Save Audio (MP3)",
category="audio",
essentials_category="Audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),
IO.Combo.Input("quality", options=["V0", "128k", "320k"], default="V0"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="mp3", quality="128k") -> IO.NodeOutput:
if audio is None:
raise ValueError("SaveAudioMP3: input audio is None (source video may have no audio track).")
return IO.NodeOutput(
ui=UI.AudioSaveHelper.get_save_audio_ui(
audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality
)
)
save_mp3 = execute # TODO: remove
class SaveAudioOpus(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveAudioOpus",
search_aliases=["export opus"],
display_name="Save Audio (Opus)",
category="audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),
IO.Combo.Input("quality", options=["64k", "96k", "128k", "192k", "320k"], default="128k"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="opus", quality="V3") -> IO.NodeOutput:
if audio is None:
raise ValueError("SaveAudioOpus: input audio is None (source video may have no audio track).")
return IO.NodeOutput(
ui=UI.AudioSaveHelper.get_save_audio_ui(
audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality
)
)
save_opus = execute # TODO: remove
class PreviewAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="PreviewAudio",
search_aliases=["play audio"],
display_name="Preview Audio",
category="audio",
inputs=[
IO.Audio.Input("audio"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
if audio is None:
raise ValueError("PreviewAudio: input audio is None (source video may have no audio track).")
return IO.NodeOutput(ui=UI.PreviewAudio(audio, cls=cls))
save_flac = execute # TODO: remove
def f32_pcm(wav: torch.Tensor) -> torch.Tensor:
"""Convert audio to float 32 bits PCM format."""
if wav.dtype.is_floating_point:
return wav
elif wav.dtype == torch.int16:
return wav.float() / (2 ** 15)
elif wav.dtype == torch.int32:
return wav.float() / (2 ** 31)
raise ValueError(f"Unsupported wav dtype: {wav.dtype}")
def load(filepath: str) -> tuple[torch.Tensor, int]:
with av.open(filepath) as af:
if not af.streams.audio:
raise ValueError("No audio stream found in the file.")
stream = af.streams.audio[0]
sr = stream.codec_context.sample_rate
n_channels = stream.channels
frames = []
length = 0
for frame in af.decode(streams=stream.index):
buf = torch.from_numpy(frame.to_ndarray())
if buf.shape[0] != n_channels:
buf = buf.view(-1, n_channels).t()
frames.append(buf)
length += buf.shape[1]
if not frames:
raise ValueError("No audio frames decoded.")
wav = torch.cat(frames, dim=1)
wav = f32_pcm(wav)
return wav, sr
class LoadAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
input_dir = folder_paths.get_input_directory()
os.makedirs(input_dir, exist_ok=True)
files = folder_paths.filter_files_content_types(os.listdir(input_dir), ["audio", "video"])
return IO.Schema(
node_id="LoadAudio",
search_aliases=["import audio", "open audio", "audio file"],
display_name="Load Audio",
category="audio",
essentials_category="Audio",
inputs=[
IO.Combo.Input("audio", upload=IO.UploadType.audio, options=sorted(files)),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
audio_path = folder_paths.get_annotated_filepath(audio)
waveform, sample_rate = load(audio_path)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return IO.NodeOutput(audio)
@classmethod
def fingerprint_inputs(cls, audio):
image_path = folder_paths.get_annotated_filepath(audio)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def validate_inputs(cls, audio):
if not folder_paths.exists_annotated_filepath(audio):
return "Invalid audio file: {}".format(audio)
return True
load = execute # TODO: remove
class RecordAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="RecordAudio",
search_aliases=["microphone input", "audio capture", "voice input"],
display_name="Record Audio",
category="audio",
inputs=[
IO.Custom("AUDIO_RECORD").Input("audio"),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
audio_path = folder_paths.get_annotated_filepath(audio)
waveform, sample_rate = load(audio_path)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return IO.NodeOutput(audio)
load = execute # TODO: remove
class TrimAudioDuration(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="TrimAudioDuration",
search_aliases=["cut audio", "audio clip", "shorten audio"],
display_name="Trim Audio Duration",
description="Trim audio tensor into chosen time range.",
category="audio",
inputs=[
IO.Audio.Input("audio"),
IO.Float.Input(
"start_index",
default=0.0,
min=-0xffffffffffffffff,
max=0xffffffffffffffff,
step=0.01,
tooltip="Start time in seconds, can be negative to count from the end (supports sub-seconds).",
),
IO.Float.Input(
"duration",
default=60.0,
min=0.0,
step=0.01,
tooltip="Duration in seconds",
),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio, start_index, duration) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
audio_length = waveform.shape[-1]
if audio_length == 0:
return IO.NodeOutput(audio)
if start_index < 0:
start_frame = audio_length + int(round(start_index * sample_rate))
else:
start_frame = int(round(start_index * sample_rate))
start_frame = max(0, min(start_frame, audio_length))
end_frame = start_frame + int(round(duration * sample_rate))
end_frame = max(0, min(end_frame, audio_length))
if start_frame >= end_frame:
raise ValueError("TrimAudioDuration: Start time must be less than end time and be within the audio length.")
return IO.NodeOutput({"waveform": waveform[..., start_frame:end_frame], "sample_rate": sample_rate})
trim = execute # TODO: remove
class SplitAudioChannels(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SplitAudioChannels",
search_aliases=["stereo to mono"],
display_name="Split Audio Channels",
description="Separates the audio into left and right channels.",
category="audio",
inputs=[
IO.Audio.Input("audio"),
],
outputs=[
IO.Audio.Output(display_name="left"),
IO.Audio.Output(display_name="right"),
],
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None, None)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
if waveform.shape[1] != 2:
raise ValueError(f"AudioSplit: Input audio must be stereo (2 channels), got {waveform.shape[1]} channel(s).")
left_channel = waveform[..., 0:1, :]
right_channel = waveform[..., 1:2, :]
return IO.NodeOutput({"waveform": left_channel, "sample_rate": sample_rate}, {"waveform": right_channel, "sample_rate": sample_rate})
separate = execute # TODO: remove
class JoinAudioChannels(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="JoinAudioChannels",
display_name="Join Audio Channels",
description="Joins left and right mono audio channels into a stereo audio.",
category="audio",
inputs=[
IO.Audio.Input("audio_left"),
IO.Audio.Input("audio_right"),
],
outputs=[
IO.Audio.Output(display_name="audio"),
],
)
@classmethod
def execute(cls, audio_left, audio_right) -> IO.NodeOutput:
if audio_left is None and audio_right is None:
return IO.NodeOutput(None)
if audio_left is None:
return IO.NodeOutput(audio_right)
if audio_right is None:
return IO.NodeOutput(audio_left)
waveform_left = audio_left["waveform"]
sample_rate_left = audio_left["sample_rate"]
waveform_right = audio_right["waveform"]
sample_rate_right = audio_right["sample_rate"]
if waveform_left.shape[1] != 1 or waveform_right.shape[1] != 1:
raise ValueError("AudioJoin: Both input audios must be mono.")
# Handle different sample rates by resampling to the higher rate
waveform_left, waveform_right, output_sample_rate = match_audio_sample_rates(
waveform_left, sample_rate_left, waveform_right, sample_rate_right
)
# Handle different lengths by trimming to the shorter length
length_left = waveform_left.shape[-1]
length_right = waveform_right.shape[-1]
if length_left != length_right:
min_length = min(length_left, length_right)
if length_left > min_length:
logging.info(f"JoinAudioChannels: Trimming left channel from {length_left} to {min_length} samples.")
waveform_left = waveform_left[..., :min_length]
if length_right > min_length:
logging.info(f"JoinAudioChannels: Trimming right channel from {length_right} to {min_length} samples.")
waveform_right = waveform_right[..., :min_length]
# Join the channels into stereo
left_channel = waveform_left[..., 0:1, :]
right_channel = waveform_right[..., 0:1, :]
stereo_waveform = torch.cat([left_channel, right_channel], dim=1)
return IO.NodeOutput({"waveform": stereo_waveform, "sample_rate": output_sample_rate})
def match_audio_sample_rates(waveform_1, sample_rate_1, waveform_2, sample_rate_2):
if sample_rate_1 != sample_rate_2:
if sample_rate_1 > sample_rate_2:
waveform_2 = torchaudio.functional.resample(waveform_2, sample_rate_2, sample_rate_1)
output_sample_rate = sample_rate_1
logging.info(f"Resampling audio2 from {sample_rate_2}Hz to {sample_rate_1}Hz for merging.")
else:
waveform_1 = torchaudio.functional.resample(waveform_1, sample_rate_1, sample_rate_2)
output_sample_rate = sample_rate_2
logging.info(f"Resampling audio1 from {sample_rate_1}Hz to {sample_rate_2}Hz for merging.")
else:
output_sample_rate = sample_rate_1
return waveform_1, waveform_2, output_sample_rate
class AudioConcat(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioConcat",
search_aliases=["join audio", "combine audio", "append audio"],
display_name="Audio Concat",
description="Concatenates the audio1 to audio2 in the specified direction.",
category="audio",
inputs=[
IO.Audio.Input("audio1"),
IO.Audio.Input("audio2"),
IO.Combo.Input(
"direction",
options=['after', 'before'],
default="after",
tooltip="Whether to append audio2 after or before audio1.",
)
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio1, audio2, direction) -> IO.NodeOutput:
if audio1 is None and audio2 is None:
return IO.NodeOutput(None)
if audio1 is None:
return IO.NodeOutput(audio2)
if audio2 is None:
return IO.NodeOutput(audio1)
waveform_1 = audio1["waveform"]
waveform_2 = audio2["waveform"]
sample_rate_1 = audio1["sample_rate"]
sample_rate_2 = audio2["sample_rate"]
if waveform_1.shape[1] == 1:
waveform_1 = waveform_1.repeat(1, 2, 1)
logging.info("AudioConcat: Converted mono audio1 to stereo by duplicating the channel.")
if waveform_2.shape[1] == 1:
waveform_2 = waveform_2.repeat(1, 2, 1)
logging.info("AudioConcat: Converted mono audio2 to stereo by duplicating the channel.")
waveform_1, waveform_2, output_sample_rate = match_audio_sample_rates(waveform_1, sample_rate_1, waveform_2, sample_rate_2)
if direction == 'after':
concatenated_audio = torch.cat((waveform_1, waveform_2), dim=2)
elif direction == 'before':
concatenated_audio = torch.cat((waveform_2, waveform_1), dim=2)
return IO.NodeOutput({"waveform": concatenated_audio, "sample_rate": output_sample_rate})
concat = execute # TODO: remove
class AudioMerge(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioMerge",
search_aliases=["mix audio", "overlay audio", "layer audio"],
display_name="Audio Merge",
description="Combine two audio tracks by overlaying their waveforms.",
category="audio",
inputs=[
IO.Audio.Input("audio1"),
IO.Audio.Input("audio2"),
IO.Combo.Input(
"merge_method",
options=["add", "mean", "subtract", "multiply"],
tooltip="The method used to combine the audio waveforms.",
)
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio1, audio2, merge_method) -> IO.NodeOutput:
if audio1 is None and audio2 is None:
return IO.NodeOutput(None)
if audio1 is None:
return IO.NodeOutput(audio2)
if audio2 is None:
return IO.NodeOutput(audio1)
waveform_1 = audio1["waveform"]
waveform_2 = audio2["waveform"]
sample_rate_1 = audio1["sample_rate"]
sample_rate_2 = audio2["sample_rate"]
waveform_1, waveform_2, output_sample_rate = match_audio_sample_rates(waveform_1, sample_rate_1, waveform_2, sample_rate_2)
length_1 = waveform_1.shape[-1]
length_2 = waveform_2.shape[-1]
if length_1 == 0 or length_2 == 0:
return IO.NodeOutput({"waveform": waveform_1, "sample_rate": output_sample_rate})
if length_2 > length_1:
logging.info(f"AudioMerge: Trimming audio2 from {length_2} to {length_1} samples to match audio1 length.")
waveform_2 = waveform_2[..., :length_1]
elif length_2 < length_1:
logging.info(f"AudioMerge: Padding audio2 from {length_2} to {length_1} samples to match audio1 length.")
pad_shape = list(waveform_2.shape)
pad_shape[-1] = length_1 - length_2
pad_tensor = torch.zeros(pad_shape, dtype=waveform_2.dtype, device=waveform_2.device)
waveform_2 = torch.cat((waveform_2, pad_tensor), dim=-1)
if merge_method == "add":
waveform = waveform_1 + waveform_2
elif merge_method == "subtract":
waveform = waveform_1 - waveform_2
elif merge_method == "multiply":
waveform = waveform_1 * waveform_2
elif merge_method == "mean":
waveform = (waveform_1 + waveform_2) / 2
max_val = waveform.abs().max()
if max_val > 1.0:
waveform = waveform / max_val
return IO.NodeOutput({"waveform": waveform, "sample_rate": output_sample_rate})
merge = execute # TODO: remove
class AudioAdjustVolume(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioAdjustVolume",
search_aliases=["audio gain", "loudness", "audio level"],
display_name="Audio Adjust Volume",
category="audio",
inputs=[
IO.Audio.Input("audio"),
IO.Int.Input(
"volume",
default=1,
min=-100,
max=100,
tooltip="Volume adjustment in decibels (dB). 0 = no change, +6 = double, -6 = half, etc",
)
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio, volume) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None)
if volume == 0:
return IO.NodeOutput(audio)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
gain = 10 ** (volume / 20)
waveform = waveform * gain
return IO.NodeOutput({"waveform": waveform, "sample_rate": sample_rate})
adjust_volume = execute # TODO: remove
class EmptyAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="EmptyAudio",
search_aliases=["blank audio"],
display_name="Empty Audio",
category="audio",
inputs=[
IO.Float.Input(
"duration",
default=60.0,
min=0.0,
max=0xffffffffffffffff,
step=0.01,
tooltip="Duration of the empty audio clip in seconds",
),
IO.Int.Input(
"sample_rate",
default=44100,
tooltip="Sample rate of the empty audio clip.",
min=1,
max=192000,
advanced=True,
),
IO.Int.Input(
"channels",
default=2,
min=1,
max=2,
tooltip="Number of audio channels (1 for mono, 2 for stereo).",
advanced=True,
),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, duration, sample_rate, channels) -> IO.NodeOutput:
num_samples = int(round(duration * sample_rate))
waveform = torch.zeros((1, channels, num_samples), dtype=torch.float32)
return IO.NodeOutput({"waveform": waveform, "sample_rate": sample_rate})
create_empty_audio = execute # TODO: remove
class AudioEqualizer3Band(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioEqualizer3Band",
search_aliases=["eq", "bass boost", "treble boost", "equalizer"],
display_name="Audio Equalizer (3-Band)",
category="audio",
is_experimental=True,
inputs=[
IO.Audio.Input("audio"),
IO.Float.Input("low_gain_dB", default=0.0, min=-24.0, max=24.0, step=0.1, tooltip="Gain for Low frequencies (Bass)"),
IO.Int.Input("low_freq", default=100, min=20, max=500, tooltip="Cutoff frequency for Low shelf"),
IO.Float.Input("mid_gain_dB", default=0.0, min=-24.0, max=24.0, step=0.1, tooltip="Gain for Mid frequencies"),
IO.Int.Input("mid_freq", default=1000, min=200, max=4000, tooltip="Center frequency for Mids"),
IO.Float.Input("mid_q", default=0.707, min=0.1, max=10.0, step=0.1, tooltip="Q factor (bandwidth) for Mids"),
IO.Float.Input("high_gain_dB", default=0.0, min=-24.0, max=24.0, step=0.1, tooltip="Gain for High frequencies (Treble)"),
IO.Int.Input("high_freq", default=5000, min=1000, max=15000, tooltip="Cutoff frequency for High shelf"),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio, low_gain_dB, low_freq, mid_gain_dB, mid_freq, mid_q, high_gain_dB, high_freq) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
if waveform.shape[-1] == 0:
return IO.NodeOutput(audio)
eq_waveform = waveform.clone()
# 1. Apply Low Shelf (Bass)
if low_gain_dB != 0:
eq_waveform = torchaudio.functional.bass_biquad(
eq_waveform,
sample_rate,
gain=low_gain_dB,
central_freq=float(low_freq),
Q=0.707
)
# 2. Apply Peaking EQ (Mids)
if mid_gain_dB != 0:
eq_waveform = torchaudio.functional.equalizer_biquad(
eq_waveform,
sample_rate,
center_freq=float(mid_freq),
gain=mid_gain_dB,
Q=mid_q
)
# 3. Apply High Shelf (Treble)
if high_gain_dB != 0:
eq_waveform = torchaudio.functional.treble_biquad(
eq_waveform,
sample_rate,
gain=high_gain_dB,
central_freq=float(high_freq),
Q=0.707
)
return IO.NodeOutput({"waveform": eq_waveform, "sample_rate": sample_rate})
class AudioExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
EmptyLatentAudio,
VAEEncodeAudio,
VAEDecodeAudio,
VAEDecodeAudioTiled,
SaveAudio,
SaveAudioMP3,
SaveAudioOpus,
LoadAudio,
PreviewAudio,
ConditioningStableAudio,
RecordAudio,
TrimAudioDuration,
SplitAudioChannels,
JoinAudioChannels,
AudioConcat,
AudioMerge,
AudioAdjustVolume,
EmptyAudio,
AudioEqualizer3Band,
]
async def comfy_entrypoint() -> AudioExtension:
return AudioExtension()
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
comfyui-frontend-package==1.43.18
comfyui-workflow-templates==0.9.77
comfyui-embedded-docs==0.5.0
torch
torchsde
torchvision
torchaudio
numpy>=1.25.0
einops
transformers>=4.50.3
tokenizers>=0.13.3
sentencepiece
safetensors>=0.4.2
aiohttp>=3.11.8
yarl>=1.18.0
pyyaml
Pillow
scipy
tqdm
psutil
alembic
SQLAlchemy>=2.0.0
filelock
av>=14.2.0
comfy-kitchen>=0.2.8
comfy-aimdo==0.3.0
requests
simpleeval>=1.0.0
blake3
#non essential dependencies:
kornia>=0.7.1
spandrel
pydantic~=2.0
pydantic-settings~=2.0
PyOpenGL
glfw
File diff suppressed because it is too large Load Diff
@@ -1,289 +0,0 @@
import argparse
import pytest
from requests.exceptions import HTTPError
from unittest.mock import patch, mock_open
from app.frontend_management import (
FrontendManager,
FrontEndProvider,
Release,
)
from comfy.cli_args import DEFAULT_VERSION_STRING
@pytest.fixture
def mock_releases():
return [
Release(
id=1,
tag_name="1.0.0",
name="Release 1.0.0",
prerelease=False,
created_at="2022-01-01T00:00:00Z",
published_at="2022-01-01T00:00:00Z",
body="Release notes for 1.0.0",
assets=[{"name": "dist.zip", "url": "https://example.com/dist.zip"}],
),
Release(
id=2,
tag_name="2.0.0",
name="Release 2.0.0",
prerelease=False,
created_at="2022-02-01T00:00:00Z",
published_at="2022-02-01T00:00:00Z",
body="Release notes for 2.0.0",
assets=[{"name": "dist.zip", "url": "https://example.com/dist.zip"}],
),
]
@pytest.fixture
def mock_provider(mock_releases):
provider = FrontEndProvider(
owner="test-owner",
repo="test-repo",
)
provider.all_releases = mock_releases
provider.latest_release = mock_releases[1]
FrontendManager.PROVIDERS = [provider]
return provider
@pytest.fixture(autouse=True)
def clear_cache():
import utils.install_util
import app.frontend_management
utils.install_util.PACKAGE_VERSIONS = {}
app.frontend_management.COMFY_PACKAGE_VERSIONS = []
def test_get_release(mock_provider, mock_releases):
version = "1.0.0"
release = mock_provider.get_release(version)
assert release == mock_releases[0]
def test_get_release_latest(mock_provider, mock_releases):
version = "latest"
release = mock_provider.get_release(version)
assert release == mock_releases[1]
def test_get_release_invalid_version(mock_provider):
version = "invalid"
with pytest.raises(ValueError):
mock_provider.get_release(version)
def test_init_frontend_default():
version_string = DEFAULT_VERSION_STRING
frontend_path = FrontendManager.init_frontend(version_string)
assert frontend_path == FrontendManager.default_frontend_path()
def test_init_frontend_invalid_version():
version_string = "test-owner/test-repo@1.100.99"
with pytest.raises(HTTPError):
FrontendManager.init_frontend_unsafe(version_string)
def test_init_frontend_invalid_provider():
version_string = "invalid/invalid@latest"
with pytest.raises(HTTPError):
FrontendManager.init_frontend_unsafe(version_string)
@pytest.fixture
def mock_os_functions():
with (
patch("app.frontend_management.os.makedirs") as mock_makedirs,
patch("app.frontend_management.os.listdir") as mock_listdir,
patch("app.frontend_management.os.rmdir") as mock_rmdir,
):
mock_listdir.return_value = [] # Simulate empty directory
yield mock_makedirs, mock_listdir, mock_rmdir
@pytest.fixture
def mock_download():
with patch("app.frontend_management.download_release_asset_zip") as mock:
mock.side_effect = Exception("Download failed") # Simulate download failure
yield mock
def test_finally_block(mock_os_functions, mock_download, mock_provider):
# Arrange
mock_makedirs, mock_listdir, mock_rmdir = mock_os_functions
version_string = "test-owner/test-repo@1.0.0"
# Act & Assert
with pytest.raises(Exception):
FrontendManager.init_frontend_unsafe(version_string, mock_provider)
# Assert
mock_makedirs.assert_called_once()
mock_download.assert_called_once()
mock_listdir.assert_called_once()
mock_rmdir.assert_called_once()
def test_parse_version_string():
version_string = "owner/repo@1.0.0"
repo_owner, repo_name, version = FrontendManager.parse_version_string(
version_string
)
assert repo_owner == "owner"
assert repo_name == "repo"
assert version == "1.0.0"
def test_parse_version_string_invalid():
version_string = "invalid"
with pytest.raises(argparse.ArgumentTypeError):
FrontendManager.parse_version_string(version_string)
def test_init_frontend_default_with_mocks():
# Arrange
version_string = DEFAULT_VERSION_STRING
# Act
with (
patch("app.frontend_management.check_comfy_packages_versions") as mock_check,
patch.object(
FrontendManager, "default_frontend_path", return_value="/mocked/path"
),
):
frontend_path = FrontendManager.init_frontend(version_string)
# Assert
assert frontend_path == "/mocked/path"
mock_check.assert_called_once()
def test_init_frontend_fallback_on_error():
# Arrange
version_string = "test-owner/test-repo@1.0.0"
# Act
with (
patch.object(
FrontendManager, "init_frontend_unsafe", side_effect=Exception("Test error")
),
patch("app.frontend_management.check_comfy_packages_versions") as mock_check,
patch.object(
FrontendManager, "default_frontend_path", return_value="/default/path"
),
):
frontend_path = FrontendManager.init_frontend(version_string)
# Assert
assert frontend_path == "/default/path"
mock_check.assert_called_once()
def test_get_frontend_version():
# Arrange
expected_version = "1.25.0"
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_frontend_version()
# Assert
assert version == expected_version
def test_get_frontend_version_invalid_semver():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.29.3.75
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_frontend_version()
# Assert
assert version is None
def test_get_templates_version():
# Arrange
expected_version = "0.1.41"
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
comfyui-workflow-templates==0.1.41
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version == expected_version
def test_get_templates_version_not_found():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version is None
def test_get_templates_version_invalid_semver():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-workflow-templates==1.0.0.beta
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version is None
def test_get_installed_templates_version():
# Arrange
expected_version = "0.1.40"
# Act
with patch("app.frontend_management.version", return_value=expected_version):
version = FrontendManager.get_installed_templates_version()
# Assert
assert version == expected_version
def test_get_installed_templates_version_not_installed():
# Act
with patch(
"app.frontend_management.version", side_effect=Exception("Package not found")
):
version = FrontendManager.get_installed_templates_version()
# Assert
assert version is None
@@ -1,144 +0,0 @@
from collections import defaultdict
import torch
from comfy.model_detection import detect_unet_config, model_config_from_unet_config
import comfy.supported_models
def _freeze(value):
"""Recursively convert a value to a hashable form so configs can be
compared/used as dict keys or set members."""
if isinstance(value, dict):
return frozenset((k, _freeze(v)) for k, v in value.items())
if isinstance(value, (list, tuple)):
return tuple(_freeze(v) for v in value)
if isinstance(value, set):
return frozenset(_freeze(v) for v in value)
return value
def _make_longcat_comfyui_sd():
"""Minimal ComfyUI-format state dict for pre-converted LongCat-Image weights."""
sd = {}
H = 32 # Reduce hidden state dimension to reduce memory usage
C_IN = 16
C_CTX = 3584
sd["img_in.weight"] = torch.empty(H, C_IN * 4)
sd["img_in.bias"] = torch.empty(H)
sd["txt_in.weight"] = torch.empty(H, C_CTX)
sd["txt_in.bias"] = torch.empty(H)
sd["time_in.in_layer.weight"] = torch.empty(H, 256)
sd["time_in.in_layer.bias"] = torch.empty(H)
sd["time_in.out_layer.weight"] = torch.empty(H, H)
sd["time_in.out_layer.bias"] = torch.empty(H)
sd["final_layer.adaLN_modulation.1.weight"] = torch.empty(2 * H, H)
sd["final_layer.adaLN_modulation.1.bias"] = torch.empty(2 * H)
sd["final_layer.linear.weight"] = torch.empty(C_IN * 4, H)
sd["final_layer.linear.bias"] = torch.empty(C_IN * 4)
for i in range(19):
sd[f"double_blocks.{i}.img_attn.norm.key_norm.weight"] = torch.empty(128)
sd[f"double_blocks.{i}.img_attn.qkv.weight"] = torch.empty(3 * H, H)
sd[f"double_blocks.{i}.img_mod.lin.weight"] = torch.empty(H, H)
for i in range(38):
sd[f"single_blocks.{i}.modulation.lin.weight"] = torch.empty(H, H)
return sd
def _make_flux_schnell_comfyui_sd():
"""Minimal ComfyUI-format state dict for standard Flux Schnell."""
sd = {}
H = 32 # Reduce hidden state dimension to reduce memory usage
C_IN = 16
sd["img_in.weight"] = torch.empty(H, C_IN * 4)
sd["img_in.bias"] = torch.empty(H)
sd["txt_in.weight"] = torch.empty(H, 4096)
sd["txt_in.bias"] = torch.empty(H)
sd["double_blocks.0.img_attn.norm.key_norm.weight"] = torch.empty(128)
sd["double_blocks.0.img_attn.qkv.weight"] = torch.empty(3 * H, H)
sd["double_blocks.0.img_mod.lin.weight"] = torch.empty(H, H)
for i in range(19):
sd[f"double_blocks.{i}.img_attn.norm.key_norm.weight"] = torch.empty(128)
for i in range(38):
sd[f"single_blocks.{i}.modulation.lin.weight"] = torch.empty(H, H)
return sd
class TestModelDetection:
"""Verify that first-match model detection selects the correct model
based on list ordering and unet_config specificity."""
def test_longcat_before_schnell_in_models_list(self):
"""LongCatImage must appear before FluxSchnell in the models list."""
models = comfy.supported_models.models
longcat_idx = next(i for i, m in enumerate(models) if m.__name__ == "LongCatImage")
schnell_idx = next(i for i, m in enumerate(models) if m.__name__ == "FluxSchnell")
assert longcat_idx < schnell_idx, (
f"LongCatImage (index {longcat_idx}) must come before "
f"FluxSchnell (index {schnell_idx}) in the models list"
)
def test_longcat_comfyui_detected_as_longcat(self):
sd = _make_longcat_comfyui_sd()
unet_config = detect_unet_config(sd, "")
assert unet_config is not None
assert unet_config["image_model"] == "flux"
assert unet_config["context_in_dim"] == 3584
assert unet_config["vec_in_dim"] is None
assert unet_config["guidance_embed"] is False
assert unet_config["txt_ids_dims"] == [1, 2]
model_config = model_config_from_unet_config(unet_config, sd)
assert model_config is not None
assert type(model_config).__name__ == "LongCatImage"
def test_longcat_comfyui_keys_pass_through_unchanged(self):
"""Pre-converted weights should not be transformed by process_unet_state_dict."""
sd = _make_longcat_comfyui_sd()
unet_config = detect_unet_config(sd, "")
model_config = model_config_from_unet_config(unet_config, sd)
processed = model_config.process_unet_state_dict(dict(sd))
assert "img_in.weight" in processed
assert "txt_in.weight" in processed
assert "time_in.in_layer.weight" in processed
assert "final_layer.linear.weight" in processed
def test_flux_schnell_comfyui_detected_as_flux_schnell(self):
sd = _make_flux_schnell_comfyui_sd()
unet_config = detect_unet_config(sd, "")
assert unet_config is not None
assert unet_config["image_model"] == "flux"
assert unet_config["context_in_dim"] == 4096
assert unet_config["txt_ids_dims"] == []
model_config = model_config_from_unet_config(unet_config, sd)
assert model_config is not None
assert type(model_config).__name__ == "FluxSchnell"
def test_unet_config_and_required_keys_combination_is_unique(self):
"""Each model in the registry must have a unique combination of
``unet_config`` and ``required_keys``. If two models share the same
combination, ``BASE.matches`` cannot disambiguate between them and the
first one in the list will always win."""
models = comfy.supported_models.models
groups = defaultdict(list)
for model in models:
key = (_freeze(model.unet_config), _freeze(model.required_keys))
groups[key].append(model.__name__)
duplicates = {k: names for k, names in groups.items() if len(names) > 1}
assert not duplicates, (
"Found models sharing the same (unet_config, required_keys) "
"combination, which makes detection ambiguous: "
+ "; ".join(", ".join(names) for names in duplicates.values())
)
Submodule comfy_source deleted from 26515acd23
+48 -3
View File
@@ -8,11 +8,16 @@ cors_origins = ["*"]
url = "sqlite:boosapi.db?mode=rwc"
max_connections = 10
ssl = false
# PostgreSQL preparation example:
# url = "postgres://boosapi:password@127.0.0.1:5432/boosapi"
# Note: this build can create/migrate PostgreSQL schemas and copy SQLite data
# with `boosapi migrate-sqlite-to-postgres`; full runtime query compatibility
# is completed in a follow-up SQL dialect pass.
[auth]
jwt_secret = "boosapi-dev-secret-change-in-production"
jwt_expiry_hours = 72
auth_token = "change-me-admin-token"
auth_token = "boos4721"
proxy_token = "sk-boos4721"
[proxy]
@@ -20,12 +25,52 @@ upstream_timeout_secs = 300
first_byte_timeout_secs = 60
max_retries = 3
proxy_log_retention_days = 30
# Optional proxy for external template galleries/workflow template assets.
# Supports http://, https:// and socks5:// proxy URLs.
# template_proxy_url = "http://127.0.0.1:7890"
[payment]
enabled = false
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
# Example: "https://tybossai.com"
public_base_url = ""
product_name_prefix = "BossAI 账户充值"
currency = "usd"
min_amount_usd = 1.0
max_amount_usd = 100000.0
order_timeout_minutes = 30
max_pending_orders = 3
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
# Prefer environment variables for secrets:
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
stripe_secret_key = ""
stripe_webhook_secret = ""
[comfyui]
enabled = true
enabled = false
python_bin = "python3"
port = 8188
extra_args = []
max_restarts = 5
base_url = "http://127.0.0.1:8188"
# backend modes:
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
# - "external": proxy to your own full ComfyUI server.
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
backend = "cloud"
# Native Comfy task costs are charged against the user's downstream API Key
# only after successful output archival. Keep 0 while pricing is not finalized.
money_printer_video_cost = 0.0
digital_human_video_cost = 0.0
# docker_image = "boosapi-comfyui:multi-tenant"
# container_name = "boosapi-comfyui-workflow"
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
# RunningHub proxy URL already contains the API key. Keep this empty unless
# you switch to a backend that requires Authorization / X-API-Key headers.
base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
agent_model = "gpt-5.5"
-37
View File
@@ -1,37 +0,0 @@
FROM python:3.11-slim
WORKDIR /workspace
# Use USTC mirror for faster apt
RUN sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl \
&& rm -rf /var/lib/apt/lists/*
# Copy ComfyUI from local source (avoid slow GitHub clone)
COPY comfy_source /workspace/ComfyUI
WORKDIR /workspace/ComfyUI
# Install git (needed for some ComfyUI custom node managers)
RUN git init
# Python venv + deps (CPU-only, generation goes through BoosAPI)
RUN python3 -m venv venv && \
. venv/bin/activate && \
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip install --upgrade pip && \
pip config set global.extra-index-url https://download.pytorch.org/whl/cpu && \
pip install torch torchvision torchaudio && \
grep -v kornia_rs requirements.txt | pip install -r /dev/stdin && \
pip install six && \
rm -rf ~/.cache/pip
ENV PATH="/workspace/ComfyUI/venv/bin:${PATH}"
EXPOSE 8188
CMD ["python3", "main.py", "--listen", "0.0.0.0", "--port", "8188", "--cpu"]
-124
View File
@@ -1,124 +0,0 @@
# syntax=docker/dockerfile:1.4
# Multi-tenant ComfyUI image — one container per BoosAPI user.
#
# Lineage: based on the same Python 3.11 / CPU-only profile the legacy
# bosapi-comfyui:ready image used. Differences from the singleton image:
# 1. Bakes `comfy_patches/overlay` into the image (proxy-base
# default + auth-header fallback, server/runtime hardening, docs)
# so containers do NOT need a host bind-mount of `comfy_source/`.
# 2. Bakes the BoosAPI custom nodes from `comfy/boosapi_comfy_nodes.py`
# under `/workspace/ComfyUI/custom_nodes/` so workflows referencing
# `BoosAPITextGen` etc. resolve without any host mount.
# 3. Reads `BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, `BOOSAPI_KEY` at
# runtime via the entrypoint shim and forwards them as
# `--comfy-api-base` and the documented env vars.
# 4. No host source mounts — all ComfyUI code lives inside the image
# so the container can run with `--read-only` and tmpfs `/tmp`.
#
# Build context MUST be the repo root (`docker build -f docker/Dockerfile.comfyui.multi-tenant .`).
#
# Requires BuildKit (Docker 20.10+ has it on by default; build-and-run.sh
# also exports DOCKER_BUILDKIT=1 defensively). The `--mount=type=cache`
# instructions below are no-ops without BuildKit.
FROM python:3.11-slim
WORKDIR /workspace
# USTC mirror for faster apt
RUN sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl tini \
&& rm -rf /var/lib/apt/lists/*
# uv: 5-10x faster than pip on resolution + install. Installed from PyPI
# via the Tsinghua mirror that's already used by the rest of the build,
# instead of curling the upstream GitHub release tarball —
# `github.com/astral-sh/uv/releases/...` is unreliable from mainland
# networks (TLS handshake completes, then body read truncates with
# `SSL_read: unexpected eof`). The PyPI wheel bundles the same self-
# contained binary and drops `uv` into the system Python's scripts dir
# (already on PATH). Pinned for reproducibility.
RUN pip install --no-cache-dir \
--index-url https://pypi.tuna.tsinghua.edu.cn/simple \
uv==0.5.31 \
&& uv --version
# ── Layer-caching strategy ────────────────────────────────────────────
# Copy ONLY requirements.txt first. The heavy install layers (torch +
# ComfyUI deps, ~200 MB combined) then live in their own cache lines
# that only invalidate when requirements.txt actually changes. Most
# rebuilds change other comfy_source/ files (workflow scripts, custom
# nodes, etc.), so this swap turns a 5-10 minute reinstall into a
# zero-network layer-cache hit.
#
# The full `COPY comfy_source` happens after the install. It overlays
# the rest of ComfyUI on top of the venv directory (which lives at
# /workspace/ComfyUI/venv) without disturbing it — venv/ isn't present
# in the source tree, and requirements.txt is just rewritten with
# identical content.
COPY comfy_source/requirements.txt /workspace/ComfyUI/requirements.txt
WORKDIR /workspace/ComfyUI
RUN uv venv venv --python python3.11
ENV PATH="/workspace/ComfyUI/venv/bin:${PATH}"
# ── PyTorch CPU wheels ────────────────────────────────────────────────
# ~200 MB combined and the single biggest network draw of the build.
# Two reliability levers:
# 1. Tsinghua pytorch-wheels mirror replaces upstream
# `download.pytorch.org/whl/cpu`. Upstream is slow + flaky from
# mainland networks (TLS handshake completes, body read truncates
# with `SSL_read: unexpected eof`). Tsinghua mirrors the same
# wheels and is documented as a drop-in `--extra-index-url`.
# 2. BuildKit cache mount on /root/.cache/uv preserves the downloaded
# wheels across rebuilds. Even if this layer is invalidated (e.g.
# after a torch version bump), uv reuses cached wheels and only
# fetches what changed.
RUN --mount=type=cache,target=/root/.cache/uv \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
uv pip install --python venv/bin/python torch torchvision torchaudio
# ── ComfyUI deps ──────────────────────────────────────────────────────
# kornia_rs is a Rust-built wheel that compiles from source on install,
# adding minutes to the build. The BoosAPI workflows we care about
# don't use it, so strip it. Cache mount handles re-downloads when
# requirements.txt changes (most lines stay the same → most wheels
# reuse from cache).
RUN --mount=type=cache,target=/root/.cache/uv \
grep -v kornia_rs requirements.txt > /tmp/requirements.txt && \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python -r /tmp/requirements.txt && \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python six && \
rm /tmp/requirements.txt
# ── Full ComfyUI source ───────────────────────────────────────────────
# This overlay COPY brings in everything else under comfy_source/. The
# venv at /workspace/ComfyUI/venv survives because the source tree
# doesn't contain a venv/ directory; requirements.txt is overwritten
# with identical bytes (no-op).
COPY comfy_source /workspace/ComfyUI
RUN git init
# ── Bake the BoosAPI patches ──────────────────────────────────────────
# Keep the image self-contained: overlay patches are tracked in the parent
# repo, while comfy_source remains a vanilla ComfyUI submodule.
COPY comfy_patches/overlay /workspace/ComfyUI
# BoosAPI custom nodes (BoosAPITextGen, image gen, etc.).
RUN mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py \
/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
# Entrypoint shim — forwards env vars to ComfyUI's CLI flags.
COPY docker/comfyui-entrypoint.sh /usr/local/bin/comfyui-entrypoint
RUN chmod +x /usr/local/bin/comfyui-entrypoint
EXPOSE 8188
# tini reaps any subprocesses ComfyUI spawns; the shim execs into Python.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/comfyui-entrypoint"]
+33
View File
@@ -0,0 +1,33 @@
FROM boosapi-comfyui:multi-tenant
ARG COMFYUI_TAG=v0.22.2
WORKDIR /workspace/ComfyUI
COPY docker/build-comfyui-src /tmp/ComfyUI-updated
RUN find /workspace/ComfyUI -mindepth 1 \
! -path '/workspace/ComfyUI/venv' \
! -path '/workspace/ComfyUI/venv/*' \
-exec rm -rf {} + \
&& cp -a /tmp/ComfyUI-updated/. /workspace/ComfyUI/ \
&& rm -rf /tmp/ComfyUI-updated \
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
uv pip install --python venv/bin/python -r requirements.txt \
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python six \
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
RUN bash /tmp/install-runninghub-template-nodes.sh \
&& rm -f /tmp/install-runninghub-template-nodes.sh
RUN python - <<'PY'
from pathlib import Path
path = Path("/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py")
if not path.exists():
raise SystemExit("BoosAPI custom node missing after ComfyUI update")
PY
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Multi-tenant ComfyUI entrypoint.
#
# Reads the per-user secrets the ComfyUIPool injects:
# BOOSAPI_BUILTIN_KEY the BoosAPI downstream-key secret used by the
# patched _helpers.py for built-in OpenAI nodes.
# BOOSAPI_BASE the BoosAPI loopback base, e.g. http://host:4000/v1
# BOOSAPI_KEY the per-user proxy key for boosapi_comfy_nodes.
#
# Then execs ComfyUI with --listen 0.0.0.0 --port 8188 and the
# --comfy-api-base flag pointing at the BoosAPI agent shim so all
# built-in OpenAI nodes route through us.
set -euo pipefail
: "${BOOSAPI_BASE:?BOOSAPI_BASE is required (BoosAPI /v1 base URL)}"
: "${BOOSAPI_BUILTIN_KEY:?BOOSAPI_BUILTIN_KEY is required (downstream-key secret)}"
# BOOSAPI_KEY is per-user; tolerate it being unset (then BoosAPITextGen
# nodes will fall back to BOOSAPI_BUILTIN_KEY via the helper patch).
export BOOSAPI_KEY="${BOOSAPI_KEY:-$BOOSAPI_BUILTIN_KEY}"
# Strip a trailing /v1 for the agent base (the helper appends it back).
BOOSAPI_HOST="${BOOSAPI_BASE%/v1}"
BOOSAPI_HOST="${BOOSAPI_HOST%/}"
COMFY_API_BASE="${BOOSAPI_HOST}/api/comfyui-agent"
cd /workspace/ComfyUI
exec python main.py \
--listen 0.0.0.0 \
--port 8188 \
--cpu \
--comfy-api-base "${COMFY_API_BASE}"
-22
View File
@@ -1,22 +0,0 @@
# docker-compose.comfyui.yml
# 开发环境:在 BoosAPI 旁边运行 ComfyUI
# 使用: docker compose -f docker/docker-compose.yml -f docker/docker-compose.comfyui.yml up -d
services:
comfyui:
build:
context: ..
dockerfile: docker/Dockerfile.comfyui
ports:
- "127.0.0.1:8188:8188"
volumes:
- comfyui-data:/workspace
- ../boosapi_comfy_nodes.py:/workspace/ComfyUI/custom_nodes/boosapi_comfy_nodes.py
environment:
- BOOSAPI_BASE=http://host.docker.internal:4000/v1
- BOOSAPI_KEY=${BOOSAPI_KEY:-sk-demo-user}
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
comfyui-data:
@@ -0,0 +1,694 @@
# 计费闭环 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 BoosAPI 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
---
## 文件结构(第 1 期)
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
---
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
### Task 1:ledger 表结构 + 写入入口
**Files:**
- Create: `src/services/billing/ledger.rs`
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
```rust
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn mem_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
ensure_ledger_schema(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
let pool = mem_pool().await;
let mut tx = pool.begin().await.unwrap();
record_ledger_entry(
&mut tx,
LedgerEntry {
user_id: 7,
track: "usd",
entry_type: "recharge",
amount: 10.0,
balance_after: 10.0,
ref_type: Some("stripe"),
ref_id: Some("cs_1"),
model: None,
operator: Some("system"),
note: None,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let row: (i64, String, String, f64, f64) = sqlx::query_as(
"SELECT user_id, track, entry_type, amount, balance_after
FROM credit_ledger WHERE user_id = 7",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
```rust
use crate::error::AppResult;
use sqlx::{SqlitePool, Transaction};
/// One balance-changing event. Written to `credit_ledger` in the same
/// transaction that updates the cached balance, so ledger and cache never diverge.
pub struct LedgerEntry<'a> {
pub user_id: i64,
pub track: &'a str, // "usd" | "points"
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
pub amount: f64, // +credit / -debit
pub balance_after: f64,
pub ref_type: Option<&'a str>,
pub ref_id: Option<&'a str>,
pub model: Option<&'a str>,
pub operator: Option<&'a str>,
pub note: Option<&'a str>,
}
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS credit_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
track TEXT NOT NULL,
entry_type TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL NOT NULL,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
ON credit_ledger (user_id, created_at DESC)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
ON credit_ledger (track, entry_type)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS user_credits (
user_id INTEGER PRIMARY KEY NOT NULL,
points_balance REAL NOT NULL DEFAULT 0,
points_total_recharged REAL NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
Ok(())
}
/// Append one ledger row inside an existing transaction. Caller is responsible
/// for also updating the cached balance in the same `tx`.
pub async fn record_ledger_entry(
tx: &mut Transaction<'_, sqlx::Sqlite>,
entry: LedgerEntry<'_>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO credit_ledger
(user_id, track, entry_type, amount, balance_after,
ref_type, ref_id, model, operator, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.user_id)
.bind(entry.track)
.bind(entry.entry_type)
.bind(entry.amount)
.bind(entry.balance_after)
.bind(entry.ref_type)
.bind(entry.ref_id)
.bind(entry.model)
.bind(entry.operator)
.bind(entry.note)
.execute(&mut **tx)
.await?;
Ok(())
}
```
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
- [ ] **Step 4: 运行测试确认通过**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: PASS(1 passed)。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/ledger.rs src/services/billing/mod.rs
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
```
### Task 2:USD 消费接入 ledger(record_usage)
**Files:**
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
```rust
#[tokio::test]
async fn record_usage_writes_consume_ledger_entry() {
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
let row: (String, String, f64, Option<String>) = sqlx::query_as(
"SELECT track, entry_type, amount, model FROM credit_ledger
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row.0, "usd");
assert_eq!(row.1, "consume");
assert_eq!(row.2, -0.42); // 消费为负
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
}
```
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
```rust
pub async fn record_usage(
&self,
key_id: i64,
model: impl Into<String>,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
) -> AppResult<()> {
Self::ensure_schema(&self.db).await?;
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
let prompt_tokens = prompt_tokens.max(0);
let completion_tokens = completion_tokens.max(0);
let total_tokens = prompt_tokens + completion_tokens;
let cost = cost.max(0.0);
let model = model.into();
let mut tx = self.db.begin().await?;
sqlx::query(
"INSERT INTO usage_logs
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(key_id).bind(&model)
.bind(prompt_tokens).bind(completion_tokens).bind(total_tokens).bind(cost)
.execute(&mut *tx).await?;
sqlx::query(
"UPDATE downstream_api_keys
SET used_requests = COALESCE(used_requests,0)+1,
used_cost = COALESCE(used_cost,0)+?,
last_used_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?",
).bind(cost).bind(key_id).execute(&mut *tx).await?;
// Resolve owning user + new used_cost for the ledger snapshot.
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
).bind(key_id).fetch_optional(&mut *tx).await?;
if let Some((Some(user_id), max_cost, used_cost)) = owner {
let used = used_cost.unwrap_or(0.0);
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
crate::services::billing::ledger::record_ledger_entry(
&mut tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "consume",
amount: -cost, balance_after: remaining,
ref_type: Some("usage_log"), ref_id: None,
model: Some(&model), operator: Some("system"), note: None,
},
).await?;
}
tx.commit().await?;
Ok(())
}
```
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/mod.rs
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
```
### Task 3:USD 充值接入 ledger(credit_user_balance)
**Files:**
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
```rust
#[tokio::test]
async fn credit_user_balance_writes_recharge_ledger() {
let pool = payment_test_pool().await;
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
let mut tx = pool.begin().await.unwrap();
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
tx.commit().await.unwrap();
let row: (String, String, f64) = sqlx::query_as(
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
Expected: FAIL。
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
```rust
// After the UPDATE that sets next_max_cost on the key:
crate::services::billing::ledger::record_ledger_entry(
tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "recharge",
amount: amount_usd.max(0.0),
balance_after: (next_max_cost - used_cost).max(0.0),
ref_type: Some("recharge_record"), ref_id: None,
model: None, operator: Some("system"), note: None,
},
).await?;
```
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
Expected: PASS(含已有的 recharge completion 测试)。
- [ ] **Step 5: 提交**
```bash
git add src/services/payment.rs
git commit -m "feat(billing): USD recharge writes credit_ledger"
```
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
**Files:**
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
- [ ] **Step 5: 提交**
```bash
git add src/routes/admin/users.rs
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
```
### Task 5:全量回归
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
- [ ] **Step 3:** 提交(若有遗留)。
---
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
### 第 2 期:积分轨
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
- 测试:拦截、扣费、充值进积分、管理员调整。
### 第 3 期:A 消费明细
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
### 第 4 期:B 充值档位
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
### 第 5 期:C 预警
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
### 第 6 期:D 审计 + 对账
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
---
## 自审
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
---
## 第 2 期:积分轨(细化 TDD)
文件结构:
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
### Task 6:积分轨核心 apply_points_delta + points_balance
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
let pool = mem_pool().await;
// 首次 +100(充值)
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
assert_eq!(bal, 100.0);
// 再 -30(消费)
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
assert_eq!(bal2, 70.0);
// user_credits 缓存 = 70
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
assert_eq!(cached, 70.0);
// ledger 两行
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
assert_eq!(n, 2);
}
```
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
/// Current points balance for a user (0 if no row yet).
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
let bal: Option<f64> = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(bal.unwrap_or(0.0))
}
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
/// in a single transaction. Returns the new balance.
#[allow(clippy::too_many_arguments)]
pub async fn apply_points_delta(
db: &SqlitePool,
user_id: i64,
delta: f64,
entry_type: &str,
model: Option<&str>,
operator: Option<&str>,
ref_type: Option<&str>,
ref_id: Option<&str>,
) -> AppResult<f64> {
ensure_ledger_schema(db).await?;
let mut tx = db.begin().await?;
let current: f64 = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
let next = current + delta;
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
sqlx::query(
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
points_balance = ?,
points_total_recharged = points_total_recharged + ?,
updated_at = datetime('now')",
)
.bind(user_id).bind(next).bind(total_recharged_inc)
.bind(next).bind(total_recharged_inc)
.execute(&mut *tx).await?;
record_ledger_entry(&mut tx, LedgerEntry {
user_id, track: "points", entry_type,
amount: delta, balance_after: next,
ref_type, ref_id, model, operator, note: None,
}).await?;
tx.commit().await?;
Ok(next)
}
```
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
### Task 7:工作流积分单价 config
**Files:** Modify `src/config.rs`
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
### Task 8:工作流执行前置拦截 + 成功扣积分
**Files:** Modify `src/workflow/executor.rs`
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
- [ ] **Step2** 确认 FAIL。
- [ ] **Step3 实现**:
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
### Task 9:comfy 充值改加积分 + admin 积分调整
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
- [ ] **Step1 失败测试**:
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
- [ ] **Step2** FAIL。
- [ ] **Step3 实现**:
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
### Task 10:第 2 期全量回归
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
---
## 第 3 期:A 消费明细(细化 TDD)
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
### Task 11:ledger 查询函数(明细 + 聚合)
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
```
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
use serde_json::{json, Value};
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool, user_id: i64,
track: Option<&str>, entry_type: Option<&str>,
limit: i64, offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (?1_track IS NULL OR track = ?1_track)
AND (?1_type IS NULL OR entry_type = ?1_type)
ORDER BY id DESC LIMIT ? OFFSET ?"
.replace("?1_track", "?2").replace("?1_type", "?3")
)
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
.fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
})).collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type"
).bind(user_id).fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
})).collect())
}
```
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
### Task 12:user 侧 billing 接口
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
- [ ] **Step2** FAIL/编译失败。
- [ ] **Step3 实现**:
```rust
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let rows = crate::services::billing::ledger::list_user_ledger(
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
Ok(Json(json!({ "success": true, "records": rows })))
}
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
Ok(Json(json!({ "success": true, "summary": agg })))
}
```
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
### Task 13:前端 api + UsageRecords 双轨展示
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
### Task 14:第3期回归
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
@@ -0,0 +1,149 @@
# 计费闭环系统设计
日期:2026-06-16
状态:设计已获批,待写实现计划
## 背景
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
## 核心模型:双轨额度
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
## 数据模型
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `user_id` | INTEGER NOT NULL | 所属用户 |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
| `ref_id` | TEXT | 关联记录 id/public_id |
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
| `note` | TEXT | 备注 |
| `created_at` | TEXT DEFAULT (datetime('now')) | |
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
### 新表 `user_credits`(积分余额缓存)
| 字段 | 类型 | 说明 |
|---|---|---|
| `user_id` | INTEGER PK | |
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
| `updated_at` | TEXT | |
### 新表 `recharge_tiers`(充值档位,admin 可配)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
| `title` | TEXT | 档位标题 |
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
| `enabled` | INTEGER DEFAULT 1 | 启用 |
### 余额派生策略:ledger + 缓存双写
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
## 双轨记账接入
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
### USD 轨
| 变动 | 现有代码 | 改造 |
|---|---|---|
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
### 积分轨(基本新建)
| 变动 | 现状 | 新增 |
|---|---|---|
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
## 积分消费规则:A+C 混合
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
## 四块功能
### A 消费明细(用户侧,明细+聚合)
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
### B 充值档位(本期可配置,订阅后期)
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
### C 超额 + 预警(两轨硬拦 + 预警)
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
- Dashboard/充值页/视频助手:余额低显示预警条。
### D 审计对账(管理员侧,全量流水)
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
## 错误处理(不变量)
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
- comfy 充值改向积分:config 开关控制,可回滚。
## 测试策略
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
## 实现顺序(分 6 期,每期可独立上线)
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
## 新增清单小结
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
- **cron**:Comfy 余额对账校准任务。
@@ -0,0 +1,100 @@
# NIT AI Studio — 第一阶段(聚合图像/数字人)设计
日期:2026-06-17
分支:`nit_edu`
状态:已通过 brainstorming,待落实现计划
## 背景
NIT 精英班专属的「AI 综合应用软件」是一套面向精英班学员的 AI 创作工具,完整愿景涵盖
文本结构化创作(论文/小说/剧本/分镜/教材)、PPT 生成、图像(P图/制图)、视频
(文生/图生/短视频)、数字人。体量过大,分阶段建设。
**本 spec 仅覆盖 AI Studio 第一阶段:聚合现有图像 + 数字人能力**,为精英班学员提供
统一创作入口,复用 BoosAPI 已有的 `picture_agent`(图像)与 `digital_human`(数字人)。
## 已确认的关键决策
1. 定位:NIT 精英班专属模块,非通用套件。
2. 第一阶段只聚合 `picture_agent`(图像)+ `digital_human`(数字人)。
3. 视频(含 money_printer、工作流视频)留后续阶段,本阶段完全不碰 money_printer。
4. 认证桥接:给精英班学员配后台下游 key + 扩展 surface 的 resolve_auth 接受 student JWT。
5. 计费:学员创作消耗走积分轨(已建的 points track),管理员可给学员充积分。
6. 文本创作 / PPT 留后续阶段。
## 前置依赖
本阶段 gate 在第一期(报考闭环)产物上,实现排在第一期之后:
- student JWT(`audience='student'`,第一期发放)。
- `enrollment.status=approved` 且 `tier=elite`。
- 非精英班 / 未审核 → 403。
## 非目标(本阶段明确不做)
- 视频能力(文生/图生/短视频、money_printer、工作流视频)。
- 文本结构化创作(论文/小说/剧本/分镜/教材)。
- PPT 生成。
- 通用套件(面向非 NIT 用户)。
## 学员下游 key 配置
- `downstream_api_keys` 加可空 `student_id` 列(关联 `students.id`,与现有 `user_id` 并列;
二者互斥:一行要么属于 user,要么属于 student)。
- 审核通过且 `tier=elite` 时,自动 provision 一个学员下游 key,沿用现有 `comfyui-agent`
内置 key 的幂等 `ensure` 模式:缺失则建、被禁用则重新启用、保持唯一。
- 学员从精英班降级或报考被驳回时,禁用该 key。
- key 命名约定:`sk-nit-stu-{uuid_hex}`,`name='nit-student'`,便于识别。
## 认证扩展
仅改两个 surface:`src/routes/picture_agent.rs`、`src/routes/digital_human.rs`。
**money_printer 不动。**
各自的 `resolve_auth` 加一个分支(现有 admin/proxy token、user JWT 逻辑不变):
- 新增:`audience='student'` 的 JWT → 校验该学员存在 `approved` 且 `tier=elite` 的报考
→ 查其 `student_id` 对应的下游 key → 作 `outbound_key`。
- 抽共享 helper `resolve_student_outbound_key(student_id, state) -> AppResult<(key_id, key)>`,
放在 `src/services/nit/`,两处 surface 复用,避免重复逻辑。
- 校验失败(无 elite 报考 / key 缺失)→ 403。
## 计费(复用积分轨)
- 学员用图像/数字人,消耗扣在其下游 key 上,走已建的积分轨(`apply_points_delta`,track=points)。
- 管理员给学员充积分:扩展现有 admin 积分调整端点支持 `student_id`(或新增 NIT 学员积分端点,
实现时择一,优先扩展现有端点)。
- 精英班开通时可送一笔初始积分,数额为配置项,默认 `0`。
## 统一入口(前端)
- `src/web/pages/nit/Studio.tsx`:精英班学员创作工作台首页,卡片式列出「图像 / 数字人」
两个入口 + 显示积分余额。
- 卡片跳进现有 `picture_agent` / `digital_human` 页面(带 student token)。
- 非精英班学员:显示引导升级提示,不展示工具卡片。
- `api.ts` 的 `nitApi` 扩展:查学员积分余额、查 Studio 可用工具。
## 错误处理
- 非精英班 / 未审核学员访问 → 403 + 友好提示。
- 学员下游 key 缺失(provision 失败)→ 明确错误,提示联系管理员。
- 积分余额不足 → 拦截创作,提示充值(复用现有 billing 引导文案范式)。
## 测试策略(TDD)
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
- 学员下游 key ensure 幂等性(缺失则建 / 禁用则启用 / 降级则禁用)。
- `resolve_auth` 接受 elite student JWT、拒绝非精英班、拒绝未审核。
- 学员创作扣积分、余额不足拦截。
前端(vitest):
- `nitApi` 积分余额 / Studio 工具查询方法(沿用 `api.test.ts` 范式)。
- Studio 页卡片渲染 + 余额展示 + 非精英班引导(沿用 `UsageRecords.test.tsx` 范式)。
## 验证
- `cargo build -j 2`、`cargo test -p boosapi --lib`(后端 nit / picture_agent / digital_human 相关)。
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
## 提交约定
全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分(迁移 / service /
surface 认证 / 前端 分开),沿用项目中文 commit 风格。
@@ -0,0 +1,195 @@
# NIT 报考学习平台 — 第一期(报考闭环 A+B+C)设计
日期:2026-06-17
分支:`nit_edu`
状态:已通过 brainstorming,待落实现计划
## 背景
NIT 报考学习平台面向报考「NIT 人工智能与大模型应用」考试的学员,提供注册、报考、
学习等功能。完整需求(见 `plan/NIT报考学习平台(初版需求备注解析).docx`)涵盖
8 个子系统,体量过大,无法单 spec 完成,故拆分分期建设。
**本 spec 仅覆盖第一期:报考闭环 A+B+C** —— 学员注册 → 报考 → 缴费审核开课。
学习中心(理论课/实操课/练习题)、OCR、校区负责人多级权限、内容保护等留后续期。
## 已确认的关键决策
1. 第一个子项目:A+B+C 报考闭环(注册 → 报考 → 缴费审核开课)。
2. 独立 `students` 表,与现有 `users`(API 网关用户)完全隔离。
3. 缴费:线下转账/扫码给校区 + 学员上传凭证截图 + 后台人工审核开课(非在线支付)。
4. 第一期不做 OCR:学员手动填身份证号/姓名,二寸照手动命名上传。
5. 开课权限只打状态标记(enrollment.status=approved + tier),学习中心留后续期再读此标记。
6. 后台第一期只做平台超管(复用现有 admin 认证),校区负责人登录留后续期。
7. 文件存储复用现有 workspace 机制(multipart + `record_bytes_asset` + `workspace_assets` 表)。
8. 代码组织:NIT 作为 BoosAPI 内的新模块,复用现有 axum/sqlx/JWT/workspace 基建。
9. 学员认证:发 `audience='student'` 的 JWT,与 admin/user token 互不通用。
10. 报名信息表:固定核心字段 + 一个 JSON 扩展字段(应对考试中心模版变化),不做动态表单引擎。
## 非目标(第一期明确不做)
- 学习中心:理论课/实操课视频、二维码、学习进度、练习题、模拟题、错题集。
- 实操课专属 AI 综合应用软件入口。
- 身份证 OCR 自动识别与二寸照自动命名。
- 校区负责人多级权限与数据隔离。
- 内容保护(防截图/录屏/水印)。
- 在线支付。
## 数据模型
三张新表,SQLite,迁移文件放 `migrations/`。
### `students` — 学员实名账户
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | 对外 ID,格式 `stu_{uuid_hex}` |
| `name` | TEXT NOT NULL | 姓名(实名,注册后不可改) |
| `phone` | TEXT NOT NULL UNIQUE | 实名手机号,登录用 |
| `id_card_no` | TEXT NOT NULL UNIQUE | 身份证号 |
| `campus_id` | INTEGER NOT NULL | FK → `nit_campuses.id` |
| `major` | TEXT | 所学专业(社会学员可空) |
| `class_name` | TEXT | 班级(可空) |
| `head_teacher` | TEXT | 班主任姓名(可空) |
| `password_hash` | TEXT NOT NULL | 登录密码哈希(沿用现有 users 哈希方案) |
| `privacy_agreed_at` | TEXT NOT NULL | 勾选隐私协议时间 |
| `status` | TEXT NOT NULL DEFAULT 'active' | active / disabled |
| `created_at` / `updated_at` | TEXT | |
### `nit_campuses` — 校区/机构库(超管录入,学员注册下拉选)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | `cmp_{uuid_hex}` |
| `province` | TEXT NOT NULL | 省份 |
| `name` | TEXT NOT NULL | 院校/机构名称 |
| `manager_name` | TEXT | 负责人姓名 |
| `manager_phone` | TEXT | 负责人联系方式 |
| `enabled` | INTEGER NOT NULL DEFAULT 1 | 仅 enabled 的校区可被学员选择 |
| `created_at` / `updated_at` | TEXT | |
### `nit_enrollments` — 报考记录
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | `enr_{uuid_hex}` |
| `student_id` | INTEGER NOT NULL | FK → `students.id` |
| `subject` | TEXT NOT NULL | 第一期固定「NIT人工智能与大模型应用」 |
| `tier` | TEXT NOT NULL | `basic`(980)/ `elite`(1980) |
| `payment_time` | TEXT | 学员填写的缴费时间 |
| `payment_proof_asset_id` | TEXT | 缴费凭证截图(workspace asset public_id) |
| `id_card_front_asset_id` | TEXT | 身份证正面 |
| `id_card_back_asset_id` | TEXT | 身份证反面 |
| `photo_asset_id` | TEXT | 二寸蓝底照 |
| `applicant_info` | TEXT (JSON) | 报名材料表可变字段 |
| `status` | TEXT NOT NULL DEFAULT 'pending_review' | pending_review / approved / rejected |
| `reviewed_by` | TEXT | 审核管理员标识 |
| `reviewed_at` | TEXT | |
| `review_note` | TEXT | 驳回原因/备注 |
| `created_at` / `updated_at` | TEXT | |
**关系**:student → campus(多对一);enrollment → student(多对一)。一个学员允许在被驳回后
重新提交报考(可有多条 rejected + 至多一条 active 的 pending_review/approved)。
**班型权限**:第一期权限即 `approved` 后 enrollment 上的 `tier` 标记;`basic` 对应理论课权限、
`elite` 对应理论+实操权限,实际课程内容由后续期读取此标记实现。
**文件**:四类上传文件全部走现有 `workspace_assets`(`source='nit-enrollment'`,
经 `record_bytes_asset` 落本地磁盘),enrollment 仅存其 `public_id` 引用。
## 认证与路由
### 学员认证
复用现有 `auth::jwt` 工具,给 students 发 `audience='student'` 的 JWT。新增
`resolve_student(headers, state) -> AppResult<i64>`(返回 student_id),与 admin/user token
互不通用。登录凭证为「手机号 + 密码」。
### 路由分布(挂进现有 axum router via `routes/mod.rs` merge)
- `src/routes/nit/mod.rs` — 装配 NIT 子路由,导出 `routes()`。
- `src/routes/nit/student_auth.rs`
- `POST /api/nit/register` — 学员注册
- `POST /api/nit/login` — 学员登录
- `src/routes/nit/enrollment.rs`(学员侧,需 student JWT)
- `POST /api/nit/enrollment/uploads` — multipart 上传单个文件,返回 asset public_id
- `POST /api/nit/enrollment` — 创建报考记录
- `GET /api/nit/enrollment/mine` — 查本人报考状态
- `src/routes/nit/admin.rs`(超管侧,复用现有 admin token 校验)
- `GET/POST/PUT /api/admin/nit/campuses` — 校区 CRUD
- `GET /api/admin/nit/enrollments` — 报考列表(按 省份/校区/班型/状态 筛选)
- `POST /api/admin/nit/enrollments/{public_id}/approve`
- `POST /api/admin/nit/enrollments/{public_id}/reject`
- `GET /api/admin/nit/enrollments/export` — CSV 导出
业务逻辑(查询/状态机)抽到 `src/services/nit/`(如 `mod.rs` + `enrollment.rs`),
路由层只做参数解析 + 鉴权 + 调用 service,保持文件聚焦。
## 数据流
### 注册
1. 校验手机号、身份证号未被占用(唯一约束 + 友好报错)。
2. 校验 `campus_id` 存在且 enabled。
3. 必须 `privacy_agreed=true`。
4. 写入 `students`(password 哈希),发 student JWT 返回。
### 报考(两步,避免半截事务)
1. 学员逐个调 `POST /api/nit/enrollment/uploads`(multipart,带 `purpose` 区分四类文件)
→ `record_bytes_asset` → 返回各 asset `public_id`。
2. 学员调 `POST /api/nit/enrollment`,带各 asset id + tier + payment_time + applicant_info
→ 校验本人无在审/已批的活动报考 → 创建 `pending_review` 记录。
### 审核(超管)
- 列表筛选 `pending_review` → 查看凭证/证件图(经 workspace asset file 接口)。
- `approve`:状态机 `pending_review → approved`,落 `tier` 权限标记,记 reviewed_by/at。
- `reject`:`pending_review → rejected`,记 review_note;学员可据此重新提交。
- 状态机只允许从 `pending_review` 流转,杜绝并发重复审核。
### 导出
按 省份/校区/班型/状态 筛选,导出 CSV(实名信息 + 班型),用于核算报考人数/教材数量。
## 前端页面(`src/web/pages/nit/`)
- 学员注册页(实名信息 + 校区下拉 + 隐私协议勾选)
- 学员登录页(手机号 + 密码)
- 报考页(选班型 + 上传四类文件 + 填缴费时间 + 报名信息表 + 提交)
- 「我的报考」状态页(pending/approved/rejected + 驳回原因 + 可重新提交)
- 超管校区管理页(CRUD)
- 超管报考审核页(列表 + 筛选 + 看图 + 通过/驳回 + 导出)
`api.ts` 新增 `nitApi`(学员侧,带 student token 注入)与 `adminApi` NIT 审核扩展。
复用现有 React/Vite/api.ts 范式与 UI 组件。
## 错误处理
- 唯一约束冲突 → 友好提示「该手机号/身份证号已注册」。
- 上传非图片或超大文件 → 拒绝并提示。
- 报考时存在活动报考 → 拒绝重复提交。
- 审核状态机:非 `pending_review` 状态的 approve/reject → 409/冲突提示。
## 测试策略(TDD)
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
- 注册手机号/身份证唯一性、校区校验、隐私协议必填。
- 登录成功/失败。
- 文件上传返回 asset id。
- 报考创建 + 重复报考拦截。
- 审核状态机(approve/reject/非法流转)。
- 导出筛选正确性。
前端(vitest):
- `nitApi` 各方法 URL/method/body 正确(沿用 `api.test.ts` 范式)。
- 注册/报考/审核关键页面交互(沿用 `UsageRecords.test.tsx` 的 react-test-renderer 范式)。
## 验证
- `cargo build -j 2`(避免 OOM)、`cargo test -p boosapi --lib nit`(后端)。
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
## 提交约定
NIT 工作全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分
(迁移 / service / 路由 / 前端 分开),沿用项目中文 commit 风格。
-42
View File
@@ -1,42 +0,0 @@
appId: me.cita777.boosapi.desktop
productName: Metapi
artifactName: ${name}-${version}-${os}-${arch}.${ext}
directories:
output: release
electronDist: node_modules/electron/dist
icon: build/desktop-icon.png
asar: false
files:
- dist/**/*
- drizzle/**/*
- package.json
mac:
target:
- dmg
- zip
category: public.app-category.developer-tools
hardenedRuntime: false
gatekeeperAssess: false
entitlementsInherit: null
win:
target:
- nsis
- zip
linux:
target:
- AppImage
- deb
- rpm
maintainer: cita-777 <cita-777@users.noreply.github.com>
publish:
provider: github
owner: cita-777
repo: boosapi
releaseType: release
publishAutoUpdate: true
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
afterPack: scripts/desktop/afterPack.mjs
afterSign: scripts/desktop/afterSign.mjs
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
read_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
workflow_id INTEGER NOT NULL,
user_id INTEGER,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
@@ -0,0 +1,51 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (
json_extract(metadata, '$.workspaceAsset.role'),
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (
json_extract(metadata, '$.workflowUi.type'),
created_at DESC
);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS user_recharge_records (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
option_id TEXT,
label TEXT,
title TEXT,
amount_usd REAL,
provider TEXT NOT NULL DEFAULT 'comfy_checkout',
checkout_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS user_recharge_records_public_id_unique
ON user_recharge_records (public_id);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx
ON user_recharge_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx
ON user_recharge_records (user_id, status);
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
recharge_record_id INTEGER,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'stripe',
provider_session_id TEXT,
provider_payment_id TEXT,
amount_usd REAL NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending',
checkout_url TEXT,
success_url TEXT,
cancel_url TEXT,
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
paid_at TEXT,
completed_at TEXT,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
ON payment_orders (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
ON payment_orders (provider, provider_session_id)
WHERE provider_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
ON payment_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
ON payment_orders (status);
+868
View File
@@ -0,0 +1,868 @@
-- BoosAPI initial schema
-- Generated from the existing SQLite database (hub.db)
-- This migration creates all tables, indexes, and constraints.
-- ============================================================
-- SITES
-- ============================================================
CREATE TABLE IF NOT EXISTS sites (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT,
proxy_url TEXT,
use_system_proxy BOOLEAN DEFAULT FALSE,
custom_headers TEXT,
external_checkin_url TEXT,
global_weight DOUBLE PRECISION DEFAULT 1,
post_refresh_probe_enabled BOOLEAN DEFAULT FALSE,
post_refresh_probe_model TEXT DEFAULT '',
post_refresh_probe_scope TEXT DEFAULT 'single',
post_refresh_probe_latency_threshold_ms BIGINT DEFAULT 0,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS sites_platform_url_unique
ON sites (platform, url);
CREATE INDEX IF NOT EXISTS sites_status_idx
ON sites (status);
-- ============================================================
-- USERS
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique
ON users (email);
CREATE INDEX IF NOT EXISTS users_status_idx
ON users (status);
-- ============================================================
-- TOKEN ROUTES
-- ============================================================
CREATE TABLE IF NOT EXISTS token_routes (
id BIGSERIAL PRIMARY KEY,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
display_name TEXT,
display_icon TEXT,
decision_snapshot TEXT,
decision_refreshed_at TEXT,
routing_strategy TEXT DEFAULT 'weighted',
route_mode TEXT DEFAULT 'pattern',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx
ON token_routes (model_pattern);
CREATE INDEX IF NOT EXISTS token_routes_enabled_idx
ON token_routes (enabled);
-- ============================================================
-- ACCOUNTS
-- ============================================================
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
username TEXT,
access_token TEXT NOT NULL,
api_token TEXT,
balance DOUBLE PRECISION DEFAULT 0,
balance_used DOUBLE PRECISION DEFAULT 0,
quota DOUBLE PRECISION DEFAULT 0,
unit_cost DOUBLE PRECISION,
value_score DOUBLE PRECISION DEFAULT 0,
status TEXT DEFAULT 'active',
checkin_enabled BOOLEAN DEFAULT TRUE,
last_checkin_at TEXT,
last_balance_refresh TEXT,
extra_config TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
oauth_provider TEXT,
oauth_account_key TEXT,
oauth_project_id TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS accounts_site_id_idx
ON accounts (site_id);
CREATE INDEX IF NOT EXISTS accounts_status_idx
ON accounts (status);
CREATE INDEX IF NOT EXISTS accounts_site_status_idx
ON accounts (site_id, status);
CREATE INDEX IF NOT EXISTS accounts_oauth_provider_idx
ON accounts (oauth_provider);
CREATE INDEX IF NOT EXISTS accounts_oauth_identity_idx
ON accounts (oauth_provider, oauth_account_key, oauth_project_id);
-- ============================================================
-- DOWNSTREAM API KEYS
-- ============================================================
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled BOOLEAN DEFAULT TRUE,
expires_at TEXT,
max_cost DOUBLE PRECISION,
used_cost DOUBLE PRECISION DEFAULT 0,
max_requests BIGINT,
used_requests BIGINT DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id BIGINT REFERENCES users(id),
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys (key);
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys (name);
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys (enabled);
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys (expires_at);
-- ============================================================
-- SITE DISABLED MODELS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_disabled_models (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique
ON site_disabled_models (site_id, model_name);
CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx
ON site_disabled_models (site_id);
-- ============================================================
-- SITE API ENDPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_api_endpoints (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
url TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
sort_order BIGINT DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until);
-- ============================================================
-- ACCOUNT TOKENS
-- ============================================================
CREATE TABLE IF NOT EXISTS account_tokens (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
source TEXT DEFAULT 'manual',
token_group TEXT,
value_status TEXT NOT NULL DEFAULT 'ready',
enabled BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx
ON account_tokens (account_id);
CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx
ON account_tokens (account_id, enabled);
CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx
ON account_tokens (enabled);
-- ============================================================
-- CHECKIN LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS checkin_logs (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
status TEXT NOT NULL,
message TEXT,
reward TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx
ON checkin_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx
ON checkin_logs (created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_status_idx
ON checkin_logs (status);
-- ============================================================
-- MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS model_availability (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
is_manual BOOLEAN DEFAULT FALSE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique
ON model_availability (account_id, model_name);
CREATE INDEX IF NOT EXISTS model_availability_account_available_idx
ON model_availability (account_id, available);
CREATE INDEX IF NOT EXISTS model_availability_model_name_idx
ON model_availability (model_name);
-- ============================================================
-- TOKEN MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS token_model_availability (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique
ON token_model_availability (token_id, model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx
ON token_model_availability (token_id, available);
CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx
ON token_model_availability (model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_available_idx
ON token_model_availability (available);
-- ============================================================
-- ROUTE CHANNELS
-- ============================================================
CREATE TABLE IF NOT EXISTS route_channels (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
token_id BIGINT,
oauth_route_unit_id BIGINT,
source_model TEXT,
priority BIGINT DEFAULT 0,
weight BIGINT DEFAULT 10,
enabled BOOLEAN DEFAULT TRUE,
manual_override BOOLEAN DEFAULT FALSE,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
FOREIGN KEY (route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS route_channels_route_id_idx
ON route_channels (route_id);
CREATE INDEX IF NOT EXISTS route_channels_account_id_idx
ON route_channels (account_id);
CREATE INDEX IF NOT EXISTS route_channels_token_id_idx
ON route_channels (token_id);
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx
ON route_channels (route_id, enabled);
CREATE INDEX IF NOT EXISTS route_channels_route_token_idx
ON route_channels (route_id, token_id);
CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id);
-- ============================================================
-- ROUTE GROUP SOURCES
-- ============================================================
CREATE TABLE IF NOT EXISTS route_group_sources (
id BIGSERIAL PRIMARY KEY,
group_route_id BIGINT NOT NULL,
source_route_id BIGINT NOT NULL,
FOREIGN KEY (group_route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (source_route_id) REFERENCES token_routes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique
ON route_group_sources (group_route_id, source_route_id);
CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx
ON route_group_sources (source_route_id);
-- ============================================================
-- PROXY LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_logs (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT,
channel_id BIGINT,
account_id BIGINT,
downstream_api_key_id BIGINT,
model_requested TEXT,
model_actual TEXT,
status TEXT,
http_status BIGINT,
latency_ms BIGINT,
first_byte_latency_ms BIGINT,
prompt_tokens BIGINT,
completion_tokens BIGINT,
total_tokens BIGINT,
estimated_cost DOUBLE PRECISION,
error_message TEXT,
retry_count BIGINT DEFAULT 0,
is_stream BIGINT,
client_family TEXT,
client_app_id TEXT,
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx
ON proxy_logs (created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx
ON proxy_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx
ON proxy_logs (status, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx
ON proxy_logs (model_actual, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx
ON proxy_logs (downstream_api_key_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at);
-- ============================================================
-- PROXY VIDEO TASKS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
upstream_video_id TEXT NOT NULL,
site_url TEXT NOT NULL,
token_value TEXT NOT NULL,
requested_model TEXT,
actual_model TEXT,
channel_id BIGINT,
account_id BIGINT,
status_snapshot TEXT,
upstream_response_meta TEXT,
last_upstream_status BIGINT,
last_polled_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks (public_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks (upstream_video_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx
ON proxy_video_tasks (created_at);
-- ============================================================
-- PROXY FILES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_files (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
purpose TEXT,
byte_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
content_base64 TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files (public_id);
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files (owner_type, owner_id, deleted_at);
-- ============================================================
-- PROXY DEBUG TRACES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id BIGSERIAL PRIMARY KEY,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id BIGINT,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id BIGINT,
selected_channel_id BIGINT,
selected_route_id BIGINT,
selected_account_id BIGINT,
selected_site_id BIGINT,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status BIGINT,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at);
-- ============================================================
-- PROXY DEBUG ATTEMPTS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id BIGSERIAL PRIMARY KEY,
trace_id BIGINT NOT NULL,
attempt_index BIGINT NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status BIGINT,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied BOOLEAN DEFAULT FALSE,
downgrade_decision BOOLEAN DEFAULT FALSE,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index);
CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at);
-- ============================================================
-- EVENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
"read" BOOLEAN DEFAULT FALSE,
related_id BIGINT,
related_type TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS events_read_created_at_idx
ON events ("read", created_at);
CREATE INDEX IF NOT EXISTS events_type_created_at_idx
ON events (type, created_at);
CREATE INDEX IF NOT EXISTS events_created_at_idx
ON events (created_at);
-- ============================================================
-- SITE ANNOUNCEMENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_announcements (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
platform TEXT NOT NULL,
source_key TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
source_url TEXT,
starts_at TEXT,
ends_at TEXT,
upstream_created_at TEXT,
upstream_updated_at TEXT,
first_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
last_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
read_at TEXT,
dismissed_at TEXT,
raw_payload TEXT,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_announcements_site_source_key_unique
ON site_announcements (site_id, source_key);
CREATE INDEX IF NOT EXISTS site_announcements_site_id_first_seen_at_idx
ON site_announcements (site_id, first_seen_at);
CREATE INDEX IF NOT EXISTS site_announcements_read_at_idx
ON site_announcements (read_at);
-- ============================================================
-- OAUTH ROUTE UNITS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_units (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider);
CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled);
-- ============================================================
-- OAUTH ROUTE UNIT MEMBERS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id BIGSERIAL PRIMARY KEY,
unit_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
sort_order BIGINT DEFAULT 0,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until);
-- ============================================================
-- SETTINGS
-- ============================================================
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
-- ============================================================
-- ADMIN SNAPSHOTS
-- ============================================================
CREATE TABLE IF NOT EXISTS admin_snapshots (
id BIGSERIAL PRIMARY KEY,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key);
CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at);
CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until);
-- ============================================================
-- ANALYTICS PROJECTION CHECKPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id BIGINT NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id BIGINT,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at);
-- ============================================================
-- MODEL DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS model_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
model TEXT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT model_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model);
CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day);
CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id);
CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model);
-- ============================================================
-- SITE DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id);
CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day);
CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id);
-- ============================================================
-- SITE HOUR USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_hour_usage (
id BIGSERIAL PRIMARY KEY,
bucket_start_utc TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_hour_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id);
CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc);
CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id);
@@ -0,0 +1,24 @@
-- Background tasks tracking
CREATE TABLE IF NOT EXISTS background_tasks (
id TEXT PRIMARY KEY NOT NULL,
task_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT DEFAULT '',
error TEXT,
dedupe_key TEXT,
success_title TEXT,
failure_title TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
result_json TEXT,
progress BIGINT DEFAULT 0,
progress_total BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS background_tasks_status_idx
ON background_tasks (status);
@@ -0,0 +1,53 @@
-- Organizations, members, and invitations for multi-tenant support
CREATE TABLE IF NOT EXISTS organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
ON organizations (slug);
CREATE INDEX IF NOT EXISTS org_plan_idx
ON organizations (plan);
CREATE TABLE IF NOT EXISTS organization_members (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
joined_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
ON organization_members (org_id, user_id);
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
ON organization_members (org_id);
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
ON organization_members (user_id);
CREATE TABLE IF NOT EXISTS invitations (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
invited_by BIGINT REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
ON invitations (token);
CREATE INDEX IF NOT EXISTS invitations_status_idx
ON invitations (status);
@@ -0,0 +1,37 @@
-- Usage tracking tables migrated from the Node.js backend.
CREATE TABLE IF NOT EXISTS usage_logs (
id BIGSERIAL PRIMARY KEY,
downstream_key_id BIGINT,
model TEXT,
prompt_tokens BIGINT DEFAULT 0,
completion_tokens BIGINT DEFAULT 0,
total_tokens BIGINT DEFAULT 0,
cost DOUBLE PRECISION DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_logs_downstream_key_created_idx
ON usage_logs (downstream_key_id, created_at);
CREATE INDEX IF NOT EXISTS usage_logs_created_idx
ON usage_logs (created_at);
CREATE TABLE IF NOT EXISTS usage_daily_snapshots (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
downstream_key_id BIGINT,
request_count BIGINT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_cost DOUBLE PRECISION NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_snapshots_day_idx
ON usage_daily_snapshots (local_day);
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
read_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template BIGINT NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
workflow_id BIGINT NOT NULL,
user_id BIGINT,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
@@ -0,0 +1,45 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count BIGINT NOT NULL DEFAULT 0,
local_file_count BIGINT NOT NULL DEFAULT 0,
total_size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-20
@@ -1,220 +0,0 @@
## Context
The frontend `/routes` page is end-to-end broken: `POST /api/routes/rebuild`
in `src/routes/admin/compat.rs:368-370` was a stub that returned
`{ success: true }` without touching `token_routes` / `route_channels`, and
`GET /api/models/token-candidates` was unregistered. Sites and accounts
configure cleanly, models populate `model_availability` via the existing
`POST /api/accounts/{id}/models/refresh` path, but nothing ever fanned that
out into route rows, so `TokenRoutes.tsx` rendered "暂无路由" no matter
what.
The proposal calls for three capabilities — `route-model-discovery`,
`route-rebuild-pipeline`, `route-candidate-api` — plus an auto-trigger
hook on account creation. This document records what was actually built
and the trade-offs we accepted relative to the proposal.
**Stakeholders:**
- Backend (this change): Rust handler authors maintaining `routes::admin`.
- Frontend (`TokenRoutes.tsx:411-412`, `:252`): consumes the rebuild
summary and the candidates payload — contracts are load-bearing.
- Operators: re-runs `POST /api/routes/rebuild` whenever model topology
shifts; relies on `manual_override = 1` channels surviving rebuilds.
**Constraints:**
- SQLite as the backing store; no schema migrations in this change.
- Existing `model_availability` table is the source of truth for model
presence — already filled by the unmodified
`POST /api/accounts/{id}/models/refresh` handler.
- `manual_override = 1` rows in `route_channels` are user-curated and
must survive every rebuild, untouched.
## Goals / Non-Goals
**Goals:**
- Turn `POST /api/routes/rebuild` into a real pipeline that reads
`model_availability` (filtered by `accounts.status` and
`site_disabled_models`) and writes `token_routes` + `route_channels`.
- Return a `RebuildSummary` whose camelCase fields match the
`TokenRoutes.tsx:411-412` toast contract (`createdRoutes`,
`createdChannels`).
- Register `GET /api/models/token-candidates` returning the four-field
shape `TokenRoutes.tsx:252` already calls.
- Fire a background rebuild after account creation so first-time users
don't have to click "重建" before the page works.
- Preserve `display_name` / `display_icon` / `routing_strategy` on
re-runs — manual edits on `token_routes` must not be clobbered.
**Non-Goals:**
- Live `/v1/models` discovery (`route-model-discovery` capability). The
proposal sketched per-platform fetchers (OpenAI / Anthropic / generic)
and a per-`(site, account)` cache. We did not build them. Reason: the
existing `model_availability` table is already filled by the refresh
endpoint, and "就用现成的" was the explicit call. Re-scoped as a
potential follow-up change.
- `modelsMissingTokenGroups` and `endpointTypesByModel` content. The
legacy Node handler computes these from a pricing-catalog fetcher and
a marketplace-cache file that don't exist in this codebase. Returning
`{}` keeps the contract intact; the frontend tolerates empty objects.
- New crates. We reused `sqlx`, `tokio`, and `axum` already in the tree.
## Decisions
### D1. Read from `model_availability` instead of fetching upstream
**Choice:** The rebuild reads `model_availability ⋈ accounts` (filtered
by `accounts.status = 'active'` and an `EXISTS NOT` against
`site_disabled_models`).
**Alternatives considered:**
- Build the per-platform fetcher trait the proposal described and call
it inline during rebuild. Rejected: doubles the scope, ads network
failure modes to a write-path that should be deterministic, and the
table is already kept current by the refresh endpoint.
- Fetch on-demand only when `model_availability` is empty for an
account. Rejected: hard to reason about — user can't tell whether a
rebuild used cached or fresh data.
**Rationale:** Separation of concerns. "Refresh models for an account"
is one job (already implemented); "rebuild routes from known models"
is another. Composing them via the table keeps each path testable.
### D2. Single transaction for the whole rebuild
**Choice:** `state.db.begin()` at the top, `tx.commit()` at the bottom.
Every read and write goes through `&mut *tx`.
**Alternatives:**
- Per-route transaction so partial failures still produce some routes.
Rejected: `token_routes` and `route_channels` get out of sync if we
abort halfway, which is exactly the bug we're fixing.
**Rationale:** SQLite write throughput is dominated by fsync, not lock
contention; one big transaction is faster than many small ones for the
sizes we expect (tens to low hundreds of accounts). Rebuild is rare
enough that holding the write lock for the duration is acceptable.
### D3. Reuse existing `token_routes` rows by `model_pattern`
**Choice:** Pre-load `(model_pattern → id)` into a `HashMap`, look up
each model name in the map, only `INSERT` when absent.
**Alternatives:**
- `INSERT ... ON CONFLICT DO NOTHING` and then `SELECT` the id back.
Rejected: requires a unique index on `model_pattern` we didn't want
to add as a side effect; also two round-trips per model.
- Truncate-and-rewrite. Rejected: drops user-edited
`display_name`/`display_icon`.
**Rationale:** O(1) lookup per model, one query upfront, manual edits
preserved.
### D4. `manual_override = 1` channels are sacred
**Choice:** When reconciling channels under a route, partition existing
rows into `manual_keys` (skipped entirely) and `auto_by_key`
(reconciled). The desired-set excludes any `(account, model)` covered
by a manual row, so we never insert an auto row that would shadow a
manual one.
**Rationale:** Operators set `manual_override = 1` precisely to opt out
of automation. Stomping it would erase intent.
### D5. `token_id` selection is best-effort, nullable-tolerant
**Choice:** When inserting a new `route_channels` row, pick the
account's default-flagged enabled token via
`ORDER BY COALESCE(is_default, 0) DESC, id ASC LIMIT 1`. If the account
has no tokens, store `NULL` and let the proxy layer's existing
"account-level token" fallback handle it at request time.
**Alternatives:**
- Skip channels for accounts with no tokens. Rejected: the proposal
surfaces these as `modelsWithoutToken` in the candidates API; they
should still appear in the route list so the operator sees them.
- Loop over every enabled token and create one channel per token.
Rejected: explodes `route_channels` and conflates "account routing"
with "token routing", which the schema treats as separate concerns.
### D6. Auto-rebuild via fire-and-forget `tokio::spawn`
**Choice:** `spawn_route_rebuild(state.clone())` after a successful
account insert in both the single-account and batch branches of
`POST /api/accounts`. Errors are logged at `warn`, the response is not
delayed.
**Alternatives:**
- Synchronous rebuild in the request path. Rejected: adds rebuild
latency to every account creation, including ones where models
haven't been refreshed yet (so the rebuild would be a no-op). Worst
case: a slow rebuild times out the HTTP request.
- Job queue / cron. Rejected: introduces infrastructure for a single
trigger; we already have the `/api/routes/rebuild` endpoint as the
manual fallback.
**Rationale:** The user's actual flow is "add account → refresh models
→ rebuild fires → routes appear". Auto-trigger handles step 3 without
blocking step 1.
### D7. Token-candidates returns two real fields, two empty objects
**Choice:** `models` and `modelsWithoutToken` are computed from
`model_availability ⋈ account_tokens ⋈ accounts ⋈ sites`.
`modelsMissingTokenGroups` and `endpointTypesByModel` are returned as
`{}`.
**Rationale:** Legacy Node behavior depends on
`fetchModelPricingCatalog` and `readModelsMarketplaceCache`, neither of
which has a Rust counterpart in this tree. Returning empty objects
satisfies the schema; the UI degrades gracefully (no missing-group
warnings, no per-model endpoint chips). When/if a marketplace cache
lands, this handler is the place to wire it in.
## Risks / Trade-offs
- **[Stale `model_availability`]** → A rebuild reflects whatever was in
the table at run time. Mitigation: the existing
`POST /api/accounts/{id}/models/refresh` is the contract for
freshness; the auto-rebuild fires after account create, and the
manual rebuild button covers re-runs.
- **[Long rebuild blocks writes]** → A single transaction holds the
write lock for the duration of the rebuild. For tens of accounts and
hundreds of models, expected runtime is sub-second. If the install
scales past low thousands of channels, switch D2 to per-route
transactions or move the rebuild to a background worker.
- **[Auto-rebuild noise]** → Fires on every account create, even when
`model_availability` for that account is still empty (rebuild becomes
a no-op writing nothing). Acceptable: cost is one short transaction
with empty desired sets. Logged at `info` so operators can see it.
- **[Empty `endpointTypesByModel`]** → Frontend can't show endpoint
chips. Cosmetic; not blocking. Documented as a non-goal so
follow-ups know where to plug the marketplace cache.
- **[`manual_override` semantics depend on `BOOLEAN` storage]** →
SQLite stores booleans as 0/1 integers. We decode via `Option<bool>`
and `unwrap_or(false)`; rows with `NULL` are treated as auto. Matches
schema default.
## Migration Plan
No schema changes. Deploy is a code-only swap:
1. Ship the new binary. The stub handler is replaced atomically; first
request after the swap exercises the new pipeline.
2. Run `POST /api/routes/rebuild` once after deploy to backfill
existing installs whose `model_availability` is already populated.
3. New account creations from this point on auto-rebuild.
**Rollback:** revert the binary. The new code never wrote to a column
or table that didn't already exist, and `manual_override` rows are
untouched, so rollback restores the stub without data loss.
## Open Questions
- Should `route-model-discovery` ship as a separate change, or is
"refresh endpoint fills the table" considered the permanent design?
Tracked outside this change for now.
- Do we want a debounce on the auto-rebuild when batch-creating many
accounts in quick succession? Current behavior fires once per
`POST /api/accounts` request (covers batch via the multi-token
branch), which is fine. If callers ever script many sequential
single-account POSTs, consider coalescing.
@@ -1,46 +0,0 @@
## Why
前端 `/routes` 页面打开后路由列表完全空白,根因是后端 `POST /api/routes/rebuild` 是个空 stub(`src/routes/admin/compat.rs:368-370` 只回 `success: true`),`/api/models/token-candidates` 端点根本没注册。即使站点和账号都配好了,`token_routes` / `route_channels` 这两张表也没人去填,前端只能渲染"暂无路由"。要让路由功能从配好账号到出现在列表里端到端可用,必须把"扫上游模型 → 过滤 → 写表"这条流水线在后端补齐。
## What Changes
- **新增**:多平台模型发现(OpenAI 兼容 / Anthropic / 自定义),从站点 `url + api_key + custom_headers` 拉 `/v1/models`,结果按 `(site_id, account_id)` 缓存并尊重 `site_disabled_models` 黑名单。
- **新增**:`POST /api/routes/rebuild` 真实实现 — 扫描所有 active 账号,按 `model_name` 聚合为一条 `token_routes` 记录,每个 `(account, source_model)` 落一条 `route_channels` 记录,多账号自动合并到同一 route 下。响应体包含 `rebuild.createdRoutes` / `createdChannels` 以满足 `TokenRoutes.tsx:411-412` 的现有契约。
- **新增**:`GET /api/models/token-candidates` 端点 — 返回 `{ models, modelsWithoutToken, modelsMissingTokenGroups, endpointTypesByModel }`,给前端"候选模型"面板和新建路由弹窗的下拉用(`TokenRoutes.tsx:252` 已经在调)。
- **新增**:每次"账号成功添加"后由 service 层异步触发一次 rebuild,避免用户必须手点重建按钮。
- **修改**:`compat.rs` 中保留的 `rebuild_routes` 占位 handler 删除,迁移到独立 service 模块。
- 不破坏已有 `GET /api/routes` / `/lite` / `/summary` 与 `route_channels` 写表 schema。
## Capabilities
### New Capabilities
- `route-model-discovery`: 站点级模型清单的获取、缓存与黑名单过滤。包含多平台 fetcher trait(OpenAI / Anthropic / 自定义 header)、`site_disabled_models` 应用、超时与重试策略、按账号 credential mode(`apikey` / oauth)选取认证头。
- `route-rebuild-pipeline`: 把发现到的模型合成路由的写库流水线。负责创建/更新 `token_routes`(按模型名聚合)、为每个 `(account, source_model)` 创建 `route_channels`、保留手工 `manual_override = 1` 通道、回滚已下线模型对应的通道。同步回填 `rebuild.createdRoutes` / `createdChannels` 字段。
- `route-candidate-api`: `GET /api/models/token-candidates` 端点。基于 `route-model-discovery` 的缓存返回前端需要的 4 个聚合字段,区分"账号有 token 能用"、"账号在但没 token"、"账号 token 缺组"三种状态。
### Modified Capabilities
无(`openspec/specs/` 当前为空,本仓库还没有沉淀过 spec,所以全部走新建)。
## Impact
**新增代码:**
- `src/services/model_discovery/` — 模型 fetcher trait + 三个 impl(openai / anthropic / generic),缓存层。
- `src/services/route_rebuild/` — 重建流水线。
- `src/routes/admin/model_candidates.rs` — `GET /api/models/token-candidates` handler。
- `src/routes/admin/compat.rs` — 把 `rebuild_routes` 改写为调用新 service。
**接触的表:**
- 读:`sites`, `accounts`, `account_tokens`, `site_disabled_models`
- 写:`token_routes`, `route_channels`
**外部依赖:**
- 复用现有 `reqwest` 客户端 + `tokio` runtime,不引入新 crate。
- 复用 `state.config.proxy.upstream_timeout_secs` 做模型拉取超时。
**前端:**无需改动。`TokenRoutes.tsx` 和 `api.ts` 当前调用契约保持不变,列表自然出现。
**风险:**
- 模型扫描走外网,慢账号会拖时间。流水线必须 per-site 并发 + 单 site 超时。
- `route_channels` 已有数据时不能粗暴 truncate,需保留 `manual_override = 1` 的人工通道。
@@ -1,88 +0,0 @@
## ADDED Requirements
### Requirement: `GET /api/models/token-candidates` is registered
The admin router SHALL register `GET /api/models/token-candidates`
behind the standard admin auth layer. The handler SHALL return a JSON
object with exactly four top-level keys: `models`,
`modelsWithoutToken`, `modelsMissingTokenGroups`, `endpointTypesByModel`.
#### Scenario: Endpoint returns 200 with the four-key shape
- **WHEN** an authenticated admin sends `GET /api/models/token-candidates`
- **THEN** the response is HTTP 200 with a JSON body containing keys
`models`, `modelsWithoutToken`, `modelsMissingTokenGroups`, and
`endpointTypesByModel` — no extra top-level keys, no missing keys
#### Scenario: Unauthenticated request is rejected
- **WHEN** a request without a valid admin token reaches the endpoint
- **THEN** the admin auth layer returns 401 before the handler runs
### Requirement: `models` lists every (model, token) candidate
The `models` field SHALL be an object whose keys are model names and
whose values are arrays of candidate descriptors. A `(model, token)`
combination SHALL appear in the `models` array for that model exactly
when ALL of the following hold:
- a row in `model_availability` reports `available = 1` for the model
on the token's account
- the token's `enabled` is truthy (NULL treated as enabled)
- the token's `value_status` is `'ready'` (NULL treated as ready)
- the account's `status = 'active'`
- the site's `status` is `'active'` (NULL treated as active)
Each candidate descriptor SHALL contain: `accountId`, `tokenId`,
`tokenName`, `isDefault` (boolean), `username`, `siteId`, `siteName`.
Within a single model's array, descriptors SHALL be deduplicated by
`tokenId`.
#### Scenario: Active token with available model appears
- **WHEN** account 5 (active, site active) has token 11 (enabled,
ready) and `model_availability(account=5, model='gpt-4')` is
available
- **THEN** `models['gpt-4']` includes a descriptor with
`accountId = 5, tokenId = 11`
#### Scenario: Disabled token is excluded
- **WHEN** account 5 has token 11 with `enabled = 0`
- **THEN** no descriptor with `tokenId = 11` appears in `models` for
any model
#### Scenario: Duplicate tokens in source data deduped
- **WHEN** the underlying join produces two rows for the same
`(model, tokenId)` pair (e.g., joins fan out)
- **THEN** the descriptor appears exactly once in
`models[model_name]`
### Requirement: `modelsWithoutToken` surfaces accounts missing tokens
The `modelsWithoutToken` field SHALL be an object keyed by model name.
For each `(account, model)` combination where the model is available
on the active account but no qualifying token (per the `models`
requirement) exists for that account, the handler SHALL include a
descriptor `{ accountId, username, siteId, siteName }` under that
model name. Descriptors SHALL be deduplicated by `accountId` within
each model's array.
#### Scenario: Account with available model but no enabled tokens
- **WHEN** account 8 reports `gpt-4` available but has zero rows in
`account_tokens` (or all rows are disabled)
- **THEN** `modelsWithoutToken['gpt-4']` includes a descriptor with
`accountId = 8`, and that account does NOT appear in `models['gpt-4']`
#### Scenario: Account with token does not appear here
- **WHEN** account 5 reports `gpt-4` available and has a usable token
- **THEN** account 5 does NOT appear in `modelsWithoutToken['gpt-4']`
### Requirement: `modelsMissingTokenGroups` and `endpointTypesByModel` are empty
The handler SHALL return `modelsMissingTokenGroups` and
`endpointTypesByModel` as empty JSON objects (`{}`). The frontend
contract permits empty objects and these fields SHALL NOT be
populated until a marketplace-cache or pricing-catalog source is
added to the backend.
#### Scenario: Both fields are empty objects
- **WHEN** the handler returns successfully under any state of the
database
- **THEN** `response.modelsMissingTokenGroups === {}` and
`response.endpointTypesByModel === {}`
@@ -1,153 +0,0 @@
## ADDED Requirements
### Requirement: Route rebuild reads from `model_availability`
The rebuild pipeline SHALL derive its working set from the
`model_availability` table joined to `accounts`, filtering out:
- accounts whose `status` is not `'active'`
- rows whose `available` is not `1` (treating `NULL` as not available)
- `(account, model)` combinations covered by `site_disabled_models`
for the account's `site_id`
The pipeline SHALL NOT make outbound HTTP requests to upstream platforms
during a rebuild. Refreshing `model_availability` is the responsibility
of the existing `POST /api/accounts/{id}/models/refresh` endpoint and
is out of scope for this requirement.
#### Scenario: Inactive account is excluded
- **WHEN** an account with `status = 'suspended'` has rows in
`model_availability`
- **THEN** no `token_routes` row is created on its behalf and no
`route_channels` row references its `account_id`
#### Scenario: Site-disabled model is excluded
- **WHEN** `site_disabled_models` contains `(site_id = 7, model_name = 'gpt-x')`
and an active account on site 7 has `model_availability` for `gpt-x`
- **THEN** the rebuild does not include that account in the channel set
for the `gpt-x` route
### Requirement: Rebuild aggregates one route per distinct model name
The rebuild SHALL ensure exactly one row exists in `token_routes` for
every distinct `model_name` in the filtered working set, keyed by
`model_pattern = model_name`. The rebuild SHALL reuse existing rows
when their `model_pattern` already matches; new rows SHALL be inserted
with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, and
`enabled = 1`.
The rebuild MUST NOT overwrite `display_name`, `display_icon`, or any
other fields on existing `token_routes` rows. Operators rely on these
fields being editable from the frontend without being clobbered on
re-runs.
#### Scenario: New model creates a new route
- **WHEN** model `claude-4-opus` appears in the working set and no
`token_routes` row has `model_pattern = 'claude-4-opus'`
- **THEN** the rebuild inserts a `token_routes` row with
`model_pattern = 'claude-4-opus'`, `routing_strategy = 'weighted'`,
`route_mode = 'pattern'`, `enabled = 1`
#### Scenario: Existing route is reused without overwrite
- **WHEN** a `token_routes` row exists with
`model_pattern = 'gpt-4'` and `display_name = 'GPT-4 Turbo (Custom)'`
- **THEN** the rebuild leaves `display_name` unchanged and reuses the
same `id` for channel reconciliation
### Requirement: Channel reconciliation preserves manual overrides
For each route, the rebuild SHALL reconcile `route_channels` rows
keyed by `(account_id, source_model)` against the desired set derived
from `model_availability`. Rows with `manual_override = 1` SHALL be
treated as immutable: not inserted, not updated, not deleted. Rows
with `manual_override = 0` (or `NULL`) SHALL be:
- inserted when missing from the existing set
- hard-deleted when no longer in the desired set
The desired set SHALL exclude any `(account, model)` combination
already covered by a `manual_override = 1` row, so the rebuild never
inserts an auto channel that shadows a manual one.
#### Scenario: Manual channel survives rebuild
- **WHEN** a `route_channels` row has `manual_override = 1` and its
`(account_id, source_model)` is no longer in the desired set
- **THEN** the row is left untouched and counted in
`RebuildSummary.skipped_manual_channels`
#### Scenario: Stale auto channel is deleted
- **WHEN** a `route_channels` row has `manual_override = 0`,
`account_id = 12`, `source_model = 'old-model'`, and no longer
appears in the desired set
- **THEN** the row is deleted and counted in
`RebuildSummary.deleted_channels`
#### Scenario: New auto channel is inserted with default token
- **WHEN** account 12 newly reports `gpt-5` available and has at least
one enabled `account_tokens` row
- **THEN** a new `route_channels` row is inserted with `route_id`
pointing at the `gpt-5` route, `account_id = 12`, the
default-flagged enabled token's id (or the lowest id when none is
default), `priority = 0`, `weight = 10`, `enabled = 1`,
`manual_override = 0`
#### Scenario: Account without tokens still gets a channel
- **WHEN** account 13 reports `gpt-5` available but has no enabled
`account_tokens` rows
- **THEN** a `route_channels` row is inserted with `token_id = NULL`,
relying on the proxy layer's account-level token fallback
### Requirement: Rebuild runs in a single transaction
The rebuild SHALL execute every read and write inside one database
transaction, committed at the end. A partial failure MUST NOT leave
`token_routes` and `route_channels` mutually inconsistent.
#### Scenario: Failure mid-rebuild rolls back
- **WHEN** an `INSERT` into `route_channels` fails partway through
the rebuild
- **THEN** the transaction aborts and no `token_routes` insert from
the same rebuild persists
### Requirement: Rebuild summary matches frontend contract
The rebuild SHALL return a `RebuildSummary` containing the fields
`createdRoutes`, `reusedRoutes`, `createdChannels`, `deletedChannels`,
`skippedManualChannels`, `scannedModels`, `scannedAccounts` (camelCase
when serialized). The `POST /api/routes/rebuild` handler SHALL embed
this object under the JSON key `rebuild`, alongside `success: true`.
`TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` and
`rebuild.createdChannels`; both MUST be present and SHALL count only
rows actually inserted by the current rebuild (not pre-existing rows
that were reused).
#### Scenario: Rebuild response shape
- **WHEN** the handler completes successfully
- **THEN** the response body is
`{ "success": true, "rebuild": { "createdRoutes": <n1>,
"reusedRoutes": <n2>, "createdChannels": <n3>,
"deletedChannels": <n4>, "skippedManualChannels": <n5>,
"scannedModels": <n6>, "scannedAccounts": <n7> } }`
#### Scenario: Reused route does not increment createdRoutes
- **WHEN** every model in the working set has a pre-existing
`token_routes` row
- **THEN** `createdRoutes = 0` and `reusedRoutes` equals the number of
distinct models scanned
### Requirement: Auto-rebuild after account creation
The system SHALL fire one route rebuild in the background after
`POST /api/accounts` returns success for at least one created
account. The HTTP response SHALL return without waiting for the
rebuild to finish. Errors from the background rebuild SHALL be logged
at `warn` level and SHALL NOT affect the response status or body.
#### Scenario: Single-account create triggers rebuild
- **WHEN** `POST /api/accounts` creates exactly one account
- **THEN** `spawn_route_rebuild` is invoked once with the cloned
`AppState` and the response returns immediately
#### Scenario: Batch-create with all failures does not trigger
- **WHEN** `POST /api/accounts` returns with `created.len() == 0` and
`failed.len() > 0`
- **THEN** `spawn_route_rebuild` is not invoked
@@ -1,52 +0,0 @@
## 1. Service module
- [x] 1.1 Create `src/services/route_rebuild.rs` with `RebuildSummary` struct (camelCase serde) and `pub async fn rebuild_routes(&AppState) -> AppResult<RebuildSummary>`
- [x] 1.2 Implement transactional pipeline: open `state.db.begin()`, do all reads/writes through `&mut *tx`, commit at the end
- [x] 1.3 Query `model_availability ⋈ accounts` filtering by `accounts.status = 'active'`, `available = 1`, and `NOT EXISTS site_disabled_models`
- [x] 1.4 Group rows by model name into `HashMap<String, Vec<i64>>` and pre-load existing `(model_pattern, id)` from `token_routes` for reuse
- [x] 1.5 Insert new `token_routes` rows with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, `enabled = 1`; never overwrite existing display fields
- [x] 1.6 Per route, partition existing `route_channels` rows into `manual_keys` (skip) vs `auto_by_key` (reconcile)
- [x] 1.7 Insert missing auto channels with default-token pick (`ORDER BY COALESCE(is_default,0) DESC, id ASC LIMIT 1`); allow `token_id = NULL` when no tokens exist
- [x] 1.8 Hard-delete auto channels that fell out of the desired set; count into `RebuildSummary.deleted_channels`
- [x] 1.9 Expose the module from `src/services/mod.rs` (`pub mod route_rebuild;`)
## 2. Rebuild HTTP handler
- [x] 2.1 Replace the stub `rebuild_routes()` in `src/routes/admin/compat.rs:368` with a handler that takes `State<AppState>` and calls `services::route_rebuild::rebuild_routes`
- [x] 2.2 Return `Json(json!({ "success": true, "rebuild": summary }))` so `TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` / `createdChannels`
## 3. Token-candidates handler
- [x] 3.1 Create `src/routes/admin/model_candidates.rs` with `pub async fn token_candidates(State<AppState>) -> AppResult<Json<Value>>`
- [x] 3.2 Query `(model, account, token, site)` join filtering active sites/accounts, enabled tokens, `value_status = 'ready'`; build `models` map deduped by `tokenId` per model
- [x] 3.3 Query `(model, account, site)` join for available models on active accounts; diff against the covered set to populate `modelsWithoutToken` deduped by `accountId` per model
- [x] 3.4 Return all four keys: `models`, `modelsWithoutToken`, `modelsMissingTokenGroups: {}`, `endpointTypesByModel: {}`
- [x] 3.5 Mount `GET /api/models/token-candidates` under the admin auth layer in `src/routes/admin/mod.rs`
## 4. Auto-rebuild after account create
- [x] 4.1 Add `fn spawn_route_rebuild(state: AppState)` helper at the bottom of `src/routes/admin/accounts.rs` that `tokio::spawn`s the rebuild and logs the summary at `info`, errors at `warn`
- [x] 4.2 Call `spawn_route_rebuild(state.clone())` in the single-account success branch of `create_account`
- [x] 4.3 Call `spawn_route_rebuild(state.clone())` in the batch branch when `created.len() > 0`; skip when no accounts were created
## 5. Verification
- [x] 5.1 `cargo build` clean — no warnings on the new code
- [x] 5.2 Confirm `compat.rs:19` route mount still points at the (now non-stub) `rebuild_routes` handler
- [x] 5.3 Confirm `accounts.rs:228` (batch) and `:248` (single) success paths both fire the auto-rebuild
<!--
Descoped (not implemented in this change, see design.md "Non-Goals"):
- `route-model-discovery` capability (per-platform `/v1/models` fetcher
trait with per-(site, account) cache). Deferred per "就用现成的"
instruction; existing `POST /api/accounts/{id}/models/refresh` fills
`model_availability`, so the pipeline reads from there.
- Marketplace pricing-catalog source for `endpointTypesByModel` and
`modelsMissingTokenGroups`. Returning `{}` is the documented contract;
populate when a Rust-side cache lands.
These are tracked as potential follow-up changes, not work owed by this
change.
-->
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-19
@@ -1,143 +0,0 @@
## Context
`src/routes/comfyui_agent.rs` 第 79-91 行的 `upstream_config()` 用 `BOOSAPI_UPSTREAM_BASE` env(默认 `https://api.boos.lat/v1`)+ `BOOSAPI_UPSTREAM_KEY` env(默认 `state.config.auth.proxy_token`)拼出 base/key,三个 handler(chat 第 351 行、images 第 274 行、audio 第 311 行)都直接用 `reqwest::Client` post 到这个外部地址。这导致:
- ComfyUI Agent 流量绕过本进程已实现的 token_router / capability / proxy_logs / 计费。
- 部署必须保证 `api.boos.lat` 可达,否则功能不可用。
- 本服务自己就是 OpenAI 兼容网关,"BoosAPI 调远端 BoosAPI" 是反直觉的部署形态。
数据库现状(`migrations/20260516000001_initial.sql`):
- `downstream_api_keys` 表已存在,`user_id INTEGER REFERENCES users(id)` 可空(无 NOT NULL 约束)。
- `key` 列上有 `downstream_api_keys_key_unique` 唯一索引。
- `settings` 表是 `(key TEXT PRIMARY KEY, value TEXT)`,可放任意键值。
- `find_downstream_key_by_token` 已能按明文 key 查(`src/db/queries/mod.rs:31`)。
约束:
- 保留现栈(axum + sqlx + reqwest),不引入新依赖。
- 启动顺序在 `src/main.rs`:load config → `db::create_pool` → migrate → `AppState::new` → `state.comfyui.start()` → `build_router`。Builtin key 引导必须在 migrate 之后、router 之前。
- 不破坏既有 `BOOSAPI_UPSTREAM_*` env 在调试场景下的语义。
## Goals / Non-Goals
**Goals:**
- ComfyUI Agent 默认调用 `http://127.0.0.1:{server.port}/v1/{chat/completions, images/generations, audio/speech}`。
- 引入名为 `comfyui-agent` 的内置下游 key,明文持久化到 `settings` 表,重启后保持稳定(不每次换 key)。
- 三类调用(chat / images / audio)行为一致:同一 base + 同一 Bearer key + 同一回退策略。
- `/api/comfyui-agent/status` 暴露 `mode` 与 `keyName`,供前端排障使用。
- 启动时不依赖外部网络。
**Non-Goals:**
- 不为内置 key 增加新的管理界面字段(沿用现有 Downstream Keys 页面,仅做"不可删除"标记)。
- 不重写 token_router / capability,本次只是消费它们。
- 不做请求级动态 key 切换(每次请求都用同一个 builtin key)。
- 不改造 SYSTEM_PROMPT、CHARACTER/SCENE 解析、SSE 转发等业务逻辑。
- 不动 `comfyui_process.rs`(嵌入式 ComfyUI 二进制管理)。
## Decisions
### 1. 内置 key 用现有 `downstream_api_keys` 表 + `user_id IS NULL` 表示系统级
**为什么不另建表**:
- 想要让 builtin key 与普通用户 key 共享同一套计费、限额、proxy_logs 关联(`usage_logs.downstream_key_id REFERENCES downstream_api_keys(id)` 已存在)。
- `user_id` 已经是可空 `INTEGER REFERENCES users(id)`,不需要 schema 改动。
**Alternative 考虑**:
- 单独建一张 `system_api_keys` 表 → 拒绝,要在 `usage_logs` 里加 `system_key_id` 列、改 query,复杂度不成比例。
- 复用 `config.auth.proxy_token` 当 Bearer → 拒绝,proxy_token 与 admin auth_token 是不同语义且不在 `downstream_api_keys` 中,无法计费。
### 2. 密钥明文写到 `settings` 表,key = `builtin_key.comfyui_agent.secret`
启动引导逻辑(`src/services/builtin_keys.rs::ensure_comfyui_agent_key`):
1. `SELECT value FROM settings WHERE key = 'builtin_key.comfyui_agent.secret'`。
2. 若有:在 `downstream_api_keys` 中 `find_downstream_key_by_token(value)`,命中则记 id;未命中则进入步骤 3。
3. 若无(或步骤 2 未命中):生成一个 `sk-comfyui-{uuid_v4_no_hyphen}` 形式的 key,先 `INSERT INTO downstream_api_keys(name='comfyui-agent', key=?, user_id=NULL, enabled=1, description='Built-in key for ComfyUI Agent')`,再 `INSERT OR REPLACE INTO settings(key, value)`。
4. 返回 `(id, secret)` 给 AppState 缓存(`AppState.builtin_keys.comfyui_agent: (i64, String)`)。
整个流程在事务中执行,确保 `settings` 与 `downstream_api_keys` 一致。
**为什么不每次启动重新生成**:
- 每次换 key 会让历史 `proxy_logs` / `usage_logs` 仍然引用旧 id,但客户端无法用旧 key 验证。
- 重启后 key 稳定也方便用户在前端 Downstream Keys 页面持续看到同一行。
**为什么不 hash 存储**:
- 后端调用方就是后端自己,需要在内存中直接持有明文当 Bearer 用;hash 反而要额外解密通道。
- 现有所有 downstream_api_keys 都是明文存储(`find_downstream_key_by_token` 直接 `WHERE key = ?`),保持一致。
### 3. base 解析优先级:env override → 本地拼接
```rust
fn upstream_config(state: &AppState) -> (String, String, AgentMode) {
if let Some(env_base) = std::env::var("BOOSAPI_UPSTREAM_BASE").ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty())
{
let key = std::env::var("BOOSAPI_UPSTREAM_KEY").ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| state.builtin_keys.comfyui_agent.1.clone());
return (env_base, key, AgentMode::FallbackRemote);
}
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1"
} else {
&state.config.server.host
};
let base = format!("http://{}:{}/v1", host, state.config.server.port);
let key = state.builtin_keys.comfyui_agent.1.clone();
(base, key, AgentMode::Local)
}
```
**为什么默认本地而不是默认 env**:用户希望 BoosAPI 自包含运行;env 仅作为"我知道我在干什么"的回退。
**为什么不 await async client**:base 解析是纯字符串,每次请求重新算成本可忽略(每次请求最多 3 次调用);不缓存避免与 env 动态变化语义冲突。
### 4. status 端点扩字段,不改路径
`GET /api/comfyui-agent/status` 响应增加:
```json
{
"success": true,
"model": "...",
"upstreamBase": "<resolved>",
"mode": "local" | "fallback-remote",
"keyName": "comfyui-agent",
"comfyui": {...}
}
```
前端可在标题栏显示 mode 徽标,区分本地 / 回退。
### 5. 模型名沿用现配置 `state.config.comfyui.agent_model`
不改 chat 调用的 `model` 字段。如果本地 `/v1/chat/completions` 没有这个模型,会返回 404,让用户去 token_routes 配置;这是正确的失败路径,比 silently fallback 好。
images 仍用 `gpt-image-2`、audio 仍用 `tts-1` —— 与现状一致,本地 token_router 命中即用。
## Risks / Trade-offs
- **[Risk] `127.0.0.1:{port}` 在容器中不可达**:当 server bind `0.0.0.0`,部分容器内 `127.0.0.1` 仍可达 host 进程;但若用户在 Dockerfile 里禁用 loopback,agent 会失败。 → Mitigation:保留 env 回退;status mode 字段帮助排障;文档明确说明。
- **[Risk] settings 表在前端被 `app_settings` admin API 暴露**:明文密钥泄漏给 admin。 → Mitigation:在 settings 读写层加 `builtin_key.*` 前缀过滤,列出 settings 时 mask;本次实施任务覆盖该过滤。
- **[Risk] 前端 Downstream Keys 页面显示 builtin key 的明文**:现有页面会列出明文。 → Mitigation:在 `routes/user_api_keys.rs`(admin list 入口)里把 `name='comfyui-agent' AND user_id IS NULL` 的 key 标记 `system: true` 并打码 key 显示后 4 位;禁止 DELETE/PATCH。
- **[Risk] builtin key 被人为禁用**:admin 在 UI 把 enabled 改为 false,agent 会立即 401。 → Mitigation:启动引导时若发现 enabled=0,主动设回 1 并发 warning;admin API 拒绝对 system key 设 enabled=0。
- **[Risk] 引导事务失败导致 settings 记录与 downstream_api_keys 不一致**:例如 settings 插入后进程崩溃,重启时 token 还在 settings 但表里没行。 → Mitigation:步骤 2 已经包含"settings 有值但表没行"的回填路径;幂等。
- **[Trade-off] 仍保留 `BOOSAPI_UPSTREAM_*` env**:增加一个分支,但提供排障逃生口;用户态文档明确"仅排障使用"。
## Migration Plan
1. **第 1 步**:加 `src/services/builtin_keys.rs` 模块,包含 `ensure_comfyui_agent_key(db: &SqlitePool) -> AppResult<(i64, String)>` 与单测(重复调用幂等、settings 有值但表无行的回填、表内有但 settings 无的恢复)。
2. **第 2 步**:在 `AppState` 增加 `builtin_keys: BuiltinKeys { comfyui_agent: (i64, String) }`;`AppState::new` 改为 async(或在 `main.rs` 中先 ensure 再传入)。
3. **第 3 步**:重写 `comfyui_agent.rs::upstream_config`,新增 `AgentMode` 枚举;三个 handler 改用新返回值;status 多返回 `mode` / `keyName`。
4. **第 4 步**:在 admin 层(`routes/user_api_keys.rs` 或 admin keys 路由)对 `name='comfyui-agent' AND user_id IS NULL` 的行加保护:拒绝 DELETE / 拒绝 enabled=0;list 响应里标 `system=true`。
5. **第 5 步**:在 settings 暴露层(确认有没有 generic settings list API)里过滤 / mask `builtin_key.*` 前缀。
6. **第 6 步**:手测三类调用 + status mode 切换;`AGENTS.md` / `README.md` 更新。
回滚:每步独立 commit;如步骤 3 出问题,revert 即可恢复"默认远端"行为。步骤 1-2 是新增代码,无需回滚。
## Open Questions
- 是否有 generic `/api/admin/settings` 路由会列出 settings 表全部行?需要 `rg "FROM settings"` 在实施第一步前确认;若有,第 5 步改为对该路由加前缀过滤。
- token_router 在没有为 `agent_model` / `gpt-image-2` / `tts-1` 配置路由时的失败模式是 404 还是 500?影响错误文案;实施前在本地起一个空配置环境跑一次。
- builtin key 是否需要支持轮换?本次不做,Open Question 记录后续若有需求再开 spec。
@@ -1,41 +0,0 @@
## Why
`src/routes/comfyui_agent.rs` 目前把 chat / images / audio 三类上游调用全部硬编码走 `https://api.boos.lat/v1`(可被 `BOOSAPI_UPSTREAM_BASE` env 覆盖),完全绕过了本进程已经实现的代理层(token_router、capability、proxy_logs、计费)。这意味着:ComfyUI Agent 的流量看不见、算不上账、用不到本地的智能路由;而本服务自己就是一个 OpenAI 兼容网关,本地 `/v1` 已经能提供 chat/images/audio 三条能力。再继续走外部上游既绕过自家路由又造成"BoosAPI 调 BoosAPI"的奇怪部署形态。
## What Changes
- ComfyUI Agent 默认改为调用本进程自身的 `http://127.0.0.1:{server.port}/v1/*`,复用 token_router 与计费体系。
- 引入一个**内置下游 API key**(name = `comfyui-agent`,user_id 留空表示系统级),仅 ComfyUI Agent 使用,单独计费/限额;启动时若不存在则自动创建并把明文写入 `app_settings`,重启后复用。
- chat / images / audio 三类调用都切到本地 `/v1`,使用上述内置 key 作 Bearer。
- 保留 `BOOSAPI_UPSTREAM_BASE` 为**可选回退**:仅当显式设置且非空时,Agent 才直连远端(用于本地路由不可用的排障场景),并在 `/api/comfyui-agent/status` 响应中明确暴露当前生效的 base 与是否为回退。
- `/api/comfyui-agent/status` 新增字段 `keyName`(始终是 `comfyui-agent`)、`mode`(`local` 或 `fallback-remote`)。
- **BREAKING**(运维侧):当未设置 `BOOSAPI_UPSTREAM_BASE` 时,部署不再需要外部 `api.boos.lat` 网络可达;但同时要求本进程 `server.port` 已绑定到回环且能被 Agent 自身访问。
## Capabilities
### New Capabilities
- `comfyui-agent-local-routing`: ComfyUI Agent 通过本进程 `/v1` 调用 chat / images / audio,使用专属内置下游 key 计费,并提供 `BOOSAPI_UPSTREAM_BASE` 远端回退。
- `builtin-downstream-key`: 系统级(user_id IS NULL)内置下游 API key 的生命周期管理:启动时确保存在、密钥持久化在 `app_settings`、可在 admin API 中可见但不可删除。
### Modified Capabilities
<!-- openspec/specs/ 目前为空,没有既有 spec 受影响。 -->
## Impact
- **后端代码**
- `src/routes/comfyui_agent.rs`:`upstream_config()` 改为从 AppState 拼本地 base + 内置 key;保留 env 回退分支并在响应里暴露 `mode`。
- `src/services/`(或新建 `src/services/builtin_keys.rs`):启动时确保 `comfyui-agent` 内置 key 存在,明文落 `app_settings`。
- `src/db/queries/`:新增 `find_or_create_builtin_downstream_key(name)` / `read_builtin_key_secret(name)`。
- `src/lib.rs` 的 `AppState::new` 之后、`main.rs` 启动序列中调用 builtin key 引导。
- **数据库**
- 复用既有 `downstream_api_keys` 表(user_id 设为 NULL 表示系统级);可能需要一条迁移把 `user_id` 列改为可空(如果已是可空则跳过)。
- 复用既有 `app_settings` / `settings` 键值表存放生成的明文密钥(避免每次重启换 key 让历史日志关联断裂)。
- **观测**
- ComfyUI Agent 流量从此出现在 `proxy_logs` 中,按 key=`comfyui-agent` 可在管理面板筛出。
- **配置 / 文档**
- `AGENTS.md` 与 `README.md` 的"ComfyUI Agent"段落需要更新:默认本地、env 回退、密钥位置。
- **测试**
- 新增端到端测试:mock 一个 `/v1/chat/completions` 路径返回固定 SSE,验证 Agent 命中本地。
- 新增单测:builtin key 引导 idempotent;status 在 env 设置/未设置时分别返回 `local` / `fallback-remote`。
@@ -1,82 +0,0 @@
## ADDED Requirements
### Requirement: 启动时确保内置 ComfyUI Agent key 存在
服务启动序列 SHALL 在数据库迁移完成之后、HTTP 路由对外提供之前,调用 `ensure_comfyui_agent_key`,确保 `downstream_api_keys` 表中存在唯一一行 `name='comfyui-agent' AND user_id IS NULL`,且其明文 key 同步保存在 `settings` 表的 `builtin_key.comfyui_agent.secret` 键。该流程 MUST 幂等。
#### Scenario: 全新数据库首次启动
- **WHEN** 启动时数据库无任何 `name='comfyui-agent'` 行,且 settings 表无 `builtin_key.comfyui_agent.secret`
- **THEN** 启动结束后两处都存在;密钥形如 `sk-comfyui-<32 位十六进制或等价 uuid>`;`enabled=1`
#### Scenario: 已有 settings 但缺少表记录(崩溃恢复)
- **WHEN** settings 表存在 `builtin_key.comfyui_agent.secret = "sk-comfyui-xxx"`,但 `downstream_api_keys` 中没有对应行
- **THEN** 引导逻辑用 settings 中的明文回填一行 `downstream_api_keys`,不重新生成密钥
#### Scenario: 已有表记录但缺少 settings
- **WHEN** `downstream_api_keys` 中存在 `name='comfyui-agent' AND user_id IS NULL` 行 key=`sk-comfyui-yyy`,但 settings 中无 `builtin_key.comfyui_agent.secret`
- **THEN** 引导逻辑把表中的明文写回 settings;密钥保持原值
#### Scenario: 重复启动不变更密钥
- **WHEN** 同一数据库连续启动两次
- **THEN** 第二次启动后 settings 与表中的 key 与第一次完全一致
#### Scenario: 引导期间发现 enabled=0 自动恢复
- **WHEN** 启动时发现已有行 `enabled=0`
- **THEN** 引导逻辑把该行 `enabled` 设为 1,并通过 `tracing::warn!` 输出一条提示
### Requirement: 系统级 key 在管理 API 中受保护
任何 admin 接口 SHALL 拒绝对 `name='comfyui-agent' AND user_id IS NULL` 的下游 key 执行删除(`DELETE`)或将 `enabled` 设为 false。list / get 类响应 MUST 把这一行标记为 `system: true`,并 mask 明文 key(仅暴露后 4 位)。
#### Scenario: DELETE 被拒
- **WHEN** admin 调用 `DELETE` 删除该 key 对应 id
- **THEN** 返回 403 / 409 类错误(取实现选择),message 含 `system key` 关键字;数据库中的行不变
#### Scenario: 禁用被拒
- **WHEN** admin PATCH/PUT 设 `enabled=false`
- **THEN** 返回拒绝错误,行 `enabled` 保持 1
#### Scenario: 列表响应打码
- **WHEN** admin GET 列出下游 key
- **THEN** 该行字段 `key` 形如 `••••••••<后4位>`,并附 `system: true`
#### Scenario: 普通 key 不受影响
- **WHEN** admin 操作其他普通 user_id 非空的 key
- **THEN** DELETE / PATCH / list 行为不受系统 key 保护逻辑影响
### Requirement: settings 中的内置密钥不暴露给 admin 列表
如果存在 admin 接口列出 `settings` 表内容,该接口 SHALL 过滤所有以 `builtin_key.` 为前缀的 key;按 key 单独读取时,对该前缀返回 404 或带占位符 `"••••"`,不返回实际明文。
#### Scenario: 列表过滤前缀
- **WHEN** admin GET `/api/admin/settings`(若存在)
- **THEN** 响应中不出现任何 key 以 `builtin_key.` 开头的条目
#### Scenario: 单读返回占位
- **WHEN** admin GET `/api/admin/settings/builtin_key.comfyui_agent.secret`(若存在)
- **THEN** 返回 404 或 `value: "••••"`,不返回真实明文
### Requirement: 内置 key 用 user_id IS NULL 与普通 key 区分
内置下游 key 在 `downstream_api_keys` 表中 SHALL 满足 `user_id IS NULL`;任何普通用户创建的 key MUST 写入非空 `user_id`,确保 `name='comfyui-agent' AND user_id IS NULL` 的查询能稳定匹配仅一行。
#### Scenario: 普通用户不能创建同名 key 占用系统标识
- **WHEN** 普通用户调用创建 key 接口,name 设为 `comfyui-agent`
- **THEN** 该 key 仍写入非空 user_id;系统级查询不会返回该行
#### Scenario: 引导查询仅匹配系统行
- **WHEN** 数据库中同时存在 user_id=5 且 name='comfyui-agent' 的普通 key 与 user_id IS NULL 的系统 key
- **THEN** `ensure_comfyui_agent_key` 仅作用于 user_id IS NULL 的那一行
@@ -1,81 +0,0 @@
## ADDED Requirements
### Requirement: ComfyUI Agent 默认调用本进程 /v1
ComfyUI Agent 的三类上游调用(`/v1/chat/completions`、`/v1/images/generations`、`/v1/audio/speech`)SHALL 默认指向本进程自身,base 由 `state.config.server.host` 与 `state.config.server.port` 拼出(host 为 `0.0.0.0` 或 `::` 时改写为 `127.0.0.1`)。Agent MUST NOT 在默认配置下访问任何外部主机。
#### Scenario: 默认 base 指向本机回环
- **WHEN** `BOOSAPI_UPSTREAM_BASE` 未设置且 `server.port = 4000`、`server.host = 0.0.0.0`
- **THEN** chat / images / audio 三类调用的 base 解析为 `http://127.0.0.1:4000/v1`
#### Scenario: 显式 host 被保留
- **WHEN** `server.host = 10.0.0.5`、`server.port = 4000` 且未设置 env
- **THEN** base 解析为 `http://10.0.0.5:4000/v1`,不被改写为回环
#### Scenario: 三类调用使用同一 base
- **WHEN** Agent 在同一次会话中触发 chat、images、audio 三类调用
- **THEN** 三次请求的 base 完全一致(来自同一 `upstream_config` 调用结果)
### Requirement: env 显式设置时回退远端
当且仅当 `BOOSAPI_UPSTREAM_BASE` 设置为非空字符串时,Agent SHALL 改用该 env 值作为 base,并使用 `BOOSAPI_UPSTREAM_KEY`(若设置且非空)或内置 key 的明文作为 Bearer。该分支以下统称 `fallback-remote` 模式。
#### Scenario: env 非空触发回退
- **WHEN** `BOOSAPI_UPSTREAM_BASE = "https://api.boos.lat/v1"`
- **THEN** 三类调用的 base 解析为 `https://api.boos.lat/v1`,且 `mode = "fallback-remote"`
#### Scenario: env 为空字符串视同未设置
- **WHEN** `BOOSAPI_UPSTREAM_BASE = ""` 或 `BOOSAPI_UPSTREAM_BASE = " "`
- **THEN** base 解析回本机回环,`mode = "local"`
#### Scenario: env key 缺省时复用内置 key
- **WHEN** 设置了 `BOOSAPI_UPSTREAM_BASE` 但未设置 `BOOSAPI_UPSTREAM_KEY`
- **THEN** Bearer 使用 `comfyui-agent` 内置 key 的明文,不退化为 `proxy_token`
### Requirement: 本地调用使用内置下游 key 计费
`mode = "local"` 时,Agent SHALL 在请求头 `Authorization` 中携带 `Bearer <comfyui-agent 内置 key 明文>`。该 key MUST 与本进程 `downstream_api_keys` 表中 `name='comfyui-agent' AND user_id IS NULL` 的行一致,使所有 Agent 流量在 `usage_logs` 中以该 key 归集。
#### Scenario: chat 请求携带内置 key
- **WHEN** Agent 在本地模式下发起 `/v1/chat/completions`
- **THEN** 请求头 `Authorization` 形如 `Bearer sk-comfyui-...`,且该值等于 settings 表中 `builtin_key.comfyui_agent.secret` 的值
#### Scenario: usage_logs 归集到内置 key
- **WHEN** 一次本地模式下的 chat 请求成功
- **THEN** `usage_logs` 中存在一条记录,`downstream_key_id` 等于内置 key 在 `downstream_api_keys` 中的 id
### Requirement: status 端点暴露生效模式
`GET /api/comfyui-agent/status` 响应 SHALL 在原有字段基础上增加 `mode: "local" | "fallback-remote"` 与 `keyName: "comfyui-agent"` 两个字段;`upstreamBase` 字段 MUST 反映当前实际生效的 base(已 trim 末尾斜杠)。
#### Scenario: 默认配置返回 local
- **WHEN** 客户端在未设置 env 的情况下 GET `/api/comfyui-agent/status`
- **THEN** 响应包含 `"mode": "local"`、`"keyName": "comfyui-agent"`、`upstreamBase` 形如 `http://127.0.0.1:<port>/v1`
#### Scenario: env 设置时返回 fallback-remote
- **WHEN** `BOOSAPI_UPSTREAM_BASE = "https://api.boos.lat/v1"` 时 GET 该端点
- **THEN** 响应 `"mode": "fallback-remote"`、`upstreamBase = "https://api.boos.lat/v1"`
#### Scenario: 鉴权失败仍不暴露密钥
- **WHEN** 客户端用错误的 admin token 请求
- **THEN** 返回 401,且响应体不包含 `keyName` / `upstreamBase` 任一字段
### Requirement: 本地路由失败必须如实返回
当本地 `/v1` 没有配置对应模型路由(chat 的 `agent_model`、images 的 `gpt-image-2`、audio 的 `tts-1`),Agent SHALL 把上游返回的错误状态码 / 文案如实通过 SSE `error` 事件转发给前端,MUST NOT 在收到 4xx 时自动切到 `fallback-remote`。
#### Scenario: 本地缺少 agent_model 路由
- **WHEN** 本地模式下发起 chat,token_router 返回 404
- **THEN** SSE 流中出现 `error` 事件,message 包含上游 404 内容;后续请求仍走本地,不自动切远端
@@ -1,85 +0,0 @@
## 1. 内置 key 引导逻辑
- [x] 1.1 新建 `src/services/builtin_keys.rs`,定义 `pub struct BuiltinKeys { pub comfyui_agent: (i64, String) }` 与 `pub async fn ensure_comfyui_agent_key(db: &SqlitePool) -> AppResult<(i64, String)>`
- [x] 1.2 实现 `ensure_comfyui_agent_key`:在事务中执行查 settings → 查 downstream_api_keys → 缺什么补什么,确保两边一致;密钥格式 `sk-comfyui-{uuid_v4 去横线}`,settings key = `builtin_key.comfyui_agent.secret`
- [x] 1.3 引导逻辑发现 `enabled=0` 时主动设回 1,并 `tracing::warn!("comfyui-agent builtin key was disabled; re-enabling")`
- [x] 1.4 在 `src/services/mod.rs` 中 `pub mod builtin_keys;`
- [x] 1.5 单测:用 `tempfile::NamedTempFile` + 内存 sqlite 覆盖 5 个场景(首次启动、settings 有表无、表有 settings 无、重复启动密钥不变、enabled=0 自动恢复)
## 2. AppState 接入
- [x] 2.1 在 `src/lib.rs` 中给 `AppState` 增加 `pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>`
- [x] 2.2 修改 `AppState::new` 签名为 async,或在 `main.rs` 中改为先 `ensure_comfyui_agent_key` 拿到结果再调 `AppState::new(config, pool, builtin_keys)`
- [x] 2.3 调整 `src/main.rs` 启动序列:migrate 之后、`AppState::new` 之前调用引导;任何 builtin key 相关错误用 `anyhow::Context` 包裹后向上传播,不让服务在没有 builtin key 时启动
- [x] 2.4 跑 `cargo build`、`cargo test --lib services::builtin_keys`,确认无回归
## 3. comfyui_agent.rs 切换调用目标
- [x] 3.1 在 `src/routes/comfyui_agent.rs` 顶部加 `enum AgentMode { Local, FallbackRemote }`,派生 Serialize 以便嵌入 JSON
- [x] 3.2 重写 `upstream_config(state) -> (String, String, AgentMode)`:env 非空(trim 后)走 fallback-remote 分支并把 `BOOSAPI_UPSTREAM_KEY` 缺省时用 builtin key;否则用 `state.config.server.host/port` 拼 `http://{host}/v1`,host 是 `0.0.0.0` 或 `::` 时改写 `127.0.0.1`
- [x] 3.3 三个 handler(chat 第 351 行、images 第 274 行、audio 第 311 行)全部改为接受 `(api_base, api_key, _mode)`,bearer 用新 `api_key`
- [x] 3.4 `status_handler` 响应增加 `"mode": <local|fallback-remote>`、`"keyName": "comfyui-agent"`,`upstreamBase` 仍为已 trim 的字符串
- [x] 3.5 单测(用 `axum-test`):env 未设、env 设、env 空字符串三种条件下 `/api/comfyui-agent/status` 返回的 mode 与 upstreamBase 正确
- [x] 3.6 集成测:`wiremock` mock 一个 `/v1/chat/completions` 返回固定 SSE,断言 Agent 在 local 模式下命中该 mock
## 4. admin 层保护内置 key
- [x] 4.1 用 `rg "FROM downstream_api_keys" /root/metapi/src/routes` 找到 admin list / get / patch / delete 入口
- [x] 4.2 在 list 响应映射处过滤:若 `name='comfyui-agent' AND user_id IS NULL` 则附加 `system: true` 字段、把 `key` 改为 `••••••••<后4位>`
- [x] 4.3 在 DELETE 入口加守卫:命中系统 key 返回 403 + message `system key cannot be deleted`
- [x] 4.4 在 PATCH/PUT 入口加守卫:当请求体试图把系统 key 的 `enabled` 设为 false 时拒绝
- [x] 4.5 路由级单测:3 条用例覆盖打码 / DELETE 拒绝 / 禁用拒绝;普通 key 操作不受影响 1 条用例
## 5. settings 暴露面过滤
- [x] 5.1 用 `rg "FROM settings"` 与 `rg "/api/admin/settings"` 找到 settings list / get 入口(如有)
- [x] 5.2 list 入口过滤所有以 `builtin_key.` 为前缀的 key
- [x] 5.3 单 key 读入口:以 `builtin_key.` 开头时返回 404 或 `value: "••••"`,不返回真实明文
- [x] 5.4 若上述路由不存在,把当前调研结果记入 commit message,跳过本组任务但保留 spec scenarios 待将来实现兜底
> 调研结论:当前 `src/routes/admin/mod.rs` 仅暴露 `GET /api/settings` (list-all) 与 `PUT /api/settings` (upsert),没有单 key GET。
> 已实现:
> - `get_settings` 在 `BTreeMap` 收敛前过滤 `key.starts_with("builtin_key.")`
> - `update_setting` 对 `builtin_key.*` 写入返回 403 `system setting cannot be modified`,作为防御性兜底(即便 UI 永远看不到该 key,也防止已知 key 名被直接 PUT 篡改)
> - 单 key GET 路由不存在,无需兜底;若将来新增需保留同样的 prefix 守卫
## 6. 文档与冒烟
- [x] 6.1 更新 `AGENTS.md` 第 32-36 行的 ComfyUI Agent 段落:默认本地、env 回退语义、密钥位置(settings + downstream_api_keys)
- [x] 6.2 更新 `README.md` 中相关段落(搜索"ComfyUI Agent"出现处)
- [ ] 6.3 跑全量校验:`cargo test`、`cargo clippy --all-targets`
- [ ] 6.4 手测:默认启动一次 chat / images / audio 各一次;设置 `BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1` 再启动一次三类调用;两次 status 响应 mode 字段切换正确(用户手测,等待执行)
- [x] 6.5 在 PR 描述中列出每条 spec scenario 的对应测试位置,便于 reviewer 校对(mapping 见下文「附:scenario → test 映射」)
## 附:scenario → test 映射
### specs/builtin-downstream-key/spec.md
- 启动时确保内置 key 存在
- 全新数据库首次启动 → `src/services/builtin_keys.rs` tests::ensures_key_on_first_boot
- 已有 settings 缺表记录(崩溃恢复) → `src/services/builtin_keys.rs` tests::backfills_table_from_settings
- 已有表记录缺 settings → `src/services/builtin_keys.rs` tests::backfills_settings_from_table
- 重复启动不变更密钥 → `src/services/builtin_keys.rs` tests::idempotent_across_two_boots
- enabled=0 自动恢复 → `src/services/builtin_keys.rs` tests::recovers_disabled_row
- 系统级 key 在管理 API 中受保护
- DELETE 被拒 / 禁用被拒 / 列表打码 / 普通 key 不受影响 → `src/routes/admin/downstream_keys.rs` `#[cfg(test)] mod tests`
- settings 中的内置密钥不暴露给 admin 列表
- 列表过滤前缀 → `src/routes/admin/mod.rs` tests::settings_list_filters_builtin_keys
- 单读返回占位 → 单 key GET 路由不存在;防御性写入守卫由 tests::settings_update_rejects_builtin_key 覆盖(详见 5.4 调研结论)
- 内置 key 用 user_id IS NULL 与普通 key 区分
- 普通用户不能创建同名 key 占用系统标识 / 引导查询仅匹配系统行 → builtin_keys.rs 引导逻辑使用 `name='comfyui-agent' AND user_id IS NULL` 限定 + admin tests 覆盖
### specs/comfyui-agent-local-routing/spec.md
- ComfyUI Agent 默认调用本进程 /v1
- 默认 base 指向本机回环 / 显式 host 被保留 / 三类调用使用同一 base → `src/routes/comfyui_agent.rs` tests::status_reflects_env_state_across_three_scenarios(Scenario A 覆盖回环改写、Scenario B 覆盖显式 host、共享 upstream_config 即覆盖三类一致)
- env 显式设置时回退远端
- env 非空触发回退 / env 空字符串视同未设置 → tests::status_reflects_env_state_across_three_scenarios(Scenario B + C)
- env key 缺省时复用内置 key → upstream_config 实现内联(见 routes/comfyui_agent.rs 的 fallback-remote 分支)
- 本地调用使用内置下游 key 计费
- chat 请求携带内置 key → tests::agent_local_mode_routes_chat_to_local_upstream(wiremock 断言 `Bearer sk-comfyui-test`)
- usage_logs 归集 → 间接覆盖(agent 经本进程 /v1,途经 token_router 自然写入 usage_logs,由现有 routes/proxy 路径覆盖)
- status 端点暴露生效模式
- 默认配置返回 local / env 设置时返回 fallback-remote → tests::status_reflects_env_state_across_three_scenarios
- 鉴权失败仍不暴露密钥 → 由 status_handler 鉴权中间件覆盖(无新增分支)
- 本地路由失败必须如实返回 → 由 SSE error 事件实现保留原行为,未新增专项测;如后续要硬覆盖,建议加 wiremock 404 + 断言 SSE error 事件
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-20
@@ -1,102 +0,0 @@
## Context
Today the Rust backend (`bosapi v1.3.0`) spawns a single embedded ComfyUI subprocess at boot via `services/comfyui_process.rs::ComfyUIProcess::new(...)` and stores it in `AppState.comfyui: Arc<ComfyUIProcess>`. All `/api/video-agent/*` handlers and any future `/comfy/*` traffic share this one process — so workflows, queue items, custom node state, and uploaded media leak between users. We need to support 100+ users with full per-user isolation, but we have no GPU (we use the BoosAPI text/image/TTS/video endpoints exclusively), so per-user instances are cheap (~1–2 GB resident with the API-only profile).
The user already runs ComfyUI in Docker as image `bosapi-comfyui:ready` and has accepted the "立刻可用 + 完全隔离" path: per-user on-demand container, with K3S as the v2 target. They added two late constraints we must honor:
1. Cap each container at **2 CPU / 2 GB RAM** (`--cpus=2 --memory=2g --memory-swap=2g`).
2. The existing `/api/video-agent/*` route surface is the in-app ComfyUI consumer; it must keep working for the same frontend with no contract change.
Auth is the existing JWT cookie. Users are identified by `user_id` from the cookie's claims. There is no per-org limit yet — only a single global cap.
## Goals / Non-Goals
**Goals:**
- Per-user ComfyUI isolation: workflows, queue, history, and uploaded files MUST NOT cross users.
- Lazy startup: a container exists only when the user is actively working; reaped after **60 minutes** idle.
- Hard ceiling: at most **30 concurrent containers**, hard-capped to bound memory at ~60 GB.
- Per-container resource cap: **2 CPU / 2 GB RAM** (memory + swap).
- Persistent custom workflows and outputs at `/data/comfyui-users/<user_id>/`.
- Reuse the existing `/api/video-agent/*` route surface unchanged from the frontend's perspective.
- New `/comfy/*` HTTP+WebSocket reverse proxy with cookie auth (no username in URL).
- Be K3S-portable: the container backend lives behind a `ContainerRuntime` trait so v2 can swap Docker for K3S with no caller changes.
- Built-in ComfyUI OpenAI nodes work via the `BOOSAPI_BUILTIN_KEY` env-var fallback (already patched in `_helpers.py`; bake into image).
**Non-Goals:**
- GPU support, local model weights, training. We are API-only.
- Per-organization quotas or per-user concurrency budgets — single global cap of 30 only.
- K3S backend implementation. v1 ships Docker only; the trait is the only K3S investment.
- Migrating away from `bosapi_comfy_nodes.py`. It coexists with the patched internal nodes.
- Replacing the existing dev-time `docker-compose.comfyui.yml`. Production stops using it; dev keeps it.
## Decisions
### 1. Per-user on-demand Docker container (vs shared+key-injection or per-node-key)
**Chosen:** spawn `bosapi-comfyui:multi-tenant` once per active user via `docker run`. **Rationale:** complete filesystem and queue isolation; no need to rewrite ComfyUI's WS protocol to mux users; the user's own framing was "立刻可用 + 完全隔离 ... 后期用K3S". **Alternatives considered:** (a) shared instance with per-request key injection — leaks queue/history across users, requires a reverse-engineered WS proxy that rewrites `client_id`, fragile; (b) per-node API-key — loses access to ComfyUI's built-in OpenAI nodes and prebuilt templates the user explicitly wants kept ("内置的节点和模板都很好用").
### 2. Pool sizing: 30 hard cap, 60-minute idle reap
**Chosen:** `MAX_CONCURRENT=30`, `IDLE_TIMEOUT=60min`. **Rationale:** 30 × 2 GB = 60 GB resident, fits a single mid-size box; 60 min covers a typical creative session without forcing repeated cold starts. Beyond 30, the pool returns `503` with `Retry-After`. **Alternative considered:** unbounded with LRU evict — but evicting a busy queue mid-render is worse UX than asking the next user to wait.
### 3. URL routing: `/comfy/*` at host root, identity from JWT cookie
**Chosen:** mount a reverse-proxy router at `/comfy/*` that reads the JWT cookie, resolves the user's container, and proxies HTTP+WS to `127.0.0.1:<port>`. **Rationale:** ComfyUI's HTML/JS asset paths assume root-relative URLs and break under subpath rewriting; putting the user_id in the URL path leaks it and complicates sharing/auth. **Alternative considered:** subdomain-per-user (`<user>.comfy.boos.lat`) — needs wildcard TLS and DNS, overkill for v1.
### 4. Container backend behind `ContainerRuntime` trait
**Chosen:**
```rust
#[async_trait]
pub trait ContainerRuntime: Send + Sync {
async fn start(&self, spec: ContainerSpec) -> Result<ContainerHandle>;
async fn stop(&self, id: &str) -> Result<()>;
async fn is_alive(&self, id: &str) -> Result<bool>;
}
```
v1 ships `DockerRuntime` (shells out to `docker` or talks to `/var/run/docker.sock`). v2 adds `K3sRuntime`. **Rationale:** the user has already picked K3S as v2; constraining now costs little and saves a rewrite. **Alternative considered:** hard-code `bollard` calls into the pool — couples lifecycle to Docker, rejected.
### 5. Resource limits per container
**Chosen:** `--cpus=2 --memory=2g --memory-swap=2g`, plus `--pids-limit=512`, `--read-only` rootfs with `--tmpfs /tmp:size=512m`, `--cap-drop=ALL`, `--security-opt=no-new-privileges`. Volume mounts: `/data/comfyui-users/<user_id>/input`, `/output`, `/temp`, `/user`, `/custom_nodes_user`. **Rationale:** user explicitly said "Docker的CPU RAM都要做限制 给个2C2G". The hardening flags follow defense-in-depth; ComfyUI does not need privileges or rootfs writes beyond the mounted volumes.
### 6. Idle reaper as a background tokio task
**Chosen:** a single tokio task ticks every 60 s, scans `pool.containers`, and stops any whose `last_used_at` is > 60 min ago. State recorded in-memory; a crash drops it but containers self-stop on `--rm` exit anyway (we do `docker stop`, not `docker kill`, so ComfyUI flushes). **Alternative considered:** Docker's own `--stop-timeout` + cron — split control plane between Rust and shell, harder to reason about.
### 7. Refactor of `/api/video-agent/*` (preserve frontend contract)
**Chosen:** every handler in `routes/video_agent.rs` extracts `user_id` from the JWT cookie, calls `state.comfyui_pool.get_or_start(user_id).await?`, and forwards to that handle's `start/stop/restart/status` or HTTP client. Status responses gain `containerState: "starting" | "ready" | "stopped"` so the frontend can render the new full-screen loading overlay. The existing JSON keys remain. **Alternative considered:** introduce a v2 namespace `/api/v2/video-agent/*` — gratuitous churn on the frontend.
### 8. AppState rename
**Chosen:** `pub comfyui_pool: Arc<ComfyUIPool>` replaces `pub comfyui: Arc<ComfyUIProcess>`. The old `ComfyUIProcess` type is **kept** — it's now the implementation detail used inside `DockerRuntime` to model the in-container ComfyUI subprocess (start/stop, port readiness probe, log buffer). `lib.rs` no longer constructs it directly; `DockerRuntime::start` does.
### 9. Image build
**Chosen:** new `docker/Dockerfile.comfyui` based on the user's existing `bosapi-comfyui:ready` lineage. Adds:
- the patched `comfy_source/comfy_api_nodes/util/_helpers.py` (env-var fallback)
- `bosapi_comfy_nodes.py` baked in (no host bind-mount, so it survives `--read-only`)
- `ENTRYPOINT` reads `BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, `BOOSAPI_KEY`, then exec's `python main.py --listen 0.0.0.0 --port 8188 --comfy-api-base "$BOOSAPI_BASE/api/comfyui-agent"`.
Tag: `bosapi-comfyui:multi-tenant`. `build-and-run.sh` builds this image; the runtime spawns containers from it on demand.
## Risks / Trade-offs
- **Cold-start latency 10–20 s** → mitigated by full-screen loading UI with progress text; pool exposes `containerState` so the frontend can poll.
- **30-container ceiling can reject users at peak** → 503 with `Retry-After: 60`; UI shows a friendly "服务繁忙" page. Monitor and raise the cap (or move to K3S) if it bites.
- **Docker socket exposure** if the API runs in Docker → mount `/var/run/docker.sock` only, restrict to root in API container, never expose to user containers. Document for ops.
- **Port pool exhaustion (8200–8299 = 100 slots, cap is 30)** → 70-port headroom is fine; allocator picks lowest-free; on container stop the port is released.
- **Idle reap mid-render** → reap only when no active WS connections AND `last_used_at` > 60 min; bump `last_used_at` on every HTTP+WS frame. Acceptable false-negative rate.
- **Persistent volume disk growth** → no quota in v1. Document; add a per-user disk cap (e.g. 5 GB via `du`+reject) only if it becomes a real problem.
- **Built-in OpenAI nodes auth** depends on `BOOSAPI_BUILTIN_KEY` env reaching the container → enforce via `ContainerSpec.env` in the pool, not opt-in.
- **K3S abstraction tax** → only the trait shape; we still have to design pod specs later. Acceptable.
- **State loss on API restart** → containers keep running but the pool's in-memory map is gone. Mitigation: on startup, list `comfyui-user-*` containers, reconcile map. Tracked as a task.
## Migration Plan
1. Land `ContainerRuntime` trait + `DockerRuntime` + `ComfyUIPool` behind a feature flag `multi_tenant_comfy` (default off). The old `state.comfyui` singleton stays intact.
2. Build `bosapi-comfyui:multi-tenant` image; smoke-test by hand with `docker run`.
3. Add `/comfy/*` proxy and a single new `state.comfyui_pool` field alongside `state.comfyui`.
4. Refactor `/api/video-agent/*` handlers to read from `state.comfyui_pool` when the flag is on, else fall back to `state.comfyui`.
5. Toggle the flag on in staging; verify with two real users that workflows/uploads do not cross.
6. Remove `state.comfyui` and the flag. `docker-compose.comfyui.yml` becomes dev-only.
7. Document host requirements in `README` (Docker socket, `/data/comfyui-users/`, image build).
**Rollback:** flip the feature flag off and redeploy. Containers from the multi-tenant path keep running until idle-reaped; no data loss because volumes are persistent.
## Open Questions
- Should the API container run with `docker:dind` or mount the host socket? Default to host socket; revisit if ops needs isolation.
- Where do we publish the loading-state UI string set? Put it next to other i18n strings in the frontend repo.
- Do we expose `/comfy/admin/*` for ops to introspect the pool? Out of scope for v1; add later if needed.
@@ -1,43 +0,0 @@
## Why
Today every user of `/api/video-agent/*` and `/comfy/*` shares one global `Arc<ComfyUIProcess>` singleton spawned at boot. That means workflows, queue, custom nodes, and uploaded assets are visible across users — unsuitable for the 100+ user multi-tenant deployment we are moving toward. We need per-user isolation now, with an architecture that can move to K3S later without a second rewrite.
## What Changes
- **BREAKING (internal API only):** Replace the `state.comfyui: Arc<ComfyUIProcess>` singleton with `state.comfyui_pool: Arc<ComfyUIPool>`. All existing `/api/video-agent/*` handlers refactor to look up (or lazily start) the caller's container via the pool.
- Introduce per-user on-demand ComfyUI containers. One container per authenticated user, started on first `/comfy/*` or `/api/video-agent/*` request, reaped after **60 minutes idle**.
- Cap the pool at **30 concurrent containers** (≈60 GB RAM at 2 GB each). Requests beyond that queue with a clear "service busy" response and full-screen loading UI hint.
- Each container limited to **2 CPU / 2 GB RAM** (`--cpus=2 --memory=2g --memory-swap=2g`), bound to a port from the **8200–8299** pool, named `comfyui-user-<user_id>`.
- Persistent per-user volume mounted at `/data/comfyui-users/<user_id>/` so custom workflows, inputs, and outputs survive container restarts.
- Add a reverse-proxy route family `/comfy/*` (HTTP + WebSocket) that routes the authenticated user to their own container. **No username in URL** — identity comes from the existing JWT cookie. ComfyUI's HTML/asset paths assume root, so we proxy at the host level rather than under a subpath.
- Bake the existing `comfy_source/comfy_api_nodes/util/_helpers.py` env-var fallback patch into the container image so ComfyUI's built-in OpenAI nodes pick up `BOOSAPI_BUILTIN_KEY` automatically.
- Abstract the container backend behind a `ContainerRuntime` trait. v1 ships `DockerRuntime`. v2 (when we move to K3S) adds `K3sRuntime` without touching pool, proxy, or route code.
- Wire pool startup, idle reaper, and proxy into the existing axum app and `build-and-run.sh`.
## Capabilities
### New Capabilities
- `comfyui-multi-tenant`: per-user ComfyUI container lifecycle (start, idle reap, max-concurrency, port allocation, persistent volumes).
- `container-runtime`: backend-agnostic container abstraction (`ContainerRuntime` trait + `DockerRuntime` impl) so K3S can be added later without rewriting callers.
- `comfyui-proxy`: cookie-authenticated reverse proxy at `/comfy/*` (HTTP + WebSocket) that maps the request to the caller's container.
- `video-agent-per-user`: refactor of the existing `/api/video-agent/*` handlers to use the per-user pool instead of the global singleton, preserving the current frontend contract.
### Modified Capabilities
<!-- None — no specs/ directory exists yet, so all four capabilities are net-new. -->
## Impact
- **Code touched (Rust):**
- `src/services/comfyui_process.rs` — keep as the in-container process model used by `DockerRuntime`, no longer spawned directly by `lib.rs`.
- `src/services/` — new `comfyui_pool.rs`, `container_runtime.rs`, `docker_runtime.rs`.
- `src/routes/video_agent.rs` — every handler switches from `state.comfyui` to `state.comfyui_pool.get_or_start(user_id)`.
- `src/routes/` — new `comfy_proxy.rs` mounting `/comfy/*` (HTTP + WS).
- `src/lib.rs` — `AppState` field rename, pool construction, idle-reaper task spawn.
- **Infra:**
- New `docker/Dockerfile.comfyui` baking the `_helpers.py` patch and `bosapi_comfy_nodes.py` into `bosapi-comfyui:multi-tenant`.
- `build-and-run.sh` builds the image; the Rust binary itself launches/stops containers at runtime (no docker-compose).
- Host paths: `/data/comfyui-users/<user_id>/` (persistent), `/var/run/docker.sock` mounted into the API container if API runs in Docker.
- **Auth:** Existing JWT cookie used as-is. New `/comfy/*` proxy reads it; existing `/api/video-agent/*` already does.
- **Frontend:** No contract change for `/api/video-agent/*`. New full-screen loading UI shown during the 10–20 s container startup; backend exposes a status endpoint the UI can poll.
- **Removals:** Old `docker-compose.comfyui.yml` becomes dev-only; production no longer uses it. The single global `state.comfyui` field disappears.
- **Out of scope:** GPU support (we are API-only), per-org quotas (single global cap of 30 only), K3S backend implementation (v2 task).
@@ -1,78 +0,0 @@
## ADDED Requirements
### Requirement: Per-user container lifecycle
The system SHALL maintain at most one ComfyUI container per authenticated user. The container SHALL be started lazily on the user's first request to `/comfy/*` or `/api/video-agent/*` and SHALL be reaped after 60 minutes of inactivity.
#### Scenario: First request starts the container
- **WHEN** an authenticated user with no running container makes a request to `/comfy/*`
- **THEN** the system starts a container named `comfyui-user-<user_id>`, allocates a port from 8200–8299, records `last_used_at = now`, and forwards the request once the container's HTTP port is reachable
#### Scenario: Subsequent request reuses the container
- **WHEN** the same user makes another request within 60 minutes
- **THEN** the system reuses the existing container, updates `last_used_at = now`, and forwards the request without restarting
#### Scenario: Idle container is reaped
- **WHEN** a container's `last_used_at` is older than 60 minutes AND it has no active WebSocket connections
- **THEN** the system stops the container via the runtime's `stop` method and releases its port back to the pool
### Requirement: Concurrency cap
The system SHALL refuse to start a new container when 30 are already running.
#### Scenario: 31st user is rejected
- **WHEN** 30 containers are running and a 31st distinct user requests `/comfy/*`
- **THEN** the system responds with HTTP 503 and a `Retry-After: 60` header, and does NOT evict any existing container
#### Scenario: Cap frees on idle reap
- **WHEN** any of the 30 containers is reaped
- **THEN** the next pending user request that arrives starts a fresh container in the freed slot
### Requirement: Resource limits
Each container SHALL run with `--cpus=2`, `--memory=2g`, `--memory-swap=2g`, `--pids-limit=512`, `--read-only` rootfs, `--cap-drop=ALL`, and `--security-opt=no-new-privileges`.
#### Scenario: Container respects CPU and memory caps
- **WHEN** a new container is started
- **THEN** `docker inspect` shows `HostConfig.NanoCpus = 2000000000`, `HostConfig.Memory = 2147483648`, `HostConfig.MemorySwap = 2147483648`
#### Scenario: Container has no extra capabilities
- **WHEN** a new container is started
- **THEN** `docker inspect` shows `HostConfig.CapDrop` contains `ALL` and `HostConfig.SecurityOpt` contains `no-new-privileges`
### Requirement: Persistent per-user volumes
Each container SHALL mount the host directory `/data/comfyui-users/<user_id>/` so that custom workflows, inputs, and outputs survive container restarts.
#### Scenario: Workflow saved in one session is visible after restart
- **WHEN** a user saves a workflow file to ComfyUI's `user/` directory, then triggers a container restart (idle reap + new request)
- **THEN** the workflow file is still present in the new container's `user/` directory
#### Scenario: Volume is per-user
- **WHEN** user A saves a file and user B starts their own container
- **THEN** user B's container does NOT see user A's file
### Requirement: Built-in API key injection
Each container SHALL receive `BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, and `BOOSAPI_KEY` as environment variables so ComfyUI's built-in OpenAI nodes (via the patched `_helpers.py`) and the `bosapi_comfy_nodes.py` custom nodes both authenticate successfully.
#### Scenario: Internal OpenAI node authenticates
- **WHEN** a workflow uses a built-in ComfyUI OpenAI node and the container has `BOOSAPI_BUILTIN_KEY` set
- **THEN** the node's outbound request to the BoosAPI shim includes `Authorization: Bearer <BOOSAPI_BUILTIN_KEY>`
#### Scenario: Custom BoosAPI node authenticates
- **WHEN** a workflow uses `BoosAPITextGen` from `bosapi_comfy_nodes.py` and the container has `BOOSAPI_KEY` set
- **THEN** the node's request to `BOOSAPI_BASE` includes `Authorization: Bearer <BOOSAPI_KEY>`
### Requirement: Container state observable to callers
The pool SHALL expose each container's state as `starting`, `ready`, or `stopped` so the frontend can render a loading UI during cold start.
#### Scenario: Status reflects starting then ready
- **WHEN** a user's first request triggers a container start
- **THEN** `pool.state(user_id)` returns `starting` until the HTTP port is reachable, then `ready`
#### Scenario: Status reflects stopped after reap
- **WHEN** a container has been idle-reaped
- **THEN** `pool.state(user_id)` returns `stopped`
### Requirement: Reconcile on startup
On API restart, the pool SHALL list all `comfyui-user-*` containers from the runtime and reconstruct its in-memory map (user_id, port, last_used_at).
#### Scenario: Existing containers are adopted
- **WHEN** the API process restarts while a `comfyui-user-42` container is still running
- **THEN** the new pool's map contains an entry for user 42 with the original container's port, and the next request from user 42 reuses it
@@ -1,45 +0,0 @@
## ADDED Requirements
### Requirement: Cookie-authenticated reverse proxy at /comfy/*
The system SHALL mount a reverse proxy at `/comfy/*` that resolves the caller's `user_id` from the existing JWT cookie and forwards both HTTP and WebSocket traffic to that user's container.
#### Scenario: Anonymous request is rejected
- **WHEN** a request to `/comfy/` arrives without a valid JWT cookie
- **THEN** the proxy responds with HTTP 401 and does NOT touch the container pool
#### Scenario: Authenticated HTTP request reaches the right container
- **WHEN** user 7 requests `GET /comfy/api/object_info`
- **THEN** the proxy forwards the request body, query, and headers to `http://127.0.0.1:<port_for_user_7>/api/object_info` and streams the response back unchanged
#### Scenario: Authenticated WebSocket upgrade is proxied
- **WHEN** user 7's browser opens `wss://host/comfy/ws?clientId=abc`
- **THEN** the proxy upgrades the connection, opens a WS to `ws://127.0.0.1:<port_for_user_7>/ws?clientId=abc`, and pipes frames in both directions until either side closes
### Requirement: No URL rewriting of asset paths
The proxy SHALL forward request paths to the container with the `/comfy` prefix stripped and SHALL NOT rewrite response bodies.
#### Scenario: Asset path is preserved
- **WHEN** the browser requests `/comfy/extensions/core/foo.js`
- **THEN** the proxy forwards `/extensions/core/foo.js` to the container, and the response body is returned byte-for-byte
#### Scenario: ComfyUI's root-relative URLs work
- **WHEN** the container returns HTML referencing `/scripts/app.js`
- **THEN** the browser fetches `/comfy/scripts/app.js` (because the page was served under `/comfy/`) and the proxy resolves it correctly
### Requirement: Cold-start handling
If a request arrives while the user's container is `starting`, the proxy SHALL hold the request (up to 30 s) until the container reports `ready`, instead of returning 502.
#### Scenario: Request waits for cold start
- **WHEN** user 7's first request to `/comfy/` arrives and the container needs 12 s to start
- **THEN** the proxy waits for the readiness signal and forwards the request once the container is ready, returning a normal 200
#### Scenario: Cold-start timeout
- **WHEN** a container fails to become ready within 30 s
- **THEN** the proxy responds with HTTP 504 and the pool marks the container `failed` for the next idle scan
### Requirement: Last-used timestamp updated on every frame
Every HTTP request and WebSocket frame proxied to the container SHALL update `pool.containers[user_id].last_used_at = now`.
#### Scenario: Long-running WS keeps container alive
- **WHEN** a user holds a WS connection open for 90 minutes while exchanging frames every minute
- **THEN** the container is not reaped at the 60-minute mark
@@ -1,34 +0,0 @@
## ADDED Requirements
### Requirement: Backend-agnostic ContainerRuntime trait
The system SHALL expose container lifecycle behind a single `ContainerRuntime` trait so the pool, proxy, and route handlers do NOT depend on Docker directly.
#### Scenario: Pool uses only the trait
- **WHEN** the source for `services/comfyui_pool.rs` is inspected
- **THEN** it imports `ContainerRuntime` and `ContainerSpec` only — never `bollard`, `docker`, or `kube` types
#### Scenario: Trait surface is sufficient for v1
- **WHEN** a `DockerRuntime` is provided
- **THEN** the pool can start containers, stop them, check liveness, and list existing `comfyui-user-*` containers using only the trait's methods
### Requirement: DockerRuntime implementation
The system SHALL ship a `DockerRuntime` that implements `ContainerRuntime` against the local Docker daemon, either via the host socket `/var/run/docker.sock` or the `docker` CLI.
#### Scenario: Start spawns a container
- **WHEN** `DockerRuntime::start(spec)` is called with `image=bosapi-comfyui:multi-tenant`, `name=comfyui-user-7`, port mapping, env, and volume mounts
- **THEN** a container with that name exists, is running, and exposes the chosen host port
#### Scenario: Stop removes the container
- **WHEN** `DockerRuntime::stop("comfyui-user-7")` is called
- **THEN** the container is stopped and removed (image used `--rm`-equivalent semantics) and its name becomes reusable
#### Scenario: List finds existing user containers
- **WHEN** `DockerRuntime::list_user_containers()` is called
- **THEN** it returns one entry per running container whose name starts with `comfyui-user-`, including its port mapping
### Requirement: K3S extension point reserved
The trait SHALL be designed such that a future `K3sRuntime` implementing the same surface can replace `DockerRuntime` without changes to the pool, proxy, or route handlers.
#### Scenario: Swapping runtimes does not break callers
- **WHEN** `AppState::comfyui_pool` is constructed with a `Box<dyn ContainerRuntime>` instead of a concrete `DockerRuntime`
- **THEN** all callers compile and behavior is unchanged for the `DockerRuntime` instance
@@ -1,41 +0,0 @@
## ADDED Requirements
### Requirement: video-agent handlers route through the pool
Every handler under `routes/video_agent.rs` SHALL resolve the caller's `user_id` from the JWT cookie and operate on `state.comfyui_pool.get_or_start(user_id)` instead of the global `state.comfyui` singleton. The wire format of existing endpoints SHALL NOT change.
#### Scenario: status reflects per-user container
- **WHEN** user 7 calls `GET /api/video-agent/status`
- **THEN** the response describes user 7's container only, including a new `containerState: "starting" | "ready" | "stopped"` field, and the pre-existing JSON keys are preserved
#### Scenario: start is idempotent per user
- **WHEN** user 7 calls `POST /api/video-agent/start` twice in a row
- **THEN** the first call starts (or reuses) the container and returns 200, the second call returns 200 immediately without spawning a duplicate container
#### Scenario: stop only stops the caller's container
- **WHEN** user 7 calls `POST /api/video-agent/stop`
- **THEN** user 7's container is stopped and removed, and any other user's running container is unaffected
#### Scenario: chat / analyze / generate-* call the caller's container
- **WHEN** user 7 calls any of `/api/video-agent/{chat,analyze,character-prompts,generate-character,scene-prompt,generate-scene,generate-tts,full-workflow}`
- **THEN** the handler forwards the request to user 7's container's HTTP endpoint and returns the response
### Requirement: Backwards-compatible response shape
Existing JSON keys returned by `/api/video-agent/*` SHALL remain present and identical to the singleton-era response. Only additive fields are allowed.
#### Scenario: status payload is a superset
- **WHEN** the frontend (unchanged) parses the response of `/api/video-agent/status`
- **THEN** every field it currently reads is still present with the same type and meaning; only `containerState` (and any new optional fields) are added
### Requirement: Authentication required
All `/api/video-agent/*` routes SHALL require a valid JWT cookie. Anonymous requests SHALL return HTTP 401 without touching the pool.
#### Scenario: Anonymous request blocked
- **WHEN** an unauthenticated client calls `POST /api/video-agent/start`
- **THEN** the response is HTTP 401 and no container is started
### Requirement: Removal of global state.comfyui
The `comfyui: Arc<ComfyUIProcess>` field on `AppState` SHALL be removed once `comfyui_pool` is the sole code path. No call site SHALL retain a reference to the singleton.
#### Scenario: Codebase contains no singleton refs
- **WHEN** the source tree is grepped for `state.comfyui` (without the `_pool` suffix)
- **THEN** there are zero matches outside of historical comments
@@ -1,69 +0,0 @@
## 1. ContainerRuntime trait + DockerRuntime
- [x] 1.1 Add `services/container_runtime.rs` with `ContainerRuntime` trait, `ContainerSpec` (image, name, env, port_map, mounts, cpu, mem, mem_swap, pids_limit, read_only, cap_drop, security_opt), `ContainerHandle` (id, host_port).
- [x] 1.2 Add `services/docker_runtime.rs` implementing `ContainerRuntime` via `bollard` (or `tokio::process::Command` shelling to `docker` if `bollard` is heavy) — `start`, `stop`, `is_alive`, `list_user_containers`.
- [ ] 1.3 Unit-test `DockerRuntime` against the local Docker daemon: start/stop/list cycle on a hello-world image; assert resource flags via `inspect`. _(skipped this session — needs a live Docker daemon)_
## 2. ComfyUIPool
- [x] 2.1 Add `services/comfyui_pool.rs` with `ComfyUIPool { runtime: Arc<dyn ContainerRuntime>, containers: RwLock<HashMap<i64, PoolEntry>>, ports: PortAllocator(8200..=8299), max: usize, idle_timeout: Duration }`.
- [x] 2.2 Implement `get_or_start(user_id) -> Result<ContainerHandle>`: dedupe in-flight starts via per-user `Mutex<Option<oneshot::Receiver>>`; allocate port; build `ContainerSpec` with `image=bosapi-comfyui:multi-tenant`, name `comfyui-user-<id>`, env (`BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, `BOOSAPI_KEY`), volumes for `/data/comfyui-users/<id>/{input,output,temp,user,custom_nodes_user}`, resource caps from §design Decision 5.
- [x] 2.3 Readiness probe: poll `GET http://127.0.0.1:<port>/system_stats` until 200 or 30 s timeout; mark state `ready`/`failed`.
- [x] 2.4 Implement `stop(user_id)`, `state(user_id) -> "starting"|"ready"|"stopped"|"failed"`, `touch(user_id)`.
- [x] 2.5 Enforce concurrency cap: when `containers.len() >= 30`, return `PoolError::AtCapacity`; map to HTTP 503 `Retry-After: 60` at the proxy/route layer.
- [x] 2.6 Spawn idle-reaper tokio task: every 60 s, `stop` containers whose `last_used_at` > 60 min AND have no active WS connections.
- [x] 2.7 Reconcile on startup: `runtime.list_user_containers()` → seed `containers` map with existing `comfyui-user-*` entries.
## 3. AppState wiring
- [x] 3.1 Add `comfyui_pool: Arc<ComfyUIPool>` to `AppState` in `src/lib.rs` (keep `comfyui` field temporarily behind `cfg(feature = "legacy_comfy_singleton")` for rollback).
- [x] 3.2 Construct the pool in `lib.rs::build_app_state` from `DockerRuntime::new()` and the existing `BuiltinKeys.comfyui_agent` secret.
- [x] 3.3 Spawn the idle-reaper task at startup; add a graceful-shutdown hook that `stop`s any container we started in this process.
## 4. /comfy/* reverse proxy
- [x] 4.1 Add `routes/comfy_proxy.rs` mounting `any("/comfy/*path")` and `get("/comfy/ws")` (WS upgrade) using `axum::extract::ws` + `reqwest` (HTTP) / `tokio_tungstenite` (WS-to-WS).
- [x] 4.2 Auth middleware: extract JWT cookie, resolve `user_id`; on missing/invalid, return 401 without touching the pool.
- [x] 4.3 HTTP forwarder: strip `/comfy` prefix; copy method, path, query, headers (drop hop-by-hop), body; stream response back unchanged.
- [x] 4.4 WebSocket forwarder: open server-side WS to `ws://127.0.0.1:<port>/ws?...`; pump frames bidirectionally; close on either-side close.
- [x] 4.5 Cold-start hold: if `pool.state(user_id)` is `starting`, await readiness up to 30 s before forwarding; on timeout return 504.
- [x] 4.6 Touch `pool` on every HTTP request and every inbound/outbound WS frame.
- [x] 4.7 Register the router in `lib.rs::build_router` so `/comfy/*` is reachable.
## 5. /api/video-agent/* refactor
- [x] 5.1 In `routes/video_agent.rs`, add a helper `resolve_user_id(headers) -> Result<i64, AppError>` reading the JWT cookie.
- [x] 5.2 Rewrite `status` to call `pool.state(user_id)` and (if ready) the container's HTTP endpoint; preserve every existing JSON key, add `containerState`.
- [x] 5.3 Rewrite `start_comfyui` to `pool.get_or_start(user_id)` (idempotent).
- [x] 5.4 Rewrite `stop_comfyui` and `restart_comfyui` to act on the caller's container only.
- [x] 5.5 Rewrite `chat`, `analyze`, `character_prompts`, `generate_character`, `scene_prompt`, `generate_scene`, `generate_tts`, `full_workflow` to forward to `http://127.0.0.1:<port>/...` for the caller's container.
- [x] 5.6 Anonymous-request guard: every handler returns 401 if `resolve_user_id` fails.
## 6. Image build
- [x] 6.1 Add `docker/Dockerfile.comfyui` based on user's existing `bosapi-comfyui:ready` lineage; copy patched `comfy_source/comfy_api_nodes/util/_helpers.py` and `bosapi_comfy_nodes.py` into `/workspace/ComfyUI/`. _(implemented as `docker/Dockerfile.comfyui.multi-tenant` to keep the legacy singleton image untouched until rollout 9.4)_
- [x] 6.2 ENTRYPOINT script reads `BOOSAPI_BUILTIN_KEY`/`BOOSAPI_BASE`/`BOOSAPI_KEY` and execs `python main.py --listen 0.0.0.0 --port 8188 --comfy-api-base "$BOOSAPI_BASE/api/comfyui-agent"`. _(`docker/comfyui-entrypoint.sh`)_
- [x] 6.3 Tag image as `bosapi-comfyui:multi-tenant`; update `build-and-run.sh` to build it before launching the API.
- [ ] 6.4 Smoke-test: `docker run --rm --cpus=2 --memory=2g -e BOOSAPI_BUILTIN_KEY=... -p 18188:8188 bosapi-comfyui:multi-tenant` and curl `/system_stats`.
## 7. Frontend integration
- [ ] 7.1 Update video-agent panel to render full-screen loading overlay when `containerState === "starting"`; poll `/api/video-agent/status` every 2 s.
- [ ] 7.2 Add a `/comfy` entry point (link or iframe) gated on the user being authenticated; show `containerState` while loading.
- [ ] 7.3 Handle 503 from `/comfy/*` and `/api/video-agent/*` by showing a "服务繁忙,请稍候" page and retrying after `Retry-After`.
## 8. Verification
- [x] 8.1 `cargo check --all-targets` green.
- [ ] 8.2 `cargo test` green for new pool / proxy / route tests; pre-existing failures from task #27 explicitly out of scope.
- [ ] 8.3 Manual two-user test on staging: user A uploads image into ComfyUI, user B starts a fresh container and confirms A's file is NOT visible.
- [ ] 8.4 Manual cold-start test: stop user A's container, hit `/comfy/`, confirm full-screen loader shows for ~10–20 s, then ComfyUI loads.
- [ ] 8.5 Manual cap test: start 30 containers, confirm 31st request returns 503 with `Retry-After`.
- [ ] 8.6 Idle-reap test: leave a container idle for 65 minutes, confirm `docker ps` no longer lists it and the next request cold-starts.
## 9. Rollout
- [x] 9.1 Land 1–6 behind `multi_tenant_comfy` feature flag (default off) so legacy singleton path keeps working.
- [ ] 9.2 Enable in staging; run §8 manual tests.
- [ ] 9.3 Enable in production; monitor pool metrics for 1 week.
- [ ] 9.4 Remove `state.comfyui` singleton field, the feature flag, and any compat branches; archive legacy `docker-compose.comfyui.yml` references.
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-19
@@ -1,81 +0,0 @@
## Context
`src/web/App.tsx` 当前 1135 行,其中第 132–500+ 行的 `Login` 组件混合了三种登录方式(管理员 token、管理员账号密码、普通用户账号密码)和品牌侧栏文案;`src/web/pages/UserLogin.tsx` 又是同一类登录的重复实现。错误文案分别在 `loginError.ts`(仅 token 登录)、`App.tsx` 内联(账号密码登录)、`UserLogin.tsx` 内联三处解析;后端 `routes/auth.rs` 同时挂在 `/api/auth/login` 与 `/api/users/login` 两个路径上输出同一 JSON 形状。
OAuth 现状:`src/routes/oauth.rs` 顶部注释明确 "Simplified implementation — complex OAuth flow operations return 501"。`start_provider_flow` / `get_session` / `manual_callback` 都直接返回 "not implemented",没有 session 存储数据结构(`rg "oauth_sessions"` 零命中)。把 OAuth 当登录入口需要先把整个 provider flow 从 stub 写到可用,与本次前端拆分不在同一量级,因此从本次范围拆出。
约束:保留现栈(React 18 + 自研 `authSession.ts`),不引入路由库(已在用 `react-router-dom`)外的状态库;不改造桌面端 deeplink。
## Goals / Non-Goals
**Goals:**
- 把 `App.tsx` 内的登录组件拆出,得到 `pages/auth/LoginShell` + `AdminLoginPage` + `UserLoginPage` 三个文件,单文件不超过 ~250 行。
- 所有登录入口(管理员 token、管理员/用户账号密码)的失败提示走同一个 `resolveLoginErrorMessage`,状态码 → 文案映射集中在一处。
- `authSession.ts` 暴露 `commitSession({ audience, token })` / `clearSession(audience)` / `getSessionToken(audience)` 入口,所有登录路径只调用它,旧 API 标 `@deprecated` 但保留薄壳。
- `UserLoginPage` 底部留 `<OAuthSlot />` 占位组件,本次只渲染容器不挂行为,为下一个 change 留出 UI 落点。
**Non-Goals:**
- 不实现任何 OAuth provider flow(前后端皆不动)。
- 不替换 React、不引入 zustand/jotai 或 react-hook-form。
- 不重写 `routes/oauth.rs`。
- 不动桌面端(electron)登录链路。
- 不做 MFA / 邮箱验证码 / 注册流程改造(注册页不在本次范围)。
## Decisions
### 1. 拆分粒度:抽 `LoginShell` + 两个 page 组件,而不是一个统一 Login
`LoginShell` 负责品牌侧栏、容器、i18n 注入、错误展示槽位;`AdminLoginPage` 持有 token/账密两种 mode 切换;`UserLoginPage` 只有账密一种 mode + OAuth 槽位(占位)。
**为何不做单一 `<Login audience>` 组件**:当前实现就是这种思路,却让 props 组合(audience × mode × OAuth)爆炸。两个 page 组件之间共享 30% 代码(表单原语 + 提交按钮),用 `LoginShell` + 表单子组件复用即可,不必塞回一个组件。
**为何不直接删 `pages/UserLogin.tsx`**:保留路由 `/user/login` 让用户书签可用,但内部薄壳化,挂 `UserLoginPage` 即可。
### 2. 登录错误统一:扩展 `loginError.ts`,对 axios-style 异常归一
现状 `resolveLoginErrorMessage(status, reason)` 只覆盖 401/403/5xx 三类。新增:
- 400:参数错误,区分"邮箱格式不合法"/"密码不能为空"两种 reason(后端已有 `bad_request` 文案)。
- 409:账号被锁定 / 多次失败(后端目前没有,留接口以便后续接入)。
- 网络错误(fetch reject):返回 `网络异常,请检查连接`。
`AdminLogin` 与 `UserLogin` 都通过 `await loginRequest(...)` 拿统一形状 `{ status, reason }`,再交给 `resolveLoginErrorMessage`。
**为何不引入新错误库**:错误形状只有 5 种分支,多一层抽象反而模糊。
### 3. 会话写入收敛到 `authSession.commitSession`
新签名:
```ts
type Audience = "admin" | "user";
function commitSession(audience: Audience, token: string, ttlMs?: number, storage?, now?): void;
function clearSession(audience: Audience, storage?): void;
function getSessionToken(audience: Audience, storage?, now?): string | null;
```
底层仍然用现有 `AUTH_TOKEN_*` / `USER_JWT_*` 两套 key,对外暴露统一 API。`persistAuthSession` / `persistUserSession` 标记为 `@deprecated`,本次先不删(避免影响其他 page 直接调用的地方),下一次清理。
### 4. OAuth 占位:组件级 `OAuthSlot`,目前只渲染容器
`UserLoginPage` 底部嵌一个 `<OAuthSlot variant="user" />`,组件内部本次只输出一个空 `<section>`(或带 i18n 文案的 placeholder),不调任何 OAuth API。后续 change 在同一组件里实现 provider 列表 + 跳转 + exchange 链路。
**为何不直接留空白**:留可识别的 DOM 节点能让后续 change 用 `data-testid="oauth-slot"` 精准插入;留 `OAuthSlot` 文件还能让 `UserLoginPage` 的 import 形态稳定,避免后续 change 又改 page 组件 import。
## Risks / Trade-offs
- **[Risk] 拆分过程中 i18n key 错位**:现有 `t('登录')` 等中文 key 和 `i18n.tsx` supplement 强耦合,移动文件时容易丢翻译。→ Mitigation:拆分时一次性把 `t('xxx')` 调用搬到新文件,并跑 `i18n.test.ts` 校验。
- **[Risk] 测试迁移漏掉断言**:`App.login-surface.test.tsx` 是基于 `App.tsx` 内的 DOM 断言,组件路径变了断言要重写。→ Mitigation:新建 `pages/auth/LoginShell.test.tsx` 把原断言搬过去,老测试文件保留薄壳验证 `App.tsx` 里挂载入口仍存在。
- **[Trade-off] 保留 `persistAuthSession` / `persistUserSession`**:增加一次过渡期的 API 重复,但避免本次 PR 触碰过多调用点。
- **[Trade-off] OAuth 占位组件本次不挂任何行为**:会有一个看起来空的 DOM 区域,但避免本次 PR 误把"占位"当"已接入"看待;文档段落明确说明。
## Migration Plan
1. **第 1 步(无破坏)**:扩展 `loginError.ts` + 新建 `authSession.commitSession`,老函数转为薄壳调用新函数。跑全量单测确认无回归。
2. **第 2 步(前端拆分)**:新建 `src/web/pages/auth/LoginShell.tsx` + `AdminLoginPage.tsx` + `UserLoginPage.tsx` + `OAuthSlot.tsx`(占位);`App.tsx` 改为挂 `AdminLoginPage`;`pages/UserLogin.tsx` 内部 import `UserLoginPage`。手测三种现有登录路径。
3. **第 3 步(清理)**:删除 `App.tsx` 中已无用的 Login 组件 dead code,更新 `App.login-surface.test.tsx`,更新文档。
回滚:每步独立提交;如果第 2 步出问题,revert 即可恢复 `App.tsx` 内联实现。
## Open Questions
- 桌面端(electron)登录是否也通过同一组件?本次明确不在范围,但 `LoginShell` 不能假设 `window.location.origin` 是 web,组件内不出现 `window.location` 直接访问。
- `OAuthSlot` 是否需要通过 feature flag 控制可见性?本次按"始终渲染容器、内部留 placeholder"处理,下一个 change 再决定。
@@ -1,34 +0,0 @@
## Why
`src/web/App.tsx` 已经膨胀到 1135 行,其中 `Login` 组件(约 132–500+ 行)耦合了管理员 token 登录、管理员账号密码登录、用户账号密码登录、品牌侧栏、能力位文案和 i18n 适配,与同文件里另一个用户专用的 `pages/UserLogin.tsx` 形成两套并行实现。错误提示分散在三处(`loginError.ts`、`App.tsx` 内联解析、`UserLogin.tsx` 内联解析),状态码 / 文案不一致。
继续在 `App.tsx` 内堆登录逻辑会让后续接入新的登录方式(OAuth 第三方登录、邮箱验证码等)越来越难,也会让"登录失败"这类用户能看到的错误持续不一致。
OAuth 第三方登录入口本来与本次重构同期推进,但实施前确认 `src/routes/oauth.rs` 全套 provider flow 仍是 stub(所有 handler 返回 "not implemented"),缺少 session 存储、callback 解析、token 兑换链路。把 OAuth 后端从 stub 写到可用的体量与本次拆分不成比例,因此从本次范围中拆出,单独立项。
## What Changes
- 把 `App.tsx` 内的 `Login` 组件拆分成独立的 `pages/auth/` 模块(`AdminLoginPage` / `UserLoginPage` 复用同一套 `LoginShell` + 表单原语),合并 `pages/UserLogin.tsx` 的重复实现。
- 新增一个统一的前端登录错误解析层(扩展 `loginError.ts`),覆盖 `/api/auth/login`、`/api/users/login` 两个端点的所有失败码,所有登录入口走同一函数获得文案。
- 统一 `authSession.ts` 中 admin/user 两套 token 的写入路径,登录成功后通过 `commitSession(audience, token)` 单点持久化,避免页面各自调用 `persistAuthSession` / `persistUserSession`。
- 在用户登录页底部预留一个 OAuth 占位区域(不挂任何 provider 按钮),为后续接入 OAuth signin 留出 UI 槽位。
不在本次范围:
- OAuth provider flow 的实际接入(前后端均不实现),由独立 change 跟进。
- 替换 React、引入新状态库、改造桌面端 deeplink、邮箱验证码、注册流程改造。
## Capabilities
### New Capabilities
- `frontend-login`: 前端登录界面与会话建立流程(管理员 token / 账号密码、错误展示、会话持久化、OAuth 占位)。
### Modified Capabilities
<!-- 当前 openspec/specs/ 目录为空,没有既有 spec 受影响。 -->
## Impact
- 前端:`src/web/App.tsx`(拆出 Login)、`src/web/pages/UserLogin.tsx`(薄壳化)、`src/web/loginError.ts`(扩展)、`src/web/authSession.ts`(收敛入口)、新增 `src/web/pages/auth/`。
- 后端:本次不动 `routes/auth.rs` / `routes/oauth.rs`。
- 数据库:本次无迁移。
- 测试:`App.login-surface.test.tsx`、`loginError.test.ts` 需迁移到新组件位置。
- 文档:`README.md` / `AGENTS.md` 中"登录方式"段落需更新(标注 OAuth 由后续 change 接入)。
@@ -1,77 +0,0 @@
## ADDED Requirements
### Requirement: 登录界面拆分为独立模块
前端登录界面 SHALL 由 `src/web/pages/auth/` 目录下的独立模块承担,`App.tsx` 不再内联实现登录表单。`pages/auth/` 至少包含 `LoginShell`(品牌侧栏 + 容器 + 错误槽位)、`AdminLoginPage`(管理员 token / 账密两种 mode)、`UserLoginPage`(用户账密 + OAuth 占位槽)三个组件,单文件不超过 250 行。`pages/UserLogin.tsx` MUST 退化为薄壳,仅渲染 `UserLoginPage`,以保留既有路由 `/user/login`。
#### Scenario: 管理员入口挂载新组件
- **WHEN** 用户访问根路由 `/` 且未登录
- **THEN** 应用渲染 `AdminLoginPage`,且页面来源文件位于 `src/web/pages/auth/AdminLoginPage.tsx`
#### Scenario: 用户入口保留旧路由
- **WHEN** 用户访问 `/user/login`
- **THEN** 应用渲染 `UserLoginPage`,且 `src/web/pages/UserLogin.tsx` 内部仅作 import + 转发,不再持有表单状态
#### Scenario: App.tsx 不再持有登录表单状态
- **WHEN** 在 `src/web/App.tsx` 中查找形如 `setEmail` / `setPassword` / `setToken` 的 useState
- **THEN** 这些 state hooks 不出现在 `App.tsx` 中(已迁移到新组件)
### Requirement: 统一登录错误解析
所有登录入口(管理员 token、管理员账密、用户账密)的失败提示 SHALL 通过 `src/web/loginError.ts` 中的 `resolveLoginErrorMessage(status, reason)` 解析。该函数 MUST 覆盖 400 / 401 / 403 / 409 / 5xx 五类状态,以及 `network` 这类网络异常 sentinel。组件层 MUST NOT 自行 `switch (status)` 写文案。
#### Scenario: 401 返回统一令牌无效文案
- **WHEN** 调用 `resolveLoginErrorMessage(401, "")`
- **THEN** 返回包含"令牌无效"或"账号或密码错误"语义的中文文案
#### Scenario: 网络异常返回统一文案
- **WHEN** 前端 fetch 抛错(无 status 可读),调用 `resolveLoginErrorMessage("network", "")`
- **THEN** 返回"网络异常,请检查连接"文案
#### Scenario: 400 携带 reason 时分流文案
- **WHEN** 调用 `resolveLoginErrorMessage(400, "invalid email format")`
- **THEN** 返回"邮箱格式不合法"文案;当 reason 为空时返回通用"请求参数错误"
### Requirement: 会话写入收敛为单一 API
`src/web/authSession.ts` SHALL 暴露 `commitSession(audience, token, ttlMs?, storage?, now?)` 与 `clearSession(audience, storage?)` 与 `getSessionToken(audience, storage?, now?)` 三个函数。所有登录页 MUST 仅通过这三个函数读写本地存储。`persistAuthSession` 与 `persistUserSession` 仍保留为 `@deprecated` 薄壳调用 `commitSession`。
#### Scenario: 管理员登录成功调用 commitSession
- **WHEN** 管理员账密登录成功,组件拿到 JWT
- **THEN** 组件调用 `commitSession("admin", jwt)`,且不直接调用 `localStorage.setItem`
#### Scenario: 用户登录成功调用 commitSession
- **WHEN** 用户账密登录成功,组件拿到 JWT
- **THEN** 组件调用 `commitSession("user", jwt)`,且不直接调用 `persistUserSession`
#### Scenario: 旧 API 仍可工作
- **WHEN** 现有调用方仍调用 `persistAuthSession(localStorage, token)`
- **THEN** token 仍按原路径写入 `localStorage`,行为与重构前一致
### Requirement: 登录页预留 OAuth 占位槽
`UserLoginPage` SHALL 在表单下方渲染一个名为 `OAuthSlot` 的占位组件作为后续 OAuth 登录入口的挂载点。本次实施 `OAuthSlot` MUST NOT 调用任何 OAuth 相关 API,也 MUST NOT 触发跳转;它只渲染一个带稳定 `data-testid="oauth-slot"` 的容器节点(可包含 i18n 文案 placeholder)。`AdminLoginPage` MUST NOT 包含该槽位。
#### Scenario: 用户登录页渲染占位槽
- **WHEN** 用户访问 `/user/login` 加载 `UserLoginPage`
- **THEN** DOM 中存在 `data-testid="oauth-slot"` 节点,且不发起任何 `/api/oauth/*` 请求
#### Scenario: 管理员登录页不含占位槽
- **WHEN** 用户访问根路由 `/` 加载 `AdminLoginPage`
- **THEN** DOM 中不存在 `data-testid="oauth-slot"` 节点
#### Scenario: 占位槽不触发任何行为
- **WHEN** 用户与 `OAuthSlot` 内任何子元素交互
- **THEN** 不发出网络请求,不修改 `authSession`,不触发路由跳转
@@ -1,48 +0,0 @@
## 1. 基础工具层(无破坏改造)
- [x] 1.1 在 `src/web/loginError.ts` 中扩展 `resolveLoginErrorMessage`,覆盖 400(区分 `invalid email format` / 通用)、409(`no_account`)、`"network"` sentinel;保留现有 401/403/5xx 行为
- [x] 1.2 更新 `src/web/loginError.test.ts`,为新增分支补单测(400+reason、409、network、5xx 已有保留)
- [x] 1.3 在 `src/web/authSession.ts` 新增 `commitSession(audience, token, ttlMs?, storage?, now?)` / `clearSession(audience, storage?)` / `getSessionToken(audience, storage?, now?)`,内部委托给现有 `AUTH_TOKEN_*` / `USER_JWT_*` 路径
- [x] 1.4 把 `persistAuthSession` / `persistUserSession` / `clearAuthSession` / `clearUserSession` 改为薄壳调用 `commitSession` / `clearSession`,加 `@deprecated` JSDoc
- [x] 1.5 扩充 `src/web/authSession.test.ts`,覆盖 `commitSession("admin"|"user")` 双路径与旧 API 仍可工作
## 2. 前端组件拆分
- [x] 2.1 新建目录 `src/web/pages/auth/`,加 `LoginShell.tsx`:抽出品牌侧栏(logo / 文案 / capability list / 页脚链接)+ 容器 + 错误展示槽位,接收 `children` + `error` prop
- [x] 2.2 新建 `src/web/pages/auth/AdminLoginPage.tsx`,承接现有 `App.tsx` 中的 `Login`(admin 分支:token + 账密两种 mode 切换),所有错误走 `resolveLoginErrorMessage`,写入会话调 `commitSession("admin", ...)`
- [x] 2.3 新建 `src/web/pages/auth/UserLoginPage.tsx`,覆盖 `pages/UserLogin.tsx` 当前逻辑,写入会话调 `commitSession("user", ...)`;底部预留一个 `<OAuthSlot />` 占位组件,本次不挂任何 provider 按钮,留给后续 change
- [x] 2.4 把 `src/web/App.tsx` 中 `Login` 组件替换为 `<AdminLoginPage>`,删除组件内部的 useState/handler/JSX;保留 onLogin 回调签名
- [x] 2.5 把 `src/web/pages/UserLogin.tsx` 改为薄壳:`export { default } from './auth/UserLoginPage'` 或 1 行包裹组件
- [x] 2.6 迁移 `App.login-surface.test.tsx` 中 DOM 断言到 `src/web/pages/auth/LoginShell.test.tsx` 与 `AdminLoginPage.test.tsx` / `UserLoginPage.test.tsx`;保留 `App.login-surface.test.tsx` 仅断言挂载入口存在
- [x] 2.7 跑 `npm run typecheck:web && npm run test`,确认无回归
## 3. 清理与验收
- [x] 3.1 删除 `App.tsx` 中已无引用的 helper / 文案常量;用 `rg "import.*from.*authSession"` 巡检无遗漏调用 `persistAuthSession` / `persistUserSession` 的页面
- [x] 3.2 更新 `README.md` 与 `AGENTS.md` 中"登录"段落,加入新目录结构说明(`pages/auth/`);OAuth 登录入口标注"由后续 change 接入"
- [x] 3.3 跑全量校验:`npm run typecheck` / `npm run test` / `cargo test --lib` / `cargo clippy --all-targets`
- typecheck:web、typecheck:web:test 全过;typecheck:server 因 `tsconfig.server.json` 不存在报 TS5058,stash 校验确认 pre-existing
- npm run test:本 change 把 baseline 的 11 files / 17 tests 失败减到 9 files / 13 tests(修好 `App.mobile-layout` 与 `App.sidebar-mobile`),剩余 9 个失败文件均与 authSession 无关、stash 校验确认 pre-existing
- cargo test --lib:78 passed / 3 failed;3 个失败 (`proxy::token_router::router::tests::resolves_exact_model_route` / `…resolves_wildcard_route_and_skips_excluded_channel` / `routes::proxy::tests::chat_route_uses_token_router_and_conductor`) 同样在 stash 后基线复现,pre-existing
- cargo clippy --all-targets:本 change 区块零警告(`comfyui_agent.rs` 测试 mutex 已切到 `tokio::sync::Mutex`);仅余一处 unrelated `clippy::needless_borrow`(`token_router/router.rs:289` 一带)pre-existing
- [ ] 3.4 手测三条登录路径(管理员 token、管理员账密、用户账密);OAuth 占位区域确保渲染但不挂行为
- [x] 3.5 在 PR 描述中列出所有 spec scenarios 的对应测试位置,方便 reviewer 校对(mapping 见下文「附:scenario → test 映射」)
## 附:scenario → test 映射
### specs/frontend-login/spec.md
- 登录界面拆分为独立模块
- 管理员入口挂载新组件 → `src/web/pages/auth/AdminLoginPage.test.tsx`(断言挂载来源 + 路由 `/`)
- 用户入口保留旧路由 → `src/web/pages/auth/UserLoginPage.test.tsx` + `src/web/pages/UserLogin.tsx` 静态包含断言(薄壳)
- App.tsx 不再持有登录表单状态 → `src/web/App.login-surface.test.tsx`(保留为入口断言)+ rg 巡检(CI 时由 grep 守卫脚本兜底)
- 统一登录错误解析
- 401 / 网络异常 / 400+reason → `src/web/loginError.test.ts` 三组用例
- 会话写入收敛为单一 API
- 管理员登录成功调用 commitSession → `src/web/pages/auth/AdminLoginPage.test.tsx`(mock authSession,断言 commitSession('admin', token) 被调用)
- 用户登录成功调用 commitSession → `src/web/pages/auth/UserLoginPage.test.tsx`
- 旧 API 仍可工作 → `src/web/authSession.test.ts`(薄壳兼容用例)
- 登录页预留 OAuth 占位槽
- 用户登录页渲染占位槽 → `src/web/pages/auth/UserLoginPage.test.tsx`(断言 `data-testid="oauth-slot"` 存在 + 无 fetch)
- 管理员登录页不含占位槽 → `src/web/pages/auth/AdminLoginPage.test.tsx`(反向断言)
- 占位槽不触发任何行为 → `src/web/pages/auth/OAuthSlot.test.tsx`(点击无 fetch / 无 commit / 无 navigate)
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
@@ -1,92 +0,0 @@
# route-candidate-api Specification
## Purpose
TBD - created by archiving change add-route-rebuild-pipeline. Update Purpose after archive.
## Requirements
### Requirement: `GET /api/models/token-candidates` is registered
The admin router SHALL register `GET /api/models/token-candidates`
behind the standard admin auth layer. The handler SHALL return a JSON
object with exactly four top-level keys: `models`,
`modelsWithoutToken`, `modelsMissingTokenGroups`, `endpointTypesByModel`.
#### Scenario: Endpoint returns 200 with the four-key shape
- **WHEN** an authenticated admin sends `GET /api/models/token-candidates`
- **THEN** the response is HTTP 200 with a JSON body containing keys
`models`, `modelsWithoutToken`, `modelsMissingTokenGroups`, and
`endpointTypesByModel` — no extra top-level keys, no missing keys
#### Scenario: Unauthenticated request is rejected
- **WHEN** a request without a valid admin token reaches the endpoint
- **THEN** the admin auth layer returns 401 before the handler runs
### Requirement: `models` lists every (model, token) candidate
The `models` field SHALL be an object whose keys are model names and
whose values are arrays of candidate descriptors. A `(model, token)`
combination SHALL appear in the `models` array for that model exactly
when ALL of the following hold:
- a row in `model_availability` reports `available = 1` for the model
on the token's account
- the token's `enabled` is truthy (NULL treated as enabled)
- the token's `value_status` is `'ready'` (NULL treated as ready)
- the account's `status = 'active'`
- the site's `status` is `'active'` (NULL treated as active)
Each candidate descriptor SHALL contain: `accountId`, `tokenId`,
`tokenName`, `isDefault` (boolean), `username`, `siteId`, `siteName`.
Within a single model's array, descriptors SHALL be deduplicated by
`tokenId`.
#### Scenario: Active token with available model appears
- **WHEN** account 5 (active, site active) has token 11 (enabled,
ready) and `model_availability(account=5, model='gpt-4')` is
available
- **THEN** `models['gpt-4']` includes a descriptor with
`accountId = 5, tokenId = 11`
#### Scenario: Disabled token is excluded
- **WHEN** account 5 has token 11 with `enabled = 0`
- **THEN** no descriptor with `tokenId = 11` appears in `models` for
any model
#### Scenario: Duplicate tokens in source data deduped
- **WHEN** the underlying join produces two rows for the same
`(model, tokenId)` pair (e.g., joins fan out)
- **THEN** the descriptor appears exactly once in
`models[model_name]`
### Requirement: `modelsWithoutToken` surfaces accounts missing tokens
The `modelsWithoutToken` field SHALL be an object keyed by model name.
For each `(account, model)` combination where the model is available
on the active account but no qualifying token (per the `models`
requirement) exists for that account, the handler SHALL include a
descriptor `{ accountId, username, siteId, siteName }` under that
model name. Descriptors SHALL be deduplicated by `accountId` within
each model's array.
#### Scenario: Account with available model but no enabled tokens
- **WHEN** account 8 reports `gpt-4` available but has zero rows in
`account_tokens` (or all rows are disabled)
- **THEN** `modelsWithoutToken['gpt-4']` includes a descriptor with
`accountId = 8`, and that account does NOT appear in `models['gpt-4']`
#### Scenario: Account with token does not appear here
- **WHEN** account 5 reports `gpt-4` available and has a usable token
- **THEN** account 5 does NOT appear in `modelsWithoutToken['gpt-4']`
### Requirement: `modelsMissingTokenGroups` and `endpointTypesByModel` are empty
The handler SHALL return `modelsMissingTokenGroups` and
`endpointTypesByModel` as empty JSON objects (`{}`). The frontend
contract permits empty objects and these fields SHALL NOT be
populated until a marketplace-cache or pricing-catalog source is
added to the backend.
#### Scenario: Both fields are empty objects
- **WHEN** the handler returns successfully under any state of the
database
- **THEN** `response.modelsMissingTokenGroups === {}` and
`response.endpointTypesByModel === {}`
@@ -1,157 +0,0 @@
# route-rebuild-pipeline Specification
## Purpose
TBD - created by archiving change add-route-rebuild-pipeline. Update Purpose after archive.
## Requirements
### Requirement: Route rebuild reads from `model_availability`
The rebuild pipeline SHALL derive its working set from the
`model_availability` table joined to `accounts`, filtering out:
- accounts whose `status` is not `'active'`
- rows whose `available` is not `1` (treating `NULL` as not available)
- `(account, model)` combinations covered by `site_disabled_models`
for the account's `site_id`
The pipeline SHALL NOT make outbound HTTP requests to upstream platforms
during a rebuild. Refreshing `model_availability` is the responsibility
of the existing `POST /api/accounts/{id}/models/refresh` endpoint and
is out of scope for this requirement.
#### Scenario: Inactive account is excluded
- **WHEN** an account with `status = 'suspended'` has rows in
`model_availability`
- **THEN** no `token_routes` row is created on its behalf and no
`route_channels` row references its `account_id`
#### Scenario: Site-disabled model is excluded
- **WHEN** `site_disabled_models` contains `(site_id = 7, model_name = 'gpt-x')`
and an active account on site 7 has `model_availability` for `gpt-x`
- **THEN** the rebuild does not include that account in the channel set
for the `gpt-x` route
### Requirement: Rebuild aggregates one route per distinct model name
The rebuild SHALL ensure exactly one row exists in `token_routes` for
every distinct `model_name` in the filtered working set, keyed by
`model_pattern = model_name`. The rebuild SHALL reuse existing rows
when their `model_pattern` already matches; new rows SHALL be inserted
with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, and
`enabled = 1`.
The rebuild MUST NOT overwrite `display_name`, `display_icon`, or any
other fields on existing `token_routes` rows. Operators rely on these
fields being editable from the frontend without being clobbered on
re-runs.
#### Scenario: New model creates a new route
- **WHEN** model `claude-4-opus` appears in the working set and no
`token_routes` row has `model_pattern = 'claude-4-opus'`
- **THEN** the rebuild inserts a `token_routes` row with
`model_pattern = 'claude-4-opus'`, `routing_strategy = 'weighted'`,
`route_mode = 'pattern'`, `enabled = 1`
#### Scenario: Existing route is reused without overwrite
- **WHEN** a `token_routes` row exists with
`model_pattern = 'gpt-4'` and `display_name = 'GPT-4 Turbo (Custom)'`
- **THEN** the rebuild leaves `display_name` unchanged and reuses the
same `id` for channel reconciliation
### Requirement: Channel reconciliation preserves manual overrides
For each route, the rebuild SHALL reconcile `route_channels` rows
keyed by `(account_id, source_model)` against the desired set derived
from `model_availability`. Rows with `manual_override = 1` SHALL be
treated as immutable: not inserted, not updated, not deleted. Rows
with `manual_override = 0` (or `NULL`) SHALL be:
- inserted when missing from the existing set
- hard-deleted when no longer in the desired set
The desired set SHALL exclude any `(account, model)` combination
already covered by a `manual_override = 1` row, so the rebuild never
inserts an auto channel that shadows a manual one.
#### Scenario: Manual channel survives rebuild
- **WHEN** a `route_channels` row has `manual_override = 1` and its
`(account_id, source_model)` is no longer in the desired set
- **THEN** the row is left untouched and counted in
`RebuildSummary.skipped_manual_channels`
#### Scenario: Stale auto channel is deleted
- **WHEN** a `route_channels` row has `manual_override = 0`,
`account_id = 12`, `source_model = 'old-model'`, and no longer
appears in the desired set
- **THEN** the row is deleted and counted in
`RebuildSummary.deleted_channels`
#### Scenario: New auto channel is inserted with default token
- **WHEN** account 12 newly reports `gpt-5` available and has at least
one enabled `account_tokens` row
- **THEN** a new `route_channels` row is inserted with `route_id`
pointing at the `gpt-5` route, `account_id = 12`, the
default-flagged enabled token's id (or the lowest id when none is
default), `priority = 0`, `weight = 10`, `enabled = 1`,
`manual_override = 0`
#### Scenario: Account without tokens still gets a channel
- **WHEN** account 13 reports `gpt-5` available but has no enabled
`account_tokens` rows
- **THEN** a `route_channels` row is inserted with `token_id = NULL`,
relying on the proxy layer's account-level token fallback
### Requirement: Rebuild runs in a single transaction
The rebuild SHALL execute every read and write inside one database
transaction, committed at the end. A partial failure MUST NOT leave
`token_routes` and `route_channels` mutually inconsistent.
#### Scenario: Failure mid-rebuild rolls back
- **WHEN** an `INSERT` into `route_channels` fails partway through
the rebuild
- **THEN** the transaction aborts and no `token_routes` insert from
the same rebuild persists
### Requirement: Rebuild summary matches frontend contract
The rebuild SHALL return a `RebuildSummary` containing the fields
`createdRoutes`, `reusedRoutes`, `createdChannels`, `deletedChannels`,
`skippedManualChannels`, `scannedModels`, `scannedAccounts` (camelCase
when serialized). The `POST /api/routes/rebuild` handler SHALL embed
this object under the JSON key `rebuild`, alongside `success: true`.
`TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` and
`rebuild.createdChannels`; both MUST be present and SHALL count only
rows actually inserted by the current rebuild (not pre-existing rows
that were reused).
#### Scenario: Rebuild response shape
- **WHEN** the handler completes successfully
- **THEN** the response body is
`{ "success": true, "rebuild": { "createdRoutes": <n1>,
"reusedRoutes": <n2>, "createdChannels": <n3>,
"deletedChannels": <n4>, "skippedManualChannels": <n5>,
"scannedModels": <n6>, "scannedAccounts": <n7> } }`
#### Scenario: Reused route does not increment createdRoutes
- **WHEN** every model in the working set has a pre-existing
`token_routes` row
- **THEN** `createdRoutes = 0` and `reusedRoutes` equals the number of
distinct models scanned
### Requirement: Auto-rebuild after account creation
The system SHALL fire one route rebuild in the background after
`POST /api/accounts` returns success for at least one created
account. The HTTP response SHALL return without waiting for the
rebuild to finish. Errors from the background rebuild SHALL be logged
at `warn` level and SHALL NOT affect the response status or body.
#### Scenario: Single-account create triggers rebuild
- **WHEN** `POST /api/accounts` creates exactly one account
- **THEN** `spawn_route_rebuild` is invoked once with the cloned
`AppState` and the response returns immediately
#### Scenario: Batch-create with all failures does not trigger
- **WHEN** `POST /api/accounts` returns with `created.len() == 0` and
`failed.len() > 0`
- **THEN** `spawn_route_rebuild` is not invoked
+18 -72
View File
@@ -2,12 +2,12 @@
"name": "boosapi",
"version": "1.3.0",
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
"main": "dist/desktop/main.js",
"main": "dist/web/index.html",
"keywords": [
"ai",
"api-gateway",
"proxy",
"fastify",
"rust",
"openai",
"claude",
"aggregator"
@@ -22,97 +22,45 @@
},
"license": "MIT",
"engines": {
"node": ">=25.0.0"
"node": ">=22.0.0"
},
"type": "module",
"scripts": {
"dev:server": "tsx scripts/dev/run-server.ts --watch",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
"build:web": "npm run desktop:icons && vite build",
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
"build:desktop": "tsc -p tsconfig.desktop.json",
"build": "npm run build:web && npm run build:server && npm run build:desktop",
"dev": "vite",
"build:web": "vite build",
"build": "npm run build:web",
"typecheck:web": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
"package:desktop": "npm run dist:desktop",
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
"website:dev": "npm --prefix website run dev",
"website:build": "npm --prefix website run build",
"website:start": "npm --prefix website run start",
"website:typecheck": "npm --prefix website run typecheck",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"start": "node dist/server/index.js",
"start:deploy-helper": "node dist/server/update-helper/index.js",
"start:desktop": "electron dist/desktop/main.js",
"comfyui:install": "node scripts/comfyui/install.mjs",
"comfyui:start": "node scripts/comfyui/start.mjs",
"comfyui:stop": "node scripts/comfyui/stop.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/server/db/migrate.ts",
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
"schema:generate": "npm run db:generate && npm run schema:contract",
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
"smoke:db": "tsx scripts/dev/db-smoke.ts",
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
"test": "vitest run --root .",
"test:watch": "vitest --root ."
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"test": "vitest run --root src/web",
"test:watch": "vitest --root src/web"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@visactor/react-vchart": "^2.0.21",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"fastify": "^5.8.4",
"get-port": "^7.2.0",
"gsap": "^3.15.0",
"marked": "^17.0.5",
"minimatch": "^10.2.5",
"minimist": "^1.2.8",
"mysql2": "^3.20.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.4",
"pg": "^8.16.3",
"rate-limiter-flexible": "^11.0.0",
"socks": "^2.8.7",
"undici": "^6.24.1",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@electron/notarize": "^3.1.0",
"@tailwindcss/vite": "^4.2.2",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.10",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.2",
"mermaid": "^11.14.0",
"react": "^18.3.1",
@@ -121,13 +69,11 @@
"react-test-renderer": "^18.3.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^6.0.2",
"vite": "^6.0.3",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^2.1.8",
"wait-on": "^9.0.4"
"vitest": "^2.1.8"
},
"overrides": {
"minimist": "^1.2.8"
+82 -3768
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from 'fs/promises';
const DEFAULT_PACKS = [
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
];
const args = new Set(process.argv.slice(2));
const configPath = valueArg('--config') || 'config.toml';
const baseUrl = trimSlash(
process.env.BOOSAPI_COMFYUI__BASE_URL
|| process.env.COMFYUI_CLOUD_BASE_URL
|| (await readComfyConfig(configPath)).base_url
|| 'https://cloud.comfy.org'
);
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|| process.env.COMFYUI_CLOUD_API_KEY
|| (await readComfyConfig(configPath)).api_key
|| '';
const dryRun = args.has('--dry-run');
if (!baseUrl) {
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
}
if (!apiKey) {
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
}
console.log(`Cloud backend: ${baseUrl}`);
console.log(`Packs: ${DEFAULT_PACKS.length}`);
if (dryRun) {
for (const [id, repository] of DEFAULT_PACKS) {
console.log(`[dry-run] ${id} <- ${repository}`);
}
process.exit(0);
}
await request('GET', '/api/manager/queue/start').catch((error) => {
console.warn(`manager queue start warning: ${error.message}`);
});
const results = [];
for (const [id, repository] of DEFAULT_PACKS) {
const payload = {
id,
version: 'nightly',
selected_version: 'nightly',
repository,
mode: 'remote',
channel: 'default',
skip_post_install: false,
};
try {
await request('POST', '/api/manager/queue/install', payload);
console.log(`queued: ${id}`);
results.push({ id, ok: true });
} catch (error) {
console.error(`failed: ${id}: ${error.message}`);
results.push({ id, ok: false, error: error.message });
}
}
await request('GET', '/api/manager/queue/status')
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
.catch((error) => console.warn(`queue status warning: ${error.message}`));
const failed = results.filter((item) => !item.ok);
if (failed.length > 0) {
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
process.exit(1);
}
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
async function request(method, path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-API-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const data = text ? safeJson(text) : null;
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
}
return data;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { body: text };
}
}
async function readComfyConfig(path) {
const result = {};
let section = '';
let text = '';
try {
text = await fs.readFile(path, 'utf8');
} catch {
return result;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1];
continue;
}
if (section !== 'comfyui') continue;
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
if (match) result[match[1]] = match[2];
}
return result;
}
function trimSlash(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function valueArg(name) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : '';
}
function fail(message) {
console.error(message);
process.exit(2);
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
if [ ! -d "$COMFYUI_DIR" ]; then
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
exit 1
fi
if [ ! -x "$PYTHON_BIN" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
else
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
exit 1
fi
fi
mkdir -p "$CUSTOM_NODES_DIR"
install_node() {
local name="$1"
local repo="$2"
local ref="${3:-}"
local dir="$CUSTOM_NODES_DIR/$name"
if [ -d "$dir/.git" ]; then
echo "==> Updating $name"
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
else
echo "==> Installing $name"
git clone --depth 1 "$repo" "$dir"
fi
if [ -n "$ref" ]; then
git -C "$dir" checkout --quiet "$ref"
fi
if [ -f "$dir/requirements.txt" ]; then
echo "==> Installing Python requirements for $name"
if command -v uv >/dev/null 2>&1; then
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
else
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
fi
fi
if [ -f "$dir/install.py" ]; then
echo "==> Running install.py for $name"
"$PYTHON_BIN" "$dir/install.py" || true
fi
}
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
# for local/docker/external ComfyUI instances.
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
# Nodes used by common RunningHub digital-human / video templates.
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
cat <<'EOF'
RunningHub template nodes installed.
Notes:
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
node requires them at runtime.
- The screenshot also references a "soundflow" package. I did not find a stable
public repository for that exact package name; install it through
ComfyUI-Manager or add its repository manually when you have the source URL.
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
nodes. Put those models into the matching ComfyUI models folders or use a
backend where RunningHub/Cloud already provides them.
EOF
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Build the BossAI production binary, package Alpine/musl runtime libraries,
# and deploy the runnable bundle to a Debian server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
BOSSAI_REF="${BOSSAI_REF:-main}"
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
SKIP_BUILD=0
SKIP_VERIFY=0
SKIP_SOURCE_UPDATE=0
usage() {
cat <<'EOF'
Usage: scripts/deploy-bossai-debian.sh [options]
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
needed by the Alpine-built binary, upload it to a Debian host, extract it,
and run a config check.
Options:
--host <host> Debian host/IP. Default: 129.211.30.104
--user <user> SSH user. Default: root
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
--remote <name> Git remote for BossAI. Default: bossai
--ref <ref> BossAI branch/ref to build. Default: main
--skip-build Reuse dist/bossai-production from a previous build
--skip-source-update Do not fetch/reset the BossAI source worktree
--skip-verify Skip remote ./run-boosapi.sh --check-config
-h, --help Show this help
Environment overrides:
BOSSAI_DEBIAN_HOST
BOSSAI_DEBIAN_USER
BOSSAI_DEBIAN_DIR
BOSSAI_DEBIAN_SSH_KEY
BOSSAI_SOURCE_DIR
BOSSAI_REMOTE
BOSSAI_REF
BOSSAI_WORKFLOW_UI_SOURCE
BOSSAI_PRODUCTION_DIR
BOSSAI_DEBIAN_BUNDLE_DIR
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
Remote start command after deploy:
cd /root/BossAI && ./run-boosapi.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host)
shift
[ $# -gt 0 ] || fail "--host requires a value"
REMOTE_HOST="$1"
;;
--user)
shift
[ $# -gt 0 ] || fail "--user requires a value"
REMOTE_USER="$1"
;;
--dir)
shift
[ $# -gt 0 ] || fail "--dir requires a value"
REMOTE_DIR="$1"
;;
--key)
shift
[ $# -gt 0 ] || fail "--key requires a value"
SSH_KEY="$1"
;;
--source-dir)
shift
[ $# -gt 0 ] || fail "--source-dir requires a value"
BOSSAI_SOURCE_DIR="$1"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
;;
--remote)
shift
[ $# -gt 0 ] || fail "--remote requires a value"
BOSSAI_REMOTE="$1"
;;
--ref)
shift
[ $# -gt 0 ] || fail "--ref requires a value"
BOSSAI_REF="$1"
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-source-update)
SKIP_SOURCE_UPDATE=1
;;
--skip-verify)
SKIP_VERIFY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_file() {
local path="$1"
[ -f "$path" ] || fail "Missing required file: $path"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
ensure_bossai_source() {
require_command git
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
return 0
fi
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
git -C "$BOSSAI_SOURCE_DIR" clean -fd
else
rm -rf "$BOSSAI_SOURCE_DIR"
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
fi
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
}
prepare_workflow_ui_source() {
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
return 0
fi
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
return 0
fi
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
}
runtime_lib() {
local path="$1"
require_file "$path"
cp "$path" "$BUNDLE_DIR/lib/"
}
write_runner() {
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
#!/usr/bin/env sh
set -eu
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
EOF
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
}
package_bundle() {
require_file "$PRODUCTION_DIR/boosapi"
require_file "$PRODUCTION_DIR/config.toml"
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
mkdir -p "$BUNDLE_DIR/lib"
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
chmod +x "$BUNDLE_DIR/boosapi"
runtime_lib /lib/ld-musl-x86_64.so.1
runtime_lib /usr/lib/libssl.so.3
runtime_lib /usr/lib/libcrypto.so.3
runtime_lib /usr/lib/libgcc_s.so.1
write_runner
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
ok "Bundle ready: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
ls -lh "$BUNDLE_ARCHIVE"
}
deploy_bundle() {
require_file "$SSH_KEY"
require_command ssh
require_command scp
local remote="${REMOTE_USER}@${REMOTE_HOST}"
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
log "Preparing remote directory: $remote:$REMOTE_DIR"
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
log "Uploading bundle..."
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
log "Extracting bundle on Debian host..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
if [ "$SKIP_VERIFY" -eq 0 ]; then
log "Checking remote config..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
fi
ok "Deployed BossAI to $remote:$REMOTE_DIR"
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
}
if [ "$SKIP_BUILD" -eq 0 ]; then
ensure_bossai_source
prepare_workflow_ui_source
log "Building BossAI production bundle..."
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
--production \
--production-dir "$PRODUCTION_DIR"
else
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
log "Skipping build; using existing $PRODUCTION_DIR"
fi
package_bundle
deploy_bundle
+138
View File
@@ -0,0 +1,138 @@
import fs from 'fs/promises';
import path from 'path';
import { spawn } from 'child_process';
const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
const DEFAULT_CLOUD_REF = 'v1.46.2';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
const variants = {
external: {
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_EXTERNAL_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
description: 'External/RunningHub compatible frontend',
},
cloud: {
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_CLOUD_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
description: 'Comfy Cloud frontend',
},
};
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, ...(options.env || {}) };
if (options.cleanPackageEnv) {
Object.keys(env)
.filter((key) => key.startsWith('npm_package_'))
.forEach((key) => {
delete env[key];
});
Object.assign(env, options.env || {});
}
const child = spawn(command, args, {
cwd: options.cwd || ROOT,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`));
});
});
}
function selectedVariants() {
if (VARIANT === 'all') return Object.entries(variants);
if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
}
async function readPackageInfo(dir) {
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
return JSON.parse(raw);
}
async function buildVariant(name, config) {
const ref = config.ref;
const dist = config.dist;
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
await fs.rm(dir, { recursive: true, force: true });
if (REPO) {
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
} else {
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
if (!hasSubmodule) {
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
}
await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
}
const packageInfo = await readPackageInfo(dir);
const packageEnv = {
npm_package_name: packageInfo.name || '',
npm_package_version: packageInfo.version || ref.replace(/^v/, ''),
};
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
} else {
await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
}
await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
await fs.writeFile(
path.join(dist, 'boosapi-workflow-ui.json'),
`${JSON.stringify({
variant: name,
ref,
packageName: packageInfo.name || null,
packageVersion: packageInfo.version || null,
}, null, 2)}\n`,
);
console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') {
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
}
}
for (const [name, config] of selectedVariants()) {
await buildVariant(name, config);
}
+2471
View File
File diff suppressed because it is too large Load Diff
+230
View File
@@ -13,6 +13,10 @@ pub struct AppConfig {
pub proxy: ProxyConfig,
#[serde(default)]
pub comfyui: ComfyUIConfig,
#[serde(default)]
pub pixelle_video: PixelleVideoConfig,
#[serde(default)]
pub payment: PaymentConfig,
}
#[derive(Debug, Deserialize, Clone)]
@@ -47,11 +51,25 @@ pub struct ProxyConfig {
pub first_byte_timeout_secs: u64,
pub max_retries: u32,
pub proxy_log_retention_days: u32,
#[serde(default)]
pub template_proxy_url: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ComfyUIConfig {
pub enabled: bool,
#[serde(default)]
pub backend: ComfyUIBackend,
#[serde(default)]
pub money_printer_video_cost: f64,
#[serde(default)]
pub digital_human_video_cost: f64,
#[serde(default = "default_workflow_points_cost")]
pub workflow_points_cost: f64,
#[serde(default = "default_comfyui_docker_image")]
pub docker_image: String,
#[serde(default = "default_comfyui_container_name")]
pub container_name: String,
pub install_dir: Option<PathBuf>,
pub python_bin: String,
pub port: u16,
@@ -59,13 +77,116 @@ pub struct ComfyUIConfig {
pub max_restarts: u32,
pub external_url: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub agent_model: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PixelleVideoConfig {
pub enabled: bool,
pub api_base_url: Option<String>,
pub api_key: Option<String>,
#[serde(default = "default_pixelle_backend_provider")]
pub backend_provider: String,
pub frame_template: String,
pub media_workflow: Option<String>,
pub tts_workflow: Option<String>,
pub runninghub_media_workflow: Option<String>,
pub runninghub_tts_workflow: Option<String>,
pub mode: String,
pub n_scenes: i64,
pub video_fps: i64,
pub poll_interval_secs: u64,
pub timeout_secs: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PaymentConfig {
pub enabled: bool,
#[serde(default)]
pub provider: PaymentProvider,
pub public_base_url: Option<String>,
pub product_name_prefix: String,
pub currency: String,
pub min_amount_usd: f64,
pub max_amount_usd: Option<f64>,
pub daily_limit_usd: Option<f64>,
pub order_timeout_minutes: i64,
pub max_pending_orders: i64,
pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>,
pub comfy_customer_token: Option<String>,
pub comfy_customer_token_endpoint: Option<String>,
pub comfy_firebase_api_key: Option<String>,
pub comfy_oauth_client_id: Option<String>,
pub comfy_oauth_client_secret: Option<String>,
pub comfy_oauth_refresh_token: Option<String>,
pub comfy_customer_api_base_url: Option<String>,
pub comfy_checkout_endpoint: Option<String>,
pub success_path: String,
pub cancel_path: String,
/// When true, confirming a comfy_cloud recharge grants workflow points
/// (amount_usd * usd_to_points_rate) instead of crediting the USD pool.
#[serde(default = "default_comfy_recharge_to_points")]
pub comfy_recharge_to_points: bool,
/// Points granted per 1 USD when comfy_recharge_to_points is enabled.
#[serde(default = "default_usd_to_points_rate")]
pub usd_to_points_rate: f64,
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PaymentProvider {
#[default]
ComfyCloud,
Stripe,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum ComfyUIBackend {
#[default]
Boosapi,
External,
Cloud,
Runninghub,
Docker,
}
fn default_comfyui_docker_image() -> String {
"boosapi-comfyui:multi-tenant".to_string()
}
fn default_comfyui_container_name() -> String {
"boosapi-comfyui-workflow".to_string()
}
fn default_pixelle_backend_provider() -> String {
"comfy".to_string()
}
fn default_workflow_points_cost() -> f64 {
10.0
}
fn default_comfy_recharge_to_points() -> bool {
true
}
fn default_usd_to_points_rate() -> f64 {
100.0
}
impl Default for ComfyUIConfig {
fn default() -> Self {
Self {
enabled: false,
backend: ComfyUIBackend::Boosapi,
money_printer_video_cost: 0.0,
digital_human_video_cost: 0.0,
workflow_points_cost: 10.0,
docker_image: default_comfyui_docker_image(),
container_name: default_comfyui_container_name(),
install_dir: None,
python_bin: "python3".to_string(),
port: 8188,
@@ -73,12 +194,121 @@ impl Default for ComfyUIConfig {
max_restarts: 5,
external_url: None,
base_url: Some("http://127.0.0.1:8188".to_string()),
api_key: None,
agent_model: "gpt-4o".to_string(),
}
}
}
impl Default for PixelleVideoConfig {
fn default() -> Self {
Self {
enabled: false,
api_base_url: Some("http://127.0.0.1:8000".to_string()),
api_key: None,
backend_provider: default_pixelle_backend_provider(),
frame_template: "1080x1920/image_default.html".to_string(),
media_workflow: None,
tts_workflow: None,
runninghub_media_workflow: Some("runninghub/video_wan2.1_fusionx.json".to_string()),
runninghub_tts_workflow: Some("runninghub/tts_edge.json".to_string()),
mode: "fixed".to_string(),
n_scenes: 5,
video_fps: 30,
poll_interval_secs: 5,
timeout_secs: 900,
}
}
}
impl Default for PaymentConfig {
fn default() -> Self {
Self {
enabled: false,
provider: PaymentProvider::ComfyCloud,
public_base_url: None,
product_name_prefix: "BossAI 账户充值".to_string(),
currency: "usd".to_string(),
min_amount_usd: 1.0,
max_amount_usd: Some(100_000.0),
daily_limit_usd: None,
order_timeout_minutes: 30,
max_pending_orders: 3,
stripe_secret_key: None,
stripe_webhook_secret: None,
comfy_customer_token: None,
comfy_customer_token_endpoint: None,
comfy_firebase_api_key: None,
comfy_oauth_client_id: None,
comfy_oauth_client_secret: None,
comfy_oauth_refresh_token: None,
comfy_customer_api_base_url: None,
comfy_checkout_endpoint: None,
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
comfy_recharge_to_points: true,
usd_to_points_rate: 100.0,
}
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: vec!["*".to_string()],
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".to_string(),
jwt_expiry_hours: 72,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 300,
first_byte_timeout_secs: 60,
max_retries: 3,
proxy_log_retention_days: 30,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
pixelle_video: PixelleVideoConfig::default(),
payment: PaymentConfig::default(),
}
}
}
impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool {
matches!(
self.backend,
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
)
}
pub fn uses_cloud_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Cloud)
}
pub fn uses_runninghub_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Runninghub)
}
pub fn uses_docker_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Docker)
}
pub fn target_base_url(&self) -> String {
self.external_url
.as_deref()
+118 -1
View File
@@ -1,12 +1,129 @@
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
any::{install_default_drivers, AnyPoolOptions},
postgres::{PgPool, PgPoolOptions},
sqlite::{SqlitePool, SqlitePoolOptions},
};
pub mod models;
pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DbKind {
Sqlite,
Postgres,
}
impl DbKind {
pub fn from_url(database_url: &str) -> anyhow::Result<Self> {
let normalized = database_url.trim().to_ascii_lowercase();
if normalized.starts_with("sqlite:") {
Ok(Self::Sqlite)
} else if normalized.starts_with("postgres://") || normalized.starts_with("postgresql://") {
Ok(Self::Postgres)
} else {
bail!("unsupported database URL; expected sqlite:, postgres:// or postgresql://")
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Sqlite => "sqlite",
Self::Postgres => "postgres",
}
}
}
pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result<SqlitePool> {
if DbKind::from_url(database_url)? != DbKind::Sqlite {
bail!(
"runtime PostgreSQL query support is not enabled yet; use migrate-sqlite-to-postgres to prepare PostgreSQL data"
);
}
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.after_connect(|conn, _meta| {
Box::pin(async move {
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA busy_timeout = 5000")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&mut *conn)
.await?;
Ok(())
})
})
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn create_pg_pool(database_url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect PostgreSQL")?;
Ok(pool)
}
pub async fn test_connection(database_url: &str, max_connections: u32) -> anyhow::Result<DbKind> {
match DbKind::from_url(database_url)? {
DbKind::Sqlite => {
SqlitePoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect SQLite")?
.close()
.await;
Ok(DbKind::Sqlite)
}
DbKind::Postgres => {
create_pg_pool(database_url, max_connections)
.await?
.close()
.await;
Ok(DbKind::Postgres)
}
}
}
pub async fn run_migrations(
kind: DbKind,
sqlite_pool: Option<&SqlitePool>,
pg_pool: Option<&PgPool>,
) -> anyhow::Result<()> {
match kind {
DbKind::Sqlite => {
let pool = sqlite_pool.context("SQLite pool required for SQLite migrations")?;
sqlx::migrate!("./migrations").run(pool).await?;
}
DbKind::Postgres => {
let pool = pg_pool.context("PostgreSQL pool required for PostgreSQL migrations")?;
sqlx::migrate!("./migrations_pg").run(pool).await?;
}
}
Ok(())
}
pub async fn create_any_pool(
database_url: &str,
max_connections: u32,
) -> anyhow::Result<sqlx::AnyPool> {
install_default_drivers();
Ok(AnyPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?)
}
+383
View File
@@ -0,0 +1,383 @@
use anyhow::Context;
use sqlx::{Row, SqlitePool};
use std::collections::HashSet;
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
ensure_columns(
pool,
"sites",
&[
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
("post_refresh_probe_model", "TEXT DEFAULT ''"),
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
(
"post_refresh_probe_latency_threshold_ms",
"INTEGER DEFAULT 0",
),
],
)
.await?;
ensure_columns(
pool,
"downstream_api_keys",
&[
("excluded_site_ids", "TEXT"),
("excluded_credential_refs", "TEXT"),
],
)
.await?;
ensure_columns(
pool,
"route_channels",
&[("oauth_route_unit_id", "INTEGER")],
)
.await?;
ensure_columns(
pool,
"proxy_logs",
&[
("client_family", "TEXT"),
("client_app_id", "TEXT"),
("client_app_name", "TEXT"),
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
("billing_details", "TEXT"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
],
)
.await?;
run_compat_ddl(pool).await?;
Ok(())
}
async fn ensure_columns(
pool: &SqlitePool,
table: &str,
columns: &[(&str, &str)],
) -> anyhow::Result<()> {
let existing = table_columns(pool, table).await?;
if existing.is_empty() {
return Ok(());
}
for (name, definition) in columns {
if existing.contains(*name) {
continue;
}
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("add column {table}.{name}"))?;
}
Ok(())
}
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
let sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&sql).fetch_all(pool).await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.collect())
}
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
for statement in SQLITE_COMPAT_DDL {
sqlx::query(statement)
.execute(pool)
.await
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
}
Ok(())
}
const SQLITE_COMPAT_DDL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary)",
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC)",
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id)",
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at)",
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index)",
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at)",
"CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled)",
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until)",
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at)",
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_spend >= 0
AND total_latency_ms >= 0 AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model)",
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id)",
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model)",
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id)",
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id)",
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id)",
];
+657
View File
@@ -0,0 +1,657 @@
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
postgres::PgPool,
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::collections::HashMap;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationSummary {
pub tables: Vec<TableMigrationSummary>,
pub total_rows: i64,
pub force_truncate: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableMigrationSummary {
pub table: &'static str,
pub rows: i64,
}
#[derive(Debug, Clone, Copy)]
enum ColumnKind {
I64,
F64,
Bool,
Text,
}
#[derive(Debug, Clone, Copy)]
struct ColumnSpec {
name: &'static str,
kind: ColumnKind,
}
#[derive(Debug, Clone, Copy)]
struct TableSpec {
name: &'static str,
columns: &'static [ColumnSpec],
}
macro_rules! col {
($name:literal, i64) => {
ColumnSpec {
name: $name,
kind: ColumnKind::I64,
}
};
($name:literal, f64) => {
ColumnSpec {
name: $name,
kind: ColumnKind::F64,
}
};
($name:literal, bool) => {
ColumnSpec {
name: $name,
kind: ColumnKind::Bool,
}
};
($name:literal, text) => {
ColumnSpec {
name: $name,
kind: ColumnKind::Text,
}
};
}
const TABLES: &[TableSpec] = &[
TableSpec {
name: "users",
columns: &[
col!("id", i64),
col!("username", text),
col!("email", text),
col!("password_hash", text),
col!("role", text),
col!("status", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "sites",
columns: &[
col!("id", i64),
col!("name", text),
col!("url", text),
col!("platform", text),
col!("status", text),
col!("api_key", text),
col!("proxy_url", text),
col!("use_system_proxy", bool),
col!("custom_headers", text),
col!("external_checkin_url", text),
col!("global_weight", f64),
col!("post_refresh_probe_enabled", bool),
col!("post_refresh_probe_model", text),
col!("post_refresh_probe_scope", text),
col!("post_refresh_probe_latency_threshold_ms", i64),
col!("is_pinned", bool),
col!("sort_order", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "accounts",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("username", text),
col!("access_token", text),
col!("api_token", text),
col!("balance", f64),
col!("balance_used", f64),
col!("quota", f64),
col!("unit_cost", f64),
col!("value_score", f64),
col!("status", text),
col!("checkin_enabled", bool),
col!("last_checkin_at", text),
col!("last_balance_refresh", text),
col!("extra_config", text),
col!("is_pinned", bool),
col!("sort_order", i64),
col!("oauth_provider", text),
col!("oauth_account_key", text),
col!("oauth_project_id", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "account_tokens",
columns: &[
col!("id", i64),
col!("account_id", i64),
col!("name", text),
col!("token", text),
col!("source", text),
col!("token_group", text),
col!("value_status", text),
col!("enabled", bool),
col!("is_default", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "downstream_api_keys",
columns: &[
col!("id", i64),
col!("name", text),
col!("key", text),
col!("description", text),
col!("group_name", text),
col!("tags", text),
col!("enabled", bool),
col!("expires_at", text),
col!("max_cost", f64),
col!("used_cost", f64),
col!("max_requests", i64),
col!("used_requests", i64),
col!("supported_models", text),
col!("allowed_route_ids", text),
col!("site_weight_multipliers", text),
col!("excluded_site_ids", text),
col!("excluded_credential_refs", text),
col!("last_used_at", text),
col!("user_id", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "token_routes",
columns: &[
col!("id", i64),
col!("model_pattern", text),
col!("model_mapping", text),
col!("display_name", text),
col!("display_icon", text),
col!("decision_snapshot", text),
col!("decision_refreshed_at", text),
col!("routing_strategy", text),
col!("route_mode", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "route_channels",
columns: &[
col!("id", i64),
col!("route_id", i64),
col!("account_id", i64),
col!("token_id", i64),
col!("oauth_route_unit_id", i64),
col!("source_model", text),
col!("priority", i64),
col!("weight", i64),
col!("enabled", bool),
col!("manual_override", bool),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
],
},
TableSpec {
name: "proxy_logs",
columns: &[
col!("id", i64),
col!("route_id", i64),
col!("channel_id", i64),
col!("account_id", i64),
col!("downstream_api_key_id", i64),
col!("model_requested", text),
col!("model_actual", text),
col!("status", text),
col!("http_status", i64),
col!("latency_ms", i64),
col!("first_byte_latency_ms", i64),
col!("prompt_tokens", i64),
col!("completion_tokens", i64),
col!("total_tokens", i64),
col!("estimated_cost", f64),
col!("error_message", text),
col!("retry_count", i64),
col!("is_stream", bool),
col!("client_family", text),
col!("client_app_id", text),
col!("client_app_name", text),
col!("client_confidence", text),
col!("billing_details", text),
col!("created_at", text),
],
},
TableSpec {
name: "settings",
columns: &[col!("key", text), col!("value", text)],
},
TableSpec {
name: "workspace_assets",
columns: &[
col!("id", i64),
col!("public_id", text),
col!("user_id", i64),
col!("source", text),
col!("kind", text),
col!("title", text),
col!("prompt", text),
col!("model", text),
col!("status", text),
col!("mime_type", text),
col!("file_name", text),
col!("file_path", text),
col!("remote_url", text),
col!("thumbnail_path", text),
col!("size_bytes", i64),
col!("metadata", text),
col!("created_at", text),
col!("updated_at", text),
col!("deleted_at", text),
],
},
TableSpec {
name: "user_announcements",
columns: &[
col!("id", i64),
col!("title", text),
col!("content", text),
col!("level", text),
col!("status", text),
col!("audience", text),
col!("starts_at", text),
col!("ends_at", text),
col!("created_at", text),
col!("updated_at", text),
col!("published_at", text),
],
},
TableSpec {
name: "user_announcement_reads",
columns: &[
col!("announcement_id", i64),
col!("user_id", i64),
col!("read_at", text),
],
},
TableSpec {
name: "site_api_endpoints",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("url", text),
col!("enabled", bool),
col!("sort_order", i64),
col!("cooldown_until", text),
col!("last_selected_at", text),
col!("last_failed_at", text),
col!("last_failure_reason", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_units",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("provider", text),
col!("name", text),
col!("strategy", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_unit_members",
columns: &[
col!("id", i64),
col!("unit_id", i64),
col!("account_id", i64),
col!("sort_order", i64),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_traces",
columns: &[
col!("id", i64),
col!("downstream_path", text),
col!("client_kind", text),
col!("session_id", text),
col!("trace_hint", text),
col!("requested_model", text),
col!("downstream_api_key_id", i64),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("sticky_session_key", text),
col!("sticky_hit_channel_id", i64),
col!("selected_channel_id", i64),
col!("selected_route_id", i64),
col!("selected_account_id", i64),
col!("selected_site_id", i64),
col!("selected_site_platform", text),
col!("endpoint_candidates_json", text),
col!("endpoint_runtime_state_json", text),
col!("decision_summary_json", text),
col!("final_status", text),
col!("final_http_status", i64),
col!("final_upstream_path", text),
col!("final_response_headers_json", text),
col!("final_response_body_json", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_attempts",
columns: &[
col!("id", i64),
col!("trace_id", i64),
col!("attempt_index", i64),
col!("endpoint", text),
col!("request_path", text),
col!("target_url", text),
col!("runtime_executor", text),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("response_status", i64),
col!("response_headers_json", text),
col!("response_body_json", text),
col!("raw_error_text", text),
col!("recover_applied", bool),
col!("downgrade_decision", bool),
col!("downgrade_reason", text),
col!("memory_write_json", text),
col!("created_at", text),
],
},
TableSpec {
name: "admin_snapshots",
columns: &[
col!("id", i64),
col!("namespace", text),
col!("snapshot_key", text),
col!("payload", text),
col!("generated_at", text),
col!("expires_at", text),
col!("stale_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "analytics_projection_checkpoints",
columns: &[
col!("projector_key", text),
col!("time_zone", text),
col!("last_proxy_log_id", i64),
col!("watermark_created_at", text),
col!("recompute_from_id", i64),
col!("recompute_requested_at", text),
col!("recompute_reason", text),
col!("recompute_started_at", text),
col!("recompute_completed_at", text),
col!("last_projected_at", text),
col!("last_successful_at", text),
col!("last_error", text),
col!("lease_owner", text),
col!("lease_token", text),
col!("lease_expires_at", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "model_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("model", text),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_hour_usage",
columns: &[
col!("id", i64),
col!("bucket_start_utc", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
];
pub async fn migrate(
sqlite: &SqlitePool,
pg: &PgPool,
force_truncate: bool,
) -> anyhow::Result<MigrationSummary> {
ensure_target_is_empty(pg, force_truncate).await?;
if force_truncate {
truncate_target(pg).await?;
}
let mut summaries = Vec::new();
let mut total_rows = 0;
for table in TABLES {
let rows = copy_table(sqlite, pg, table).await?;
summaries.push(TableMigrationSummary {
table: table.name,
rows,
});
total_rows += rows;
}
sync_sequences(pg).await?;
Ok(MigrationSummary {
tables: summaries,
total_rows,
force_truncate,
})
}
async fn ensure_target_is_empty(pg: &PgPool, force_truncate: bool) -> anyhow::Result<()> {
if force_truncate {
return Ok(());
}
let mut non_empty = Vec::new();
for table in TABLES {
let sql = format!("SELECT COUNT(*) FROM {}", table.name);
let count: i64 = sqlx::query_scalar(&sql).fetch_one(pg).await.unwrap_or(0);
if count > 0 {
non_empty.push(format!("{} ({})", table.name, count));
}
}
if !non_empty.is_empty() {
bail!(
"target PostgreSQL database is not empty: {}; rerun with --force-truncate to overwrite",
non_empty.join(", ")
);
}
Ok(())
}
async fn truncate_target(pg: &PgPool) -> anyhow::Result<()> {
let table_names = TABLES
.iter()
.rev()
.map(|table| table.name)
.collect::<Vec<_>>()
.join(", ");
let sql = format!("TRUNCATE TABLE {} RESTART IDENTITY CASCADE", table_names);
sqlx::query(&sql).execute(pg).await?;
Ok(())
}
async fn copy_table(sqlite: &SqlitePool, pg: &PgPool, table: &TableSpec) -> anyhow::Result<i64> {
let available = sqlite_columns(sqlite, table.name).await?;
let columns = table
.columns
.iter()
.copied()
.filter(|column| available.contains_key(column.name))
.collect::<Vec<_>>();
if columns.is_empty() {
return Ok(0);
}
let select_sql = format!(
"SELECT {} FROM {} ORDER BY {}",
columns
.iter()
.map(|column| column.name)
.collect::<Vec<_>>()
.join(", "),
table.name,
if table.name == "settings" {
"key"
} else {
columns[0].name
}
);
let rows = sqlx::query(&select_sql).fetch_all(sqlite).await?;
let insert_sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
table.name,
columns
.iter()
.map(|column| quote_ident(column.name))
.collect::<Vec<_>>()
.join(", "),
(1..=columns.len())
.map(|index| format!("${index}"))
.collect::<Vec<_>>()
.join(", ")
);
for row in &rows {
let mut query = sqlx::query(&insert_sql);
for column in &columns {
query = bind_column(query, row, column)?;
}
query
.execute(pg)
.await
.with_context(|| format!("insert into {}", table.name))?;
}
Ok(rows.len() as i64)
}
fn bind_column<'q>(
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
row: &SqliteRow,
column: &ColumnSpec,
) -> anyhow::Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>> {
Ok(match column.kind {
ColumnKind::I64 => query.bind(row.try_get::<Option<i64>, _>(column.name)?),
ColumnKind::F64 => query.bind(row.try_get::<Option<f64>, _>(column.name)?),
ColumnKind::Bool => query.bind(row.try_get::<Option<bool>, _>(column.name)?),
ColumnKind::Text => query.bind(row.try_get::<Option<String>, _>(column.name)?),
})
}
async fn sqlite_columns(sqlite: &SqlitePool, table: &str) -> anyhow::Result<HashMap<String, ()>> {
let rows = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(sqlite)
.await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.map(|name| (name, ()))
.collect())
}
async fn sync_sequences(pg: &PgPool) -> anyhow::Result<()> {
for table in TABLES
.iter()
.filter(|table| table.columns.iter().any(|column| column.name == "id"))
{
let sql = format!(
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE((SELECT MAX(id) FROM {table}), 1), (SELECT COUNT(*) FROM {table}) > 0)",
table = table.name
);
let _ = sqlx::query(&sql).execute(pg).await;
}
Ok(())
}
fn quote_ident(name: &str) -> String {
if name == "read" {
"\"read\"".to_string()
} else {
name.to_string()
}
}
+26 -1
View File
@@ -24,6 +24,12 @@ pub enum AppError {
#[error("Rate limited")]
RateLimited,
#[error("Rate limited: {message}")]
RateLimitedWithRetry {
message: String,
retry_after_secs: u64,
},
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
@@ -46,18 +52,37 @@ impl IntoResponse for AppError {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
AppError::RateLimitedWithRetry { message, .. } => {
(StatusCode::TOO_MANY_REQUESTS, message.clone())
}
AppError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
let retry_after_secs = match &self {
AppError::RateLimitedWithRetry {
retry_after_secs, ..
} => Some(*retry_after_secs),
_ => None,
};
let billing = match &self {
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
"reason": "insufficient_quota",
"rechargePath": "/user/recharge",
"usagePath": "/user/usage",
})),
_ => None,
};
(
status,
Json(json!({
"error": {
"message": message,
"type": status.canonical_reason(),
}
},
"retry_after_secs": retry_after_secs,
"billing": billing,
})),
)
.into_response()
+30 -13
View File
@@ -6,18 +6,23 @@ pub mod proxy;
pub mod routes;
pub mod services;
pub mod shared;
pub mod template_http;
pub mod transformers;
pub mod workflow;
use sqlx::SqlitePool;
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
#[derive(Clone)]
pub struct AppState {
pub config: config::AppConfig,
pub db: Arc<SqlitePool>,
pub http: reqwest::Client,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub comfyui_pool: Arc<services::comfyui_pool::ComfyUIPool>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
}
impl AppState {
@@ -25,6 +30,7 @@ impl AppState {
config: config::AppConfig,
db: SqlitePool,
builtin_keys: services::builtin_keys::BuiltinKeys,
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
) -> Self {
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
"127.0.0.1".to_string()
@@ -36,25 +42,36 @@ impl AppState {
let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(),
boosapi_url.clone(),
config.auth.proxy_token.clone(),
runtime_auth.proxy_token(),
comfyui_agent_secret.clone(),
);
// Per-user multi-tenant ComfyUI pool. Backed by Docker today; the
// ContainerRuntime trait makes K3S a drop-in v2.
let docker = Arc::new(services::docker_runtime::DockerRuntime::new());
let pool_cfg = services::comfyui_pool::PoolConfig::from_env(
format!("{}/v1", boosapi_url.trim_end_matches('/')),
comfyui_agent_secret,
);
let comfyui_pool = services::comfyui_pool::ComfyUIPool::new(docker, pool_cfg);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
.connect_timeout(Duration::from_secs(15))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(64)
.tcp_nodelay(true)
.build()
.unwrap_or_else(|err| {
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
reqwest::Client::new()
});
Self {
config,
db: Arc::new(db),
http,
comfyui,
comfyui_pool,
builtin_keys: Arc::new(builtin_keys),
runtime_auth: Arc::new(runtime_auth),
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
registration_limiter: Arc::new(
services::registration_limiter::RegistrationLimiter::new(),
),
}
}
pub fn runtime_proxy_token(&self) -> String {
self.runtime_auth.proxy_token()
}
}
+60 -28
View File
@@ -1,11 +1,16 @@
use anyhow::Context;
use axum::serve;
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
use clap::Parser;
use boosapi::{
config::AppConfig,
db,
services::{builtin_keys, runtime_auth},
AppState,
};
use clap::{Parser, Subcommand};
use std::ffi::OsString;
use std::net::SocketAddr;
use tokio::signal;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser, Debug)]
#[command(
@@ -15,6 +20,9 @@ use tracing_subscriber::EnvFilter;
about = "AI API Gateway — Rust rewrite of BoosAPI"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Config file path
#[arg(short, long, default_value = "config.toml")]
config: String,
@@ -40,10 +48,42 @@ struct Cli {
check_config: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Copy core data from SQLite into PostgreSQL and exit
MigrateSqliteToPostgres {
/// Source SQLite URL, e.g. sqlite:boosapi.db?mode=rwc
#[arg(long)]
sqlite_url: String,
/// Target PostgreSQL URL, e.g. postgres://user:pass@host:5432/boosapi
#[arg(long)]
postgres_url: String,
/// Truncate target business tables before importing
#[arg(long)]
force_truncate: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Cli::parse_from(normalize_legacy_args());
if let Some(Commands::MigrateSqliteToPostgres {
sqlite_url,
postgres_url,
force_truncate,
}) = &args.command
{
let sqlite = db::create_pool(sqlite_url, 1).await?;
let pg = db::create_pg_pool(postgres_url, 2).await?;
db::run_migrations(db::DbKind::Postgres, None, Some(&pg)).await?;
let summary = db::sqlite_to_postgres::migrate(&sqlite, &pg, *force_truncate).await?;
println!("{}", serde_json::to_string_pretty(&summary)?);
return Ok(());
}
// Load config
let mut config = AppConfig::load_from_file(&args.config)?;
@@ -69,16 +109,23 @@ async fn main() -> anyhow::Result<()> {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
tracing_subscriber::fmt().with_env_filter(filter).init();
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
.init();
tracing::info!("Loaded configuration from {}", args.config);
let db_kind = db::DbKind::from_url(&config.db.url)?;
// Create database pool
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
tracing::info!("Database connected");
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
db::run_migrations(db_kind, Some(&pool), None).await?;
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
tracing::info!("Database migrations applied");
if args.migrate_only {
@@ -97,28 +144,20 @@ async fn main() -> anyhow::Result<()> {
let builtin = builtin_keys::BuiltinKeys {
comfyui_agent: comfyui_agent_key,
};
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
&pool,
config.auth.proxy_token.clone(),
)
.await
.context("failed to load runtime auth settings")?;
// Build app state
let state = AppState::new(config.clone(), pool, builtin);
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start");
}
let comfyui_process = state.comfyui.clone();
// Multi-tenant ComfyUI pool: adopt any containers that survived a
// previous API restart, then start the idle-reaper task. Only
// wired in when the `multi_tenant_comfy` feature is on; under the
// legacy singleton path the pool is constructed but unused, and
// `comfyui_pool` is `None` so we skip shutdown_all on exit.
#[cfg(feature = "multi_tenant_comfy")]
let comfyui_pool = {
state.comfyui_pool.reconcile_on_startup().await;
boosapi::services::comfyui_pool::spawn_reaper(state.comfyui_pool.clone());
Some(state.comfyui_pool.clone())
};
#[cfg(not(feature = "multi_tenant_comfy"))]
let comfyui_pool: Option<std::sync::Arc<boosapi::services::comfyui_pool::ComfyUIPool>> = None;
// Build router
let app = boosapi::routes::build_router(state);
@@ -128,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
serve(
tokio::net::TcpListener::bind(addr).await?,
app.into_make_service(),
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
@@ -137,13 +176,6 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
}
// Tear down any per-user ComfyUI containers we spawned. This is
// best-effort — a hard kill of the API leaves them running and the
// next startup will adopt them via reconcile_on_startup().
if let Some(pool) = comfyui_pool {
pool.shutdown_all().await;
}
Ok(())
}
+1
View File
@@ -267,6 +267,7 @@ mod tests {
route_id: Some(10),
site_id: 20,
account_id: Some(30),
account_unit_cost: None,
base_url: "https://example.test/v1".into(),
api_key: "key".into(),
model: "gpt-5.5".into(),
+1
View File
@@ -11,6 +11,7 @@ pub struct SelectedChannel {
pub route_id: Option<i64>,
pub site_id: i64,
pub account_id: Option<i64>,
pub account_unit_cost: Option<f64>,
pub base_url: String,
pub api_key: String,
pub model: String,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+297 -7
View File
@@ -27,6 +27,26 @@ pub fn routes() -> Router<AppState> {
"/api/account-tokens/sync-all",
post(sync_all_account_tokens),
)
.route(
"/api/account-tokens/batch",
post(batch_update_account_tokens),
)
.route(
"/api/account-tokens/groups/{account_id}",
get(list_account_token_groups),
)
.route(
"/api/account-tokens/sync/{account_id}",
post(sync_account_tokens),
)
.route(
"/api/account-tokens/{id}/default",
post(set_default_account_token),
)
.route(
"/api/account-tokens/{id}/value",
get(get_account_token_value),
)
.route(
"/api/account-tokens/{id}",
get(get_account_token)
@@ -48,10 +68,15 @@ pub struct CreateAccountTokenRequest {
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
#[serde(alias = "group")]
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
pub unlimited_quota: Option<bool>,
pub remain_quota: Option<i64>,
pub expired_time: Option<String>,
pub allow_ips: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -61,12 +86,20 @@ pub struct UpdateAccountTokenRequest {
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
#[serde(alias = "group")]
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchAccountTokenRequest {
pub ids: Vec<i64>,
pub action: String,
}
/// GET /api/account-tokens?accountId=
pub async fn list_account_tokens(
State(state): State<AppState>,
@@ -119,6 +152,19 @@ pub async fn create_account_token(
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or_else(|| "valid".to_string());
let token_group = normalize_token_group(payload.token_group);
let source = payload
.source
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty())
.unwrap_or_else(|| "manual".to_string());
let quota_note = build_token_source_note(
&source,
payload.unlimited_quota,
payload.remain_quota,
payload.expired_time,
payload.allow_ips,
);
let now = Utc::now().to_rfc3339();
@@ -130,8 +176,8 @@ pub async fn create_account_token(
.bind(payload.account_id)
.bind(&name)
.bind(&token)
.bind(&payload.source)
.bind(&payload.token_group)
.bind(&quota_note)
.bind(&token_group)
.bind(&value_status)
.bind(payload.enabled.unwrap_or(true))
.bind(payload.is_default.unwrap_or(false))
@@ -209,6 +255,13 @@ pub async fn update_account_token(
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or(existing.value_status);
let token_group = payload
.token_group
.map(|group| normalize_token_group(Some(group)));
let source = payload
.source
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty());
let now = Utc::now().to_rfc3339();
@@ -221,8 +274,8 @@ pub async fn update_account_token(
.bind(account_id)
.bind(&name)
.bind(&token)
.bind(payload.source.or(existing.source))
.bind(payload.token_group.or(existing.token_group))
.bind(source.or(existing.source))
.bind(token_group.or(existing.token_group))
.bind(&value_status)
.bind(payload.enabled.or(existing.enabled))
.bind(payload.is_default.or(existing.is_default))
@@ -263,14 +316,209 @@ pub async fn delete_account_token(
Ok(Json(json!({ "success": true })))
}
/// POST /api/account-tokens/batch
pub async fn batch_update_account_tokens(
State(state): State<AppState>,
Json(payload): Json<BatchAccountTokenRequest>,
) -> AppResult<Json<Value>> {
let action = payload.action.trim().to_ascii_lowercase();
if !matches!(action.as_str(), "enable" | "disable" | "delete") {
return Err(AppError::BadRequest(format!(
"unsupported batch action: {}",
payload.action
)));
}
let mut success_ids = Vec::new();
let mut failed_items = Vec::new();
let now = Utc::now().to_rfc3339();
for id in payload.ids.into_iter().filter(|id| *id > 0) {
let result = match action.as_str() {
"enable" => {
sqlx::query("UPDATE account_tokens SET enabled = 1, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&*state.db)
.await
}
"disable" => {
sqlx::query("UPDATE account_tokens SET enabled = 0, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&*state.db)
.await
}
"delete" => {
sqlx::query("DELETE FROM account_tokens WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await
}
_ => unreachable!(),
};
match result {
Ok(done) if done.rows_affected() > 0 => success_ids.push(id),
Ok(_) => failed_items.push(json!({
"id": id,
"message": "token not found"
})),
Err(err) => failed_items.push(json!({
"id": id,
"message": err.to_string()
})),
}
}
Ok(Json(json!({
"success": failed_items.is_empty(),
"successIds": success_ids,
"failedItems": failed_items
})))
}
/// GET /api/account-tokens/groups/{account_id}
pub async fn list_account_token_groups(
State(state): State<AppState>,
Path(account_id): Path<i64>,
) -> AppResult<Json<Value>> {
ensure_account_exists(&state, account_id).await?;
let rows: Vec<(Option<String>,)> = sqlx::query_as(
"SELECT DISTINCT token_group FROM account_tokens WHERE account_id = ? ORDER BY token_group",
)
.bind(account_id)
.fetch_all(&*state.db)
.await?;
let mut groups = vec!["default".to_string()];
for (group,) in rows {
let group = normalize_token_group(group);
if !groups.iter().any(|existing| existing == &group) {
groups.push(group);
}
}
Ok(Json(json!({ "groups": groups })))
}
/// POST /api/account-tokens/{id}/default
pub async fn set_default_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
let group = normalize_token_group(token.token_group.clone());
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE account_tokens SET is_default = 0, updated_at = ? \
WHERE account_id = ? AND COALESCE(NULLIF(TRIM(token_group), ''), 'default') = ?",
)
.bind(&now)
.bind(token.account_id)
.bind(&group)
.execute(&*state.db)
.await?;
sqlx::query("UPDATE account_tokens SET is_default = 1, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&*state.db)
.await?;
let updated: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": updated, "success": true })))
}
/// GET /api/account-tokens/{id}/value
pub async fn get_account_token_value(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
if is_masked_token(&token.token) || token.value_status.eq_ignore_ascii_case("masked") {
return Err(AppError::BadRequest(
"当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。"
.into(),
));
}
let token_value = token.token;
Ok(Json(json!({
"id": id,
"token": token_value.clone(),
"value": token_value
})))
}
/// POST /api/account-tokens/sync/{account_id}
pub async fn sync_account_tokens(
State(state): State<AppState>,
Path(account_id): Path<i64>,
) -> AppResult<Json<Value>> {
ensure_account_exists(&state, account_id).await?;
Ok(Json(json!({
"status": "skipped",
"accountId": account_id,
"created": 0,
"updated": 0,
"message": "当前 Rust 后端暂未接入上游站点 Token 拉取;可手动新增或编辑 API Token。"
})))
}
/// POST /api/account-tokens/sync-all
pub async fn sync_all_account_tokens() -> (StatusCode, Json<Value>) {
pub async fn sync_all_account_tokens(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
info!("Sync all account tokens requested");
let rows: Vec<(i64, String)> =
sqlx::query_as("SELECT id, COALESCE(name, '') FROM accounts ORDER BY id")
.fetch_all(&*state.db)
.await
.unwrap_or_default();
let results: Vec<Value> = rows
.into_iter()
.map(|(account_id, name)| {
json!({
"status": "skipped",
"accountId": account_id,
"accountName": name,
"created": 0,
"updated": 0,
"message": "当前 Rust 后端暂未接入上游站点 Token 拉取;可手动新增或编辑 API Token。"
})
})
.collect();
(
StatusCode::NOT_IMPLEMENTED,
StatusCode::OK,
Json(json!({
"error": "not implemented"
"status": "skipped",
"results": results,
"created": 0,
"updated": 0,
"message": "当前 Rust 后端暂未接入上游站点 Token 拉取;可手动新增或编辑 API Token。"
})),
)
}
@@ -297,3 +545,45 @@ fn normalize_create_token(token: Option<String>) -> String {
.filter(|token| !token.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string())
}
fn normalize_token_group(group: Option<String>) -> String {
group
.map(|group| group.trim().to_string())
.filter(|group| !group.is_empty())
.unwrap_or_else(|| "default".to_string())
}
fn build_token_source_note(
source: &str,
unlimited_quota: Option<bool>,
remain_quota: Option<i64>,
expired_time: Option<String>,
allow_ips: Option<String>,
) -> String {
let mut details = vec![source.trim().to_string()];
if let Some(unlimited) = unlimited_quota {
details.push(format!("unlimited_quota={}", unlimited));
}
if let Some(quota) = remain_quota {
details.push(format!("remain_quota={}", quota));
}
if let Some(expired) = expired_time
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
details.push(format!("expired_time={}", expired));
}
if let Some(ips) = allow_ips
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
details.push(format!("allow_ips={}", ips));
}
details.join(";")
}
fn is_masked_token(token: &str) -> bool {
let trimmed = token.trim();
trimmed.contains('•') || trimmed.contains('*')
}

Some files were not shown because too many files have changed in this diff Show More