Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3293e007f | |||
| a1b7b60ad4 | |||
| bfd804f719 | |||
| e7f3da5961 | |||
| 5cfd533d89 | |||
| 0b7abed79a | |||
| a89edf6f75 | |||
| 5d42671172 | |||
| ca9e45c59c | |||
| be7538a1da | |||
| cfe2098b73 | |||
| bd464016a8 | |||
| 7e8430934e | |||
| ab2fd271e8 | |||
| 4dd2be4cc3 | |||
| fd94303433 | |||
| 91dcbc5c53 | |||
| 278b0c66b8 | |||
| d697146941 | |||
| d1f3f4e93d | |||
| df97633300 | |||
| 3d54c7d529 | |||
| 55b3d4d99a | |||
| 59106e6a96 | |||
| aaba6f152e | |||
| f3d2d50f9a | |||
| 16162095f7 | |||
| 6629d9d0a3 | |||
| 3c6f061709 | |||
| d7d14e077e | |||
| 3b3292b934 | |||
| f7916a42e5 | |||
| f725de28bd | |||
| 97113964c8 | |||
| b429695cba | |||
| d6bf7352a0 | |||
| d800ed1f9f | |||
| 4855db3fc8 | |||
| 6cd0a8caca | |||
| 44dc89a0dd | |||
| 823ac3c25d | |||
| 5ecea182bc | |||
| cd843465b2 | |||
| ef0f91e683 | |||
| 9c1e5ae293 | |||
| 00b3365f5a | |||
| d5babf5f3f | |||
| 4b757b8250 | |||
| def8d7a204 | |||
| 25c6fdbd6c | |||
| 47202e6848 | |||
| 1b2880739a | |||
| 188e457e7f | |||
| 13756adc9d | |||
| f026582008 | |||
| b3eb86d5ea | |||
| 301d086cb6 | |||
| 023384b14b | |||
| d315f0ffe2 | |||
| f0385c9c8c | |||
| 339f91d014 | |||
| 0d231ec467 | |||
| be1fc1df48 | |||
| dd874bae30 | |||
| 45aa741d9c | |||
| 49c398db5e | |||
| 4d748dd938 | |||
| b40c522749 | |||
| d467e27fd8 | |||
| 5ef017c54e | |||
| 03046152fd | |||
| d75daf72d6 | |||
| d39100b56d | |||
| 2e4c5801b6 | |||
| 870b2a2da3 | |||
| 4448567368 | |||
| fae0f607aa | |||
| e4630c4cb6 | |||
| e72e323313 | |||
| 2b836b3e1e | |||
| 598b2f48d6 | |||
| 3f22d7b1ab | |||
| 5ce5ea1687 | |||
| f8741b544e | |||
| c316a78511 | |||
| 68ae9acb27 | |||
| 13c7d8aa4e | |||
| fb5fc9f1ae | |||
| 5765ea48b0 | |||
| 55bd3f41f8 | |||
| 938597b766 | |||
| fe0e3faa22 | |||
| d0f26118ff | |||
| c98d9a2c6d | |||
| 1293ebae68 | |||
| b29c7ffa7a | |||
| 056e9fb651 | |||
| fa5a2419d8 | |||
| 7b34153235 | |||
| 04792bd3f7 | |||
| bb31118792 | |||
| 5027767ca2 | |||
| fd930757f9 | |||
| eb6ed11cff | |||
| 931bcf3fb9 | |||
| e0bd9b4194 | |||
| 0f9fe4a81d | |||
| 6869504119 | |||
| 3f9f9f7665 | |||
| a1f3400af3 | |||
| da686ecda4 | |||
| 1ba293c262 | |||
| cb35d5fc6e | |||
| 7cdb02ef1d | |||
| c1074ddf71 | |||
| 3c12738138 | |||
| 8de175a8c5 | |||
| 7c6364a160 | |||
| 5ef3f8aea8 | |||
| 9e7ce80574 | |||
| 65d2f1bbe5 | |||
| 0207ba748f | |||
| d0912e44b4 |
+6
-5
@@ -1,18 +1,19 @@
|
|||||||
# Cargo build acceleration.
|
# Cargo build acceleration.
|
||||||
#
|
#
|
||||||
# The biggest single speedup is the `mold` linker (~3-5x faster link
|
# 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
|
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
|
||||||
# (no apt-get, see build-and-run.sh notes), so the mold block below is
|
# when available, so the block below is kept as an optional manual setup for
|
||||||
# commented out — uncomment it after running:
|
# direct `cargo build` / `cargo run` usage:
|
||||||
#
|
#
|
||||||
# sudo apt-get install -y mold
|
# sudo apt-get install -y mold
|
||||||
#
|
#
|
||||||
# (or build it from source: https://github.com/rui314/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).
|
# 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]
|
# [target.x86_64-unknown-linux-gnu]
|
||||||
# linker = "clang"
|
# linker = "clang"
|
||||||
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||||
|
|||||||
@@ -2,3 +2,19 @@ comfy_source/venv
|
|||||||
comfy_source/.git
|
comfy_source/.git
|
||||||
comfy_source/__pycache__
|
comfy_source/__pycache__
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
.agents/
|
||||||
|
.cache/
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.comfyui/
|
||||||
|
boosapi
|
||||||
|
bossai
|
||||||
|
boosapi.db
|
||||||
|
docker/*.tar
|
||||||
|
|||||||
+12
-1
@@ -1,13 +1,15 @@
|
|||||||
/target
|
/target
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
Cargo.lock
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
data/
|
data/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
website/.next/
|
||||||
|
website/out/
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
logs/
|
logs/
|
||||||
tmp/
|
tmp/
|
||||||
!tmp/.gitkeep
|
!tmp/.gitkeep
|
||||||
@@ -19,4 +21,13 @@ docker/.env
|
|||||||
.comfyui/
|
.comfyui/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
|
.agents/
|
||||||
|
.cache/
|
||||||
|
openspec/
|
||||||
boosapi
|
boosapi
|
||||||
|
bossai
|
||||||
|
docker/*.tar
|
||||||
|
boosapi.db-shm
|
||||||
|
boosapi.db-wal
|
||||||
|
.tours/
|
||||||
|
bun.lock
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
[submodule "comfy_source"]
|
[submodule "vendor/ComfyUI_frontend"]
|
||||||
path = comfy_source
|
path = vendor/ComfyUI_frontend
|
||||||
url = https://github.com/comfyanonymous/ComfyUI.git
|
url = https://github.com/Comfy-Org/ComfyUI_frontend.git
|
||||||
branch = master
|
branch = main
|
||||||
|
|||||||
Generated
+4177
File diff suppressed because it is too large
Load Diff
+19
-14
@@ -4,15 +4,6 @@ version = "1.3.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "AI API Gateway — Rust rewrite of BoosAPI"
|
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]
|
[dependencies]
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
|
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 runtime
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.8", features = [
|
sqlx = { version = "0.8", features = [
|
||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
"sqlite",
|
"sqlite",
|
||||||
|
"postgres",
|
||||||
|
"any",
|
||||||
"migrate",
|
"migrate",
|
||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
@@ -67,6 +61,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
|||||||
|
|
||||||
# Streaming
|
# Streaming
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
|
||||||
@@ -74,11 +69,12 @@ bytes = "1"
|
|||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
|
base64 = "0.22"
|
||||||
# WebSocket client (used by /comfy/* WS proxy to forward upstream to per-user containers)
|
hmac = "0.12"
|
||||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] }
|
sha2 = "0.10"
|
||||||
async-trait = "0.1"
|
hex = "0.4"
|
||||||
urlencoding = "2"
|
include_dir = "0.7"
|
||||||
|
mime_guess = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.24"
|
rstest = "0.24"
|
||||||
@@ -92,6 +88,15 @@ opt-level = 3
|
|||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
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
|
# Dev-profile speedups: line-tables-only debuginfo (much smaller .debug
|
||||||
# sections → much faster link) and 256 codegen units (more parallelism
|
# sections → much faster link) and 256 codegen units (more parallelism
|
||||||
# during codegen, slightly worse runtime — fine for dev). Dependencies
|
# during codegen, slightly worse runtime — fine for dev). Dependencies
|
||||||
|
|||||||
@@ -66,6 +66,31 @@
|
|||||||
|
|
||||||
## 3. 生产环境 (Production)
|
## 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 部署
|
### 推荐:Docker Compose 部署
|
||||||
|
|
||||||
1. **准备 docker-compose.yml**:
|
1. **准备 docker-compose.yml**:
|
||||||
@@ -161,8 +186,54 @@
|
|||||||
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
||||||
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
||||||
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
| `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`) |
|
| `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)
|
## 5. 运维操作 (Ops)
|
||||||
@@ -179,6 +250,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
|
|||||||
cp data/boosapi.db data/boosapi.db.bak
|
cp data/boosapi.db data/boosapi.db.bak
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
|
||||||
|
|
||||||
### 查看日志
|
### 查看日志
|
||||||
如果使用 Systemd:
|
如果使用 Systemd:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
# BoosAPI
|
# BoosAPI
|
||||||
|
|
||||||
**AI 聚合网关 + ComfyUI Agent — 将分散的中转站整合为一个统一入口**
|
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
|
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
|
||||||
<br>
|
<br>
|
||||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||||
<br>
|
<br>
|
||||||
<strong>内置 ComfyUI 视频助手</strong> — 自然语言描述脚本,自动生成角色、场景、TTS、ComfyUI 工作流。
|
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
|
||||||
<br>
|
<br>
|
||||||
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
||||||
</p>
|
</p>
|
||||||
@@ -53,21 +53,33 @@
|
|||||||
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
||||||
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
||||||
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
||||||
| 🎨 想用 ComfyUI 但不想配工作流 | **对话助手**,自然语言描述即可生成图像/视频 |
|
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
|
||||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
| 📋 不同团队需要不同配额 | **套餐系统**,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 自动提取角色、场景、对白
|
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
|
||||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||||
- **场景生成**:自动为每个场景生成配图
|
- **场景生成**:自动为每个场景生成配图
|
||||||
- **TTS 语音合成**:对话台词自动转语音
|
- **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 运行时依赖
|
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
||||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||||
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
|
- **默认走本地模式**:复用本服务自身的 `/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(推荐)
|
### Docker Compose(推荐)
|
||||||
@@ -178,14 +287,14 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# AUTH_TOKEN = 管理后台登录令牌
|
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
|
||||||
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
|
||||||
export AUTH_TOKEN=your-admin-token
|
export AUTH_TOKEN=your-admin-token
|
||||||
export PROXY_TOKEN=sk-your-proxy-token
|
export PROXY_TOKEN=sk-your-proxy-token
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
||||||
@@ -209,8 +318,8 @@ docker compose up -d
|
|||||||
│ ├── db/ # 数据库模型与查询
|
│ ├── db/ # 数据库模型与查询
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
|
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
|
||||||
│ │ ├── comfyui_agent.rs # ComfyUI 视频助手 SSE 聊天
|
│ │ ├── comfyui_agent.rs # 视频/工作流助手 SSE 聊天
|
||||||
│ │ ├── comfyui/ # ComfyUI 代理(prompt/history/view)
|
│ │ ├── comfyui/ # 外部 ComfyUI 代理(prompt/history/view)
|
||||||
│ │ ├── proxy/ # OpenAI 兼容代理入口
|
│ │ ├── proxy/ # OpenAI 兼容代理入口
|
||||||
│ │ ├── auth.rs # 用户注册/登录 JWT
|
│ │ ├── auth.rs # 用户注册/登录 JWT
|
||||||
│ │ ├── checkin.rs # 自动签到
|
│ │ ├── checkin.rs # 自动签到
|
||||||
@@ -228,14 +337,16 @@ docker compose up -d
|
|||||||
│ ├── billing/ # 计费逻辑
|
│ ├── billing/ # 计费逻辑
|
||||||
│ └── notification/ # 通知推送
|
│ └── notification/ # 通知推送
|
||||||
├── migrations/ # SQLite 迁移
|
├── migrations/ # SQLite 迁移
|
||||||
├── src/web/ # 前端源码(React 19 + Vite)
|
├── src/web/ # 管理台前端源码(React + Vite)
|
||||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||||
│ ├── pages/
|
│ ├── pages/
|
||||||
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
|
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
|
||||||
│ │ ├── ComfyUIAgent.tsx # 视频助手聊天界面
|
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
|
||||||
|
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── components/
|
│ └── components/
|
||||||
|
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
||||||
└── dist/web/ # 前端构建产物(由 Rust serve)
|
└── 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__PROXY_TOKEN=sk-your-proxy-token
|
||||||
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
|
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
|
||||||
|
|
||||||
# 数据库
|
# 数据库(默认 SQLite)
|
||||||
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
|
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 后切换到 fallback-remote 模式;BOOSAPI_UPSTREAM_KEY 缺省时仍用内置 comfyui-agent key
|
||||||
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
||||||
BOOSAPI_UPSTREAM_KEY=sk-your-key
|
BOOSAPI_UPSTREAM_KEY=sk-your-key
|
||||||
|
|
||||||
# 前端静态目录(默认 dist/web)
|
# 前端静态目录(默认 dist/web)
|
||||||
BOOSAPI_WEB_DIR=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) |
|
| 后端框架 | **Axum 0.8**(Rust) |
|
||||||
| 运行时 | **Tokio**(异步运行时) |
|
| 运行时 | **Tokio**(异步运行时) |
|
||||||
| 数据库 | **SQLite** + **SQLx**(编译期 SQL 检查) |
|
| 数据库 | **SQLite** 运行主路径 + **PostgreSQL** schema/迁移准备(SQLx) |
|
||||||
| 并行计算 | **Rayon**(CPU 密集任务) |
|
| 并行计算 | **Rayon**(CPU 密集任务) |
|
||||||
| 认证鉴权 | **JWT** + API Key 双模式 |
|
| 认证鉴权 | **JWT** + API Key 双模式 |
|
||||||
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
||||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||||
| ComfyUI Agent | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
||||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||||
|
| 管理台前端 | React + Vite(仅构建工具) |
|
||||||
|
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
|
||||||
| 样式 | Tailwind CSS v4 |
|
| 样式 | Tailwind CSS v4 |
|
||||||
| 数据可视化 | VChart |
|
| 数据可视化 | VChart |
|
||||||
| 容器化 | Docker + Docker Compose + K3s |
|
| 容器化 | Docker + Docker Compose + K3s |
|
||||||
@@ -286,6 +413,8 @@ BOOSAPI_WEB_DIR=dist/web
|
|||||||
|
|
||||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||||
|
|
||||||
|
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 本地开发
|
## 🛠️ 本地开发
|
||||||
@@ -293,11 +422,20 @@ BOOSAPI_WEB_DIR=dist/web
|
|||||||
### 后端(Rust)
|
### 后端(Rust)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 编译并运行
|
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
|
||||||
cargo run --release
|
./build-and-run.sh --check
|
||||||
|
|
||||||
# 仅检查编译
|
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
|
||||||
cargo check
|
./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
|
cargo test
|
||||||
@@ -308,9 +446,15 @@ export BOOSAPI_SERVER__PORT=4000
|
|||||||
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||||
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||||
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
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)
|
### 前端(React SPA)
|
||||||
|
|
||||||
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
||||||
@@ -322,14 +466,76 @@ npm install
|
|||||||
# 开发模式(热更新,需要 Node.js)
|
# 开发模式(热更新,需要 Node.js)
|
||||||
cd src/web && npx vite --host 0.0.0.0
|
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
|
# 输出到 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 静态文件。
|
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||||
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
> 前端 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 部署
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+140
-151
@@ -2,206 +2,195 @@
|
|||||||
|
|
||||||
# BoosAPI
|
# 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>
|
<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>
|
<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>
|
<br>
|
||||||
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
|
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
|
||||||
<br>
|
|
||||||
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
|
||||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&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">
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文</a> |
|
<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>
|
</p>
|
||||||
|
|
||||||
</div>
|
</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
|
## Key Features
|
||||||
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
|
|
||||||
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
|
|
||||||
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
|
|
||||||
|
|
||||||
| Pain Point | BoosAPI Solution |
|
### Unified API Gateway
|
||||||
|------------|-----------------|
|
|
||||||
| 🔑 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 |
|
|
||||||
|
|
||||||
---
|
- 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
|
| backend | Purpose |
|
||||||
- **Multi-view generation**: front/back/left/right directional image generation
|
|---------|---------|
|
||||||
- **Scene/video generation**: text description directly generates video content
|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
|
||||||
- **SSE real-time streaming**: generation progress feedback
|
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
|
||||||
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
|
| `external` | Proxy a full ComfyUI instance you deploy yourself |
|
||||||
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
|
| `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
|
### Assistants
|
||||||
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
|
|
||||||
- SSE streaming with automatic format conversion
|
|
||||||
|
|
||||||
### 🧠 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
|
## Hardware Guide
|
||||||
- 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
|
|
||||||
|
|
||||||
### 📡 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 |
|
Recommended SaaS gateway server:
|
||||||
|----------|---------|
|
|
||||||
| New API | `new-api` |
|
|
||||||
| One API | `one-api` |
|
|
||||||
| OneHub | `onehub` |
|
|
||||||
| DoneHub | `done-hub` |
|
|
||||||
| Veloera | `veloera` |
|
|
||||||
| AnyRouter | `anyrouter` |
|
|
||||||
| Sub2API | `sub2api` |
|
|
||||||
|
|
||||||
### 👥 Account & Token Management
|
```txt
|
||||||
|
4 CPU cores
|
||||||
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
|
8GB RAM
|
||||||
- Encrypted credential storage, auto-renewal, cascading disable
|
50GB SSD
|
||||||
|
No GPU required
|
||||||
### 💰 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 |
|
| Layer | Tech |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| Backend | Fastify |
|
| Backend | Rust, Axum 0.8, Tokio |
|
||||||
| Frontend | React 18 + Vite |
|
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
|
||||||
| Language | TypeScript |
|
| Proxy engine | Token Router + Proxy Conductor |
|
||||||
| Styling | Tailwind CSS v4 |
|
| Protocols | OpenAI / Claude / Gemini compatible transformers |
|
||||||
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
|
| Frontend | React 19 + Vite |
|
||||||
| Visualization | VChart |
|
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
|
||||||
| Container | Docker + Docker Compose |
|
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
|
||||||
| Test | Vitest |
|
| 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
|
```bash
|
||||||
npm install
|
cargo check
|
||||||
npm run db:migrate
|
cargo run
|
||||||
npm run dev
|
pnpm install
|
||||||
|
pnpm run dev:web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Build checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # Build web + server
|
cargo check
|
||||||
npm test # Run tests
|
pnpm run typecheck:web
|
||||||
|
npx vite build
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## License
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+473
-86
@@ -6,46 +6,226 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
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
|
FRONTEND_ONLY=0
|
||||||
BACKEND_ONLY=0
|
BACKEND_ONLY=0
|
||||||
|
CHECK_ONLY=0
|
||||||
NO_RUN=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() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
用法: ./build-and-run.sh [选项]
|
用法: ./build-and-run.sh [选项]
|
||||||
|
|
||||||
选项:
|
选项:
|
||||||
|
--dev 使用 dev 模式编译后端(默认,最快)
|
||||||
--release 使用 release 模式编译后端(更慢但性能好)
|
--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 只构建前端
|
--frontend-only 只构建前端
|
||||||
--backend-only 只构建后端
|
--backend-only 只构建后端
|
||||||
|
--force-frontend 强制重建前端,忽略缓存
|
||||||
|
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
||||||
|
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
|
||||||
--no-run 构建后不启动服务
|
--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 显示帮助
|
-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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
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 ;;
|
--frontend-only) FRONTEND_ONLY=1 ;;
|
||||||
--backend-only) BACKEND_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 ;;
|
--no-run) NO_RUN=1 ;;
|
||||||
--rebuild-image) REBUILD_IMAGE=1 ;;
|
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
|
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
||||||
|
fail "--frontend-only 和 --backend-only 不能同时使用"
|
||||||
|
fi
|
||||||
|
|
||||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||||
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
|
fail "--production 不能和 --frontend-only 同时使用"
|
||||||
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
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() {
|
server_port() {
|
||||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||||
@@ -113,7 +293,7 @@ stop_previous_server() {
|
|||||||
|
|
||||||
while IFS= read -r pid; do
|
while IFS= read -r pid; do
|
||||||
[ -n "$pid" ] && binary_pids+=("$pid")
|
[ -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[@]}"
|
stop_pids "boosapi binary" "${binary_pids[@]}"
|
||||||
|
|
||||||
@@ -129,110 +309,317 @@ stop_previous_server() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 同步 ComfyUI 子模块 + 应用本地补丁 ─────────────────────────────────────────
|
ensure_pnpm() {
|
||||||
# comfy_source/ 是 vanilla ComfyUI 的 git 子模块,本地补丁存放在
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
# comfy_patches/overlay/,由 comfy_patches/apply.sh 覆盖到子模块上。
|
return 0
|
||||||
# Dockerfile 把整个 comfy_source/ 复制进镜像,所以这一步必须在 docker
|
|
||||||
# build 之前完成,否则镜像里只会有 vanilla 代码。
|
|
||||||
|
|
||||||
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
|
fi
|
||||||
if [ -d "$ROOT_DIR/comfy_patches/overlay" ]; then
|
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
|
||||||
log "应用 ComfyUI 本地补丁 (comfy_patches/overlay → comfy_source/)..."
|
log "未找到 pnpm,尝试通过 corepack 自动启用..."
|
||||||
"$ROOT_DIR/comfy_patches/apply.sh"
|
corepack enable
|
||||||
ok "本地补丁已应用"
|
corepack prepare pnpm@latest --activate
|
||||||
fi
|
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
|
||||||
fi
|
}
|
||||||
|
|
||||||
# ── 构建前端 ──────────────────────────────────────────────────────────────────
|
install_frontend_dependencies() {
|
||||||
|
local frontend_pm="$1"
|
||||||
if [ "$BACKEND_ONLY" -eq 0 ]; then
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
log "构建前端 (Vite)..."
|
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
|
||||||
if ! command -v npx >/dev/null 2>&1; then
|
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
|
||||||
fail "未找到 npx,请先安装 Node.js"
|
log "安装主前端依赖 (bun install)..."
|
||||||
|
bun install
|
||||||
fi
|
fi
|
||||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
return 0
|
||||||
npx vite build
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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/"
|
ok "前端构建完成 → dist/web/"
|
||||||
fi
|
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
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。
|
configure_fast_linker
|
||||||
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。
|
build_cargo_args
|
||||||
if ! command -v mold >/dev/null 2>&1; then
|
|
||||||
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
|
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
|
fi
|
||||||
|
|
||||||
if [ "$RELEASE" -eq 1 ]; then
|
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||||
log "构建后端 (cargo build --release)..."
|
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build --release
|
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/release/boosapi"
|
BINARY="$ROOT_DIR/target/release/boosapi"
|
||||||
ok "后端构建完成 → 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
|
else
|
||||||
log "构建后端 (cargo build)..."
|
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build
|
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/debug/boosapi"
|
BINARY="$ROOT_DIR/target/debug/boosapi"
|
||||||
ok "后端构建完成 → target/debug/boosapi"
|
ok "后端构建完成 → target/debug/boosapi"
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 构建多租户 ComfyUI 镜像 ───────────────────────────────────────────────────
|
record_timing "后端构建" "$started"
|
||||||
# 多租户 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 单实例路径。
|
|
||||||
|
|
||||||
if [ "$FRONTEND_ONLY" -eq 0 ]; then
|
build_production_bundle() {
|
||||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
local started
|
||||||
IMAGE="boosapi-comfyui:multi-tenant"
|
started="$(timer_start)"
|
||||||
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
|
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
|
||||||
log "镜像 $IMAGE 已存在,跳过构建(--rebuild-image 强制重建)"
|
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
|
||||||
elif [ "$REBUILD_IMAGE" -eq 0 ] && [ -n "$SAVED_TAR" ]; then
|
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
|
||||||
log "从 $SAVED_TAR 加载 $IMAGE..."
|
|
||||||
docker load -i "$SAVED_TAR"
|
rm -rf "$PRODUCTION_DIR"
|
||||||
ok "镜像已加载 → $IMAGE"
|
mkdir -p "$PRODUCTION_DIR"
|
||||||
elif [ -f "$DOCKERFILE" ]; then
|
|
||||||
log "构建多租户 ComfyUI 镜像 ($IMAGE)..."
|
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
|
||||||
docker build \
|
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
|
||||||
-f "$DOCKERFILE" \
|
chmod +x "$PRODUCTION_DIR/boosapi"
|
||||||
-t "$IMAGE" \
|
|
||||||
"$ROOT_DIR"
|
if command -v strip >/dev/null 2>&1; then
|
||||||
ok "ComfyUI 多租户镜像构建完成 → $IMAGE"
|
log "压缩生产二进制符号表 (strip)..."
|
||||||
else
|
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
|
||||||
log "未找到 $DOCKERFILE,跳过镜像构建"
|
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
log "docker 不可用(未安装或 daemon 未运行),跳过多租户 ComfyUI 镜像准备"
|
ok "生产产物已生成 → $PRODUCTION_DIR"
|
||||||
|
log "产物内容:"
|
||||||
|
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
|
||||||
|
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
|
||||||
|
record_timing "生产打包" "$started"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
fi
|
||||||
fi
|
}
|
||||||
|
|
||||||
# ── 启动服务 ──────────────────────────────────────────────────────────────────
|
# ── 构建 / 启动 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if [ "$NO_RUN" -eq 1 ] || [ "$FRONTEND_ONLY" -eq 1 ]; then
|
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||||
log "跳过启动(--no-run 或 --frontend-only)"
|
build_frontend
|
||||||
|
build_public_website
|
||||||
|
print_timing_summary
|
||||||
|
log "跳过启动(--frontend-only)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
stop_previous_server
|
||||||
|
|
||||||
log "启动服务..."
|
log "启动服务..."
|
||||||
exec "$BINARY"
|
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"
|
||||||
|
|||||||
@@ -14,12 +14,95 @@ import os
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
# ── Config ───────────────────────────────────────────────────────────────
|
# ── 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")
|
BOOSAPI_KEY = os.environ.get("BOOSAPI_KEY", "sk-boos4721")
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"Authorization": f"Bearer {BOOSAPI_KEY}",
|
"Authorization": f"Bearer {BOOSAPI_KEY}",
|
||||||
"Content-Type": "application/json",
|
"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 Mappings ────────────────────────────────────────────────────────
|
||||||
NODE_CLASS_MAPPINGS = {}
|
NODE_CLASS_MAPPINGS = {}
|
||||||
@@ -37,7 +120,7 @@ class BoosAPIImageGen:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
"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"}),
|
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
|
||||||
"quality": (["standard", "hd"], {"default": "standard"}),
|
"quality": (["standard", "hd"], {"default": "standard"}),
|
||||||
"n": ("INT", {"default": 1, "min": 1, "max": 4}),
|
"n": ("INT", {"default": 1, "min": 1, "max": 4}),
|
||||||
@@ -50,10 +133,11 @@ class BoosAPIImageGen:
|
|||||||
RETURN_TYPES = ("IMAGE", "STRING")
|
RETURN_TYPES = ("IMAGE", "STRING")
|
||||||
RETURN_NAMES = ("image", "metadata")
|
RETURN_NAMES = ("image", "metadata")
|
||||||
FUNCTION = "generate"
|
FUNCTION = "generate"
|
||||||
|
OUTPUT_NODE = True
|
||||||
CATEGORY = "BoosAPI"
|
CATEGORY = "BoosAPI"
|
||||||
|
|
||||||
def generate(self, prompt, model, size, quality, n, style="vivid"):
|
def generate(self, prompt, model, size, quality, n, style="vivid"):
|
||||||
url = f"{BOOSAPI_BASE}/v1/images/generations"
|
url = f"{BOOSAPI_V1}/images/generations"
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -65,7 +149,7 @@ class BoosAPIImageGen:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
||||||
resp.raise_for_status()
|
raise_for_status_with_body(resp)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
images = data.get("data", [])
|
||||||
# Return first image as b64
|
# Return first image as b64
|
||||||
@@ -104,7 +188,7 @@ class BoosAPILLM:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
"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": ""}),
|
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||||
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}),
|
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}),
|
||||||
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}),
|
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}),
|
||||||
@@ -114,10 +198,11 @@ class BoosAPILLM:
|
|||||||
RETURN_TYPES = ("STRING",)
|
RETURN_TYPES = ("STRING",)
|
||||||
RETURN_NAMES = ("text",)
|
RETURN_NAMES = ("text",)
|
||||||
FUNCTION = "chat"
|
FUNCTION = "chat"
|
||||||
|
OUTPUT_NODE = True
|
||||||
CATEGORY = "BoosAPI"
|
CATEGORY = "BoosAPI"
|
||||||
|
|
||||||
def chat(self, prompt, model, system_prompt, temperature, max_tokens):
|
def chat(self, prompt, model, system_prompt, temperature, max_tokens):
|
||||||
url = f"{BOOSAPI_BASE}/v1/chat/completions"
|
url = f"{BOOSAPI_V1}/chat/completions"
|
||||||
messages = []
|
messages = []
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
messages.append({"role": "system", "content": system_prompt})
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
@@ -132,10 +217,10 @@ class BoosAPILLM:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
||||||
resp.raise_for_status()
|
raise_for_status_with_body(resp)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
text = data["choices"][0]["message"]["content"]
|
text = data["choices"][0]["message"]["content"]
|
||||||
return (text,)
|
return {"ui": {"text": (text,)}, "result": (text,)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[BoosAPI] LLM chat failed: {e}")
|
print(f"[BoosAPI] LLM chat failed: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -153,7 +238,7 @@ class BoosAPITextToImage:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"positive": ("STRING", {"multiline": True, "default": ""}),
|
"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": {
|
"optional": {
|
||||||
"negative": ("STRING", {"multiline": True, "default": ""}),
|
"negative": ("STRING", {"multiline": True, "default": ""}),
|
||||||
@@ -163,6 +248,7 @@ class BoosAPITextToImage:
|
|||||||
|
|
||||||
RETURN_TYPES = ("IMAGE",)
|
RETURN_TYPES = ("IMAGE",)
|
||||||
FUNCTION = "generate"
|
FUNCTION = "generate"
|
||||||
|
OUTPUT_NODE = True
|
||||||
CATEGORY = "BoosAPI"
|
CATEGORY = "BoosAPI"
|
||||||
|
|
||||||
def generate(self, positive, model, negative="", size="1024x1024"):
|
def generate(self, positive, model, negative="", size="1024x1024"):
|
||||||
@@ -171,7 +257,7 @@ class BoosAPITextToImage:
|
|||||||
if negative:
|
if negative:
|
||||||
prompt = f"{positive}\n避免: {negative}"
|
prompt = f"{positive}\n避免: {negative}"
|
||||||
|
|
||||||
url = f"{BOOSAPI_BASE}/v1/images/generations"
|
url = f"{BOOSAPI_V1}/images/generations"
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -181,7 +267,7 @@ class BoosAPITextToImage:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
||||||
resp.raise_for_status()
|
raise_for_status_with_body(resp)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -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/."
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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())
|
|
||||||
)
|
|
||||||
-1
Submodule comfy_source deleted from 26515acd23
+48
-3
@@ -8,11 +8,16 @@ cors_origins = ["*"]
|
|||||||
url = "sqlite:boosapi.db?mode=rwc"
|
url = "sqlite:boosapi.db?mode=rwc"
|
||||||
max_connections = 10
|
max_connections = 10
|
||||||
ssl = false
|
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]
|
[auth]
|
||||||
jwt_secret = "boosapi-dev-secret-change-in-production"
|
jwt_secret = "boosapi-dev-secret-change-in-production"
|
||||||
jwt_expiry_hours = 72
|
jwt_expiry_hours = 72
|
||||||
auth_token = "change-me-admin-token"
|
auth_token = "boos4721"
|
||||||
proxy_token = "sk-boos4721"
|
proxy_token = "sk-boos4721"
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
@@ -20,12 +25,52 @@ upstream_timeout_secs = 300
|
|||||||
first_byte_timeout_secs = 60
|
first_byte_timeout_secs = 60
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
proxy_log_retention_days = 30
|
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]
|
[comfyui]
|
||||||
enabled = true
|
enabled = false
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
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"
|
agent_model = "gpt-5.5"
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
||||||
@@ -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}"
|
|
||||||
@@ -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 风格。
|
||||||
@@ -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);
|
||||||
@@ -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` 的人工通道。
|
|
||||||
-88
@@ -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 === {}`
|
|
||||||
-153
@@ -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 的那一行
|
|
||||||
-81
@@ -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)
|
|
||||||
@@ -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
@@ -2,12 +2,12 @@
|
|||||||
"name": "boosapi",
|
"name": "boosapi",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||||
"main": "dist/desktop/main.js",
|
"main": "dist/web/index.html",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
"api-gateway",
|
"api-gateway",
|
||||||
"proxy",
|
"proxy",
|
||||||
"fastify",
|
"rust",
|
||||||
"openai",
|
"openai",
|
||||||
"claude",
|
"claude",
|
||||||
"aggregator"
|
"aggregator"
|
||||||
@@ -22,97 +22,45 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=25.0.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
"dev": "vite",
|
||||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
"build:web": "vite build",
|
||||||
"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\"",
|
"build": "npm run build:web",
|
||||||
"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",
|
|
||||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||||
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
|
"website:dev": "npm --prefix website run dev",
|
||||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
|
"website:build": "npm --prefix website run build",
|
||||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
"website:start": "npm --prefix website run start",
|
||||||
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
|
"website:typecheck": "npm --prefix website run typecheck",
|
||||||
"package:desktop": "npm run dist:desktop",
|
|
||||||
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
|
|
||||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"start": "node dist/server/index.js",
|
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
||||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
||||||
"start:desktop": "electron dist/desktop/main.js",
|
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
||||||
"comfyui:install": "node scripts/comfyui/install.mjs",
|
"test": "vitest run --root src/web",
|
||||||
"comfyui:start": "node scripts/comfyui/start.mjs",
|
"test:watch": "vitest --root src/web"
|
||||||
"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 ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
|
||||||
"@fastify/static": "^9.0.0",
|
|
||||||
"@visactor/react-vchart": "^2.0.21",
|
"@visactor/react-vchart": "^2.0.21",
|
||||||
"better-sqlite3": "^12.8.0",
|
"gsap": "^3.15.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",
|
|
||||||
"marked": "^17.0.5",
|
"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"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/notarize": "^3.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "^22.10.1",
|
"@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": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@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",
|
"jsdom": "^29.0.2",
|
||||||
"mermaid": "^11.14.0",
|
"mermaid": "^11.14.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -121,13 +69,11 @@
|
|||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitepress-plugin-mermaid": "^2.0.17",
|
"vitepress-plugin-mermaid": "^2.0.17",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8"
|
||||||
"wait-on": "^9.0.4"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimist": "^1.2.8"
|
"minimist": "^1.2.8"
|
||||||
|
|||||||
Generated
+82
-3768
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
@@ -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
|
||||||
Executable
+260
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+230
@@ -13,6 +13,10 @@ pub struct AppConfig {
|
|||||||
pub proxy: ProxyConfig,
|
pub proxy: ProxyConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comfyui: ComfyUIConfig,
|
pub comfyui: ComfyUIConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pixelle_video: PixelleVideoConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment: PaymentConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -47,11 +51,25 @@ pub struct ProxyConfig {
|
|||||||
pub first_byte_timeout_secs: u64,
|
pub first_byte_timeout_secs: u64,
|
||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
pub proxy_log_retention_days: u32,
|
pub proxy_log_retention_days: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub template_proxy_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct ComfyUIConfig {
|
pub struct ComfyUIConfig {
|
||||||
pub enabled: bool,
|
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 install_dir: Option<PathBuf>,
|
||||||
pub python_bin: String,
|
pub python_bin: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -59,13 +77,116 @@ pub struct ComfyUIConfig {
|
|||||||
pub max_restarts: u32,
|
pub max_restarts: u32,
|
||||||
pub external_url: Option<String>,
|
pub external_url: Option<String>,
|
||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
|
pub api_key: Option<String>,
|
||||||
pub agent_model: 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 {
|
impl Default for ComfyUIConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
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,
|
install_dir: None,
|
||||||
python_bin: "python3".to_string(),
|
python_bin: "python3".to_string(),
|
||||||
port: 8188,
|
port: 8188,
|
||||||
@@ -73,12 +194,121 @@ impl Default for ComfyUIConfig {
|
|||||||
max_restarts: 5,
|
max_restarts: 5,
|
||||||
external_url: None,
|
external_url: None,
|
||||||
base_url: Some("http://127.0.0.1:8188".to_string()),
|
base_url: Some("http://127.0.0.1:8188".to_string()),
|
||||||
|
api_key: None,
|
||||||
agent_model: "gpt-4o".to_string(),
|
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 {
|
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 {
|
pub fn target_base_url(&self) -> String {
|
||||||
self.external_url
|
self.external_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
+118
-1
@@ -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 models;
|
||||||
pub mod queries;
|
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> {
|
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()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.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)
|
.connect(database_url)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(pool)
|
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?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
];
|
||||||
@@ -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
@@ -24,6 +24,12 @@ pub enum AppError {
|
|||||||
#[error("Rate limited")]
|
#[error("Rate limited")]
|
||||||
RateLimited,
|
RateLimited,
|
||||||
|
|
||||||
|
#[error("Rate limited: {message}")]
|
||||||
|
RateLimitedWithRetry {
|
||||||
|
message: String,
|
||||||
|
retry_after_secs: u64,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("Service unavailable: {0}")]
|
#[error("Service unavailable: {0}")]
|
||||||
ServiceUnavailable(String),
|
ServiceUnavailable(String),
|
||||||
|
|
||||||
@@ -46,18 +52,37 @@ impl IntoResponse for AppError {
|
|||||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
|
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
|
||||||
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
|
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::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
|
||||||
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
|
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
|
||||||
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||||
AppError::Database(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,
|
status,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"error": {
|
"error": {
|
||||||
"message": message,
|
"message": message,
|
||||||
"type": status.canonical_reason(),
|
"type": status.canonical_reason(),
|
||||||
}
|
},
|
||||||
|
"retry_after_secs": retry_after_secs,
|
||||||
|
"billing": billing,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
|
|||||||
+30
-13
@@ -6,18 +6,23 @@ pub mod proxy;
|
|||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
|
pub mod template_http;
|
||||||
pub mod transformers;
|
pub mod transformers;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::Arc;
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: config::AppConfig,
|
pub config: config::AppConfig,
|
||||||
pub db: Arc<SqlitePool>,
|
pub db: Arc<SqlitePool>,
|
||||||
|
pub http: reqwest::Client,
|
||||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||||
pub comfyui_pool: Arc<services::comfyui_pool::ComfyUIPool>,
|
|
||||||
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
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 {
|
impl AppState {
|
||||||
@@ -25,6 +30,7 @@ impl AppState {
|
|||||||
config: config::AppConfig,
|
config: config::AppConfig,
|
||||||
db: SqlitePool,
|
db: SqlitePool,
|
||||||
builtin_keys: services::builtin_keys::BuiltinKeys,
|
builtin_keys: services::builtin_keys::BuiltinKeys,
|
||||||
|
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
@@ -36,25 +42,36 @@ impl AppState {
|
|||||||
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
||||||
config.comfyui.clone(),
|
config.comfyui.clone(),
|
||||||
boosapi_url.clone(),
|
boosapi_url.clone(),
|
||||||
config.auth.proxy_token.clone(),
|
runtime_auth.proxy_token(),
|
||||||
comfyui_agent_secret.clone(),
|
comfyui_agent_secret.clone(),
|
||||||
);
|
);
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
// Per-user multi-tenant ComfyUI pool. Backed by Docker today; the
|
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
|
||||||
// ContainerRuntime trait makes K3S a drop-in v2.
|
.connect_timeout(Duration::from_secs(15))
|
||||||
let docker = Arc::new(services::docker_runtime::DockerRuntime::new());
|
.pool_idle_timeout(Duration::from_secs(90))
|
||||||
let pool_cfg = services::comfyui_pool::PoolConfig::from_env(
|
.pool_max_idle_per_host(64)
|
||||||
format!("{}/v1", boosapi_url.trim_end_matches('/')),
|
.tcp_nodelay(true)
|
||||||
comfyui_agent_secret,
|
.build()
|
||||||
);
|
.unwrap_or_else(|err| {
|
||||||
let comfyui_pool = services::comfyui_pool::ComfyUIPool::new(docker, pool_cfg);
|
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
|
||||||
|
reqwest::Client::new()
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
|
http,
|
||||||
comfyui,
|
comfyui,
|
||||||
comfyui_pool,
|
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
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
@@ -1,11 +1,16 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::serve;
|
use axum::serve;
|
||||||
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
|
use boosapi::{
|
||||||
use clap::Parser;
|
config::AppConfig,
|
||||||
|
db,
|
||||||
|
services::{builtin_keys, runtime_auth},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -15,6 +20,9 @@ use tracing_subscriber::EnvFilter;
|
|||||||
about = "AI API Gateway — Rust rewrite of BoosAPI"
|
about = "AI API Gateway — Rust rewrite of BoosAPI"
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
|
||||||
/// Config file path
|
/// Config file path
|
||||||
#[arg(short, long, default_value = "config.toml")]
|
#[arg(short, long, default_value = "config.toml")]
|
||||||
config: String,
|
config: String,
|
||||||
@@ -40,10 +48,42 @@ struct Cli {
|
|||||||
check_config: bool,
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let args = Cli::parse_from(normalize_legacy_args());
|
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
|
// Load config
|
||||||
let mut config = AppConfig::load_from_file(&args.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()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
.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);
|
tracing::info!("Loaded configuration from {}", args.config);
|
||||||
|
|
||||||
|
let db_kind = db::DbKind::from_url(&config.db.url)?;
|
||||||
|
|
||||||
// Create database pool
|
// Create database pool
|
||||||
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
|
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
|
||||||
tracing::info!("Database connected");
|
tracing::info!("Database connected");
|
||||||
|
|
||||||
// Run migrations
|
// 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");
|
tracing::info!("Database migrations applied");
|
||||||
|
|
||||||
if args.migrate_only {
|
if args.migrate_only {
|
||||||
@@ -97,28 +144,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let builtin = builtin_keys::BuiltinKeys {
|
let builtin = builtin_keys::BuiltinKeys {
|
||||||
comfyui_agent: comfyui_agent_key,
|
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
|
// 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 {
|
if let Err(err) = state.comfyui.start().await {
|
||||||
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||||
}
|
}
|
||||||
let comfyui_process = state.comfyui.clone();
|
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
|
// Build router
|
||||||
let app = boosapi::routes::build_router(state);
|
let app = boosapi::routes::build_router(state);
|
||||||
|
|
||||||
@@ -128,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
serve(
|
serve(
|
||||||
tokio::net::TcpListener::bind(addr).await?,
|
tokio::net::TcpListener::bind(addr).await?,
|
||||||
app.into_make_service(),
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
)
|
)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -137,13 +176,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ mod tests {
|
|||||||
route_id: Some(10),
|
route_id: Some(10),
|
||||||
site_id: 20,
|
site_id: 20,
|
||||||
account_id: Some(30),
|
account_id: Some(30),
|
||||||
|
account_unit_cost: None,
|
||||||
base_url: "https://example.test/v1".into(),
|
base_url: "https://example.test/v1".into(),
|
||||||
api_key: "key".into(),
|
api_key: "key".into(),
|
||||||
model: "gpt-5.5".into(),
|
model: "gpt-5.5".into(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub struct SelectedChannel {
|
|||||||
pub route_id: Option<i64>,
|
pub route_id: Option<i64>,
|
||||||
pub site_id: i64,
|
pub site_id: i64,
|
||||||
pub account_id: Option<i64>,
|
pub account_id: Option<i64>,
|
||||||
|
pub account_unit_cost: Option<f64>,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
|||||||
+924
-51
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,26 @@ pub fn routes() -> Router<AppState> {
|
|||||||
"/api/account-tokens/sync-all",
|
"/api/account-tokens/sync-all",
|
||||||
post(sync_all_account_tokens),
|
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(
|
.route(
|
||||||
"/api/account-tokens/{id}",
|
"/api/account-tokens/{id}",
|
||||||
get(get_account_token)
|
get(get_account_token)
|
||||||
@@ -48,10 +68,15 @@ pub struct CreateAccountTokenRequest {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
|
#[serde(alias = "group")]
|
||||||
pub token_group: Option<String>,
|
pub token_group: Option<String>,
|
||||||
pub value_status: Option<String>,
|
pub value_status: Option<String>,
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
pub is_default: 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -61,12 +86,20 @@ pub struct UpdateAccountTokenRequest {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
|
#[serde(alias = "group")]
|
||||||
pub token_group: Option<String>,
|
pub token_group: Option<String>,
|
||||||
pub value_status: Option<String>,
|
pub value_status: Option<String>,
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
pub is_default: 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=
|
/// GET /api/account-tokens?accountId=
|
||||||
pub async fn list_account_tokens(
|
pub async fn list_account_tokens(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -119,6 +152,19 @@ pub async fn create_account_token(
|
|||||||
.map(|status| status.trim().to_string())
|
.map(|status| status.trim().to_string())
|
||||||
.filter(|status| !status.is_empty())
|
.filter(|status| !status.is_empty())
|
||||||
.unwrap_or_else(|| "valid".to_string());
|
.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();
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
@@ -130,8 +176,8 @@ pub async fn create_account_token(
|
|||||||
.bind(payload.account_id)
|
.bind(payload.account_id)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.bind(&payload.source)
|
.bind("a_note)
|
||||||
.bind(&payload.token_group)
|
.bind(&token_group)
|
||||||
.bind(&value_status)
|
.bind(&value_status)
|
||||||
.bind(payload.enabled.unwrap_or(true))
|
.bind(payload.enabled.unwrap_or(true))
|
||||||
.bind(payload.is_default.unwrap_or(false))
|
.bind(payload.is_default.unwrap_or(false))
|
||||||
@@ -209,6 +255,13 @@ pub async fn update_account_token(
|
|||||||
.map(|status| status.trim().to_string())
|
.map(|status| status.trim().to_string())
|
||||||
.filter(|status| !status.is_empty())
|
.filter(|status| !status.is_empty())
|
||||||
.unwrap_or(existing.value_status);
|
.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();
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
@@ -221,8 +274,8 @@ pub async fn update_account_token(
|
|||||||
.bind(account_id)
|
.bind(account_id)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.bind(payload.source.or(existing.source))
|
.bind(source.or(existing.source))
|
||||||
.bind(payload.token_group.or(existing.token_group))
|
.bind(token_group.or(existing.token_group))
|
||||||
.bind(&value_status)
|
.bind(&value_status)
|
||||||
.bind(payload.enabled.or(existing.enabled))
|
.bind(payload.enabled.or(existing.enabled))
|
||||||
.bind(payload.is_default.or(existing.is_default))
|
.bind(payload.is_default.or(existing.is_default))
|
||||||
@@ -263,14 +316,209 @@ pub async fn delete_account_token(
|
|||||||
Ok(Json(json!({ "success": true })))
|
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
|
/// 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");
|
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!({
|
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())
|
.filter(|token| !token.is_empty())
|
||||||
.unwrap_or_else(|| Uuid::new_v4().to_string())
|
.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
Reference in New Issue
Block a user