feat: add workflow studio and postgres support

This commit is contained in:
boos
2026-05-25 16:03:07 +08:00
parent 7cdb02ef1d
commit cb35d5fc6e
96 changed files with 12414 additions and 15548 deletions
-4
View File
@@ -1,4 +0,0 @@
[submodule "comfy_source"]
path = comfy_source
url = https://github.com/comfyanonymous/ComfyUI.git
branch = master
+13 -14
View File
@@ -4,15 +4,6 @@ version = "1.3.0"
edition = "2021"
description = "AI API Gateway — Rust rewrite of BoosAPI"
[features]
# Default off so stock builds keep using the legacy embedded ComfyUI
# singleton (`state.comfyui`). Enable once the per-user pool has been
# validated in staging (see openspec/changes/comfyui-multi-tenant-api-only,
# rollout step 9.2). Once 9.4 lands, this flag and the legacy path
# disappear together.
default = []
multi_tenant_comfy = []
[dependencies]
# Web framework
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
@@ -27,6 +18,8 @@ tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"postgres",
"any",
"migrate",
"chrono",
] }
@@ -75,11 +68,8 @@ once_cell = "1"
parking_lot = "0.12"
mime = "0.3"
base64 = "0.22"
# WebSocket client (used by /comfy/* WS proxy to forward upstream to per-user containers)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] }
async-trait = "0.1"
urlencoding = "2"
include_dir = "0.7"
mime_guess = "2"
[dev-dependencies]
rstest = "0.24"
@@ -93,6 +83,15 @@ opt-level = 3
lto = true
codegen-units = 1
# Faster production-like build for staging/manual verification. This keeps
# optimizations on but avoids the slow LTO + single-codegen-unit release path.
[profile.fast-release]
inherits = "release"
opt-level = 2
lto = false
codegen-units = 256
incremental = true
# Dev-profile speedups: line-tables-only debuginfo (much smaller .debug
# sections → much faster link) and 256 codegen units (more parallelism
# during codegen, slightly worse runtime — fine for dev). Dependencies
+24 -12
View File
@@ -2,14 +2,14 @@
# BoosAPI
**AI 聚合网关 + ComfyUI Agent — 将分散的中转站整合为一个统一入口**
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
<p>
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
<br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br>
<strong>内置 ComfyUI 视频助手</strong> — 自然语言描述脚本,自动生成角色、场景、TTS、ComfyUI 工作流。
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。
<br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p>
@@ -53,7 +53,7 @@
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
| 🎨 想用 ComfyUI 但不想配工作流 | **对话助手**,自然语言描述即可生成图像/视频 |
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
@@ -61,13 +61,14 @@
## ✨ 核心功能
### 🎨 内置 ComfyUI 视频助手(Rust)
### 🎨 Workflow Studio 与视频助手(Rust)
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音
- **ComfyUI 工作流导出**:完整 workflow JSON,可直接导入 ComfyUI
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
@@ -209,8 +210,8 @@ docker compose up -d
│ ├── db/ # 数据库模型与查询
│ ├── routes/
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
│ │ ├── comfyui_agent.rs # ComfyUI 视频助手 SSE 聊天
│ │ ├── comfyui/ # ComfyUI 代理(prompt/history/view)
│ │ ├── comfyui_agent.rs # 视频/工作流助手 SSE 聊天
│ │ ├── comfyui/ # 外部 ComfyUI 代理(prompt/history/view)
│ │ ├── proxy/ # OpenAI 兼容代理入口
│ │ ├── auth.rs # 用户注册/登录 JWT
│ │ ├── checkin.rs # 自动签到
@@ -233,7 +234,8 @@ docker compose up -d
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage + UserLoginPage(OAuthSlot 占位,行为由后续 change 接入)
│ │ ├── ComfyUIAgent.tsx # 视频助手聊天界面
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ...
│ └── components/
└── dist/web/ # 前端构建产物(由 Rust serve)
@@ -254,10 +256,19 @@ BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
# 数据库
# 数据库(默认 SQLite)
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
# 上游 API(ComfyUI Agent 回退用,默认本地模式时不需要设置)
# PostgreSQL 准备/迁移
# 当前版本保留 SQLite 作为运行主路径;可先把 SQLite 核心数据迁移到 PostgreSQL。
# 完整 PostgreSQL 业务查询适配需要后续切换运行时 SQL 方言。
# 示例:
# boosapi migrate-sqlite-to-postgres \
# --sqlite-url 'sqlite:data/boosapi.db?mode=rwc' \
# --postgres-url 'postgres://boosapi:password@127.0.0.1:5432/boosapi'
# 目标库已有数据时默认拒绝;确认覆盖时追加 --force-truncate。
# 上游 API(视频/工作流助手回退用,默认本地模式时不需要设置)
# 设置 BOOSAPI_UPSTREAM_BASE 后切换到 fallback-remote 模式;BOOSAPI_UPSTREAM_KEY 缺省时仍用内置 comfyui-agent key
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-your-key
@@ -272,12 +283,13 @@ BOOSAPI_WEB_DIR=dist/web
|----|------|
| 后端框架 | **Axum 0.8**(Rust) |
| 运行时 | **Tokio**(异步运行时) |
| 数据库 | **SQLite** + **SQLx**(编译期 SQL 检查) |
| 数据库 | **SQLite** 运行主路径 + **PostgreSQL** schema/迁移准备(SQLx) |
| 并行计算 | **Rayon**(CPU 密集任务) |
| 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| ComfyUI Agent | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| Workflow Studio | API-native 工作流入口,后续承接节点画布 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) |
| 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart |
BIN
View File
Binary file not shown.
+114 -80
View File
@@ -6,48 +6,78 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
# ── 参数 ──────────────────────────────────────────────────────────────────────
RELEASE=0
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
FRONTEND_ONLY=0
BACKEND_ONLY=0
NO_RUN=0
REBUILD_IMAGE=0
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-multi_tenant_comfy}"
FORCE_FRONTEND=0
SKIP_FRONTEND=0
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
usage() {
cat <<'EOF'
用法: ./build-and-run.sh [选项]
选项:
--dev 使用 dev 模式编译后端(默认,最快)
--release 使用 release 模式编译后端(更慢但性能好)
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
--profile <name> 指定构建 profile:dev / release / fast-release
--frontend-only 只构建前端
--backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存
--skip-frontend 跳过前端构建(沿用现有 dist/web)
--no-run 构建后不启动服务
--rebuild-image 强制重新构建多租户 ComfyUI 镜像(默认:已存在则跳过)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:multi_tenant_comfy)
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
-h, --help 显示帮助
示例:
./build-and-run.sh --release
./build-and-run.sh --fast-release
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--release) RELEASE=1 ;;
--dev) BUILD_PROFILE="dev" ;;
--release) BUILD_PROFILE="release" ;;
--fast-release) BUILD_PROFILE="fast-release" ;;
--profile)
shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
BUILD_PROFILE="$1"
;;
--frontend-only) FRONTEND_ONLY=1 ;;
--backend-only) BACKEND_ONLY=1 ;;
--force-frontend) FORCE_FRONTEND=1 ;;
--skip-frontend) SKIP_FRONTEND=1 ;;
--no-run) NO_RUN=1 ;;
--rebuild-image) REBUILD_IMAGE=1 ;;
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
# ── 工具函数 ──────────────────────────────────────────────────────────────────
if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--frontend-only 和 --backend-only 不能同时使用"
fi
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;;
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
esac
server_port() {
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
@@ -115,7 +145,7 @@ stop_previous_server() {
while IFS= read -r pid; do
[ -n "$pid" ] && binary_pids+=("$pid")
done < <(pgrep -f "target/(debug|release)/boosapi" 2>/dev/null || true)
done < <(pgrep -f "target/(debug|release|fast-release)/boosapi" 2>/dev/null || true)
stop_pids "boosapi binary" "${binary_pids[@]}"
@@ -131,40 +161,48 @@ stop_previous_server() {
fi
}
# ── 同步 ComfyUI 子模块 + 应用本地补丁 ─────────────────────────────────────────
# comfy_source/ 是 vanilla ComfyUI 的 git 子模块,本地补丁存放在
# comfy_patches/overlay/,由 comfy_patches/apply.sh 覆盖到子模块上。
# 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 "子模块已初始化"
build_frontend() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)"
return 0
fi
if [ -d "$ROOT_DIR/comfy_patches/overlay" ]; then
log "应用 ComfyUI 本地补丁 (comfy_patches/overlay → comfy_source/)..."
"$ROOT_DIR/comfy_patches/apply.sh"
ok "本地补丁已应用"
fi
fi
# ── 构建前端 ──────────────────────────────────────────────────────────────────
if [ "$BACKEND_ONLY" -eq 0 ]; then
log "构建前端 (Vite)..."
if ! command -v npx >/dev/null 2>&1; then
fail "未找到 npx,请先安装 Node.js"
fi
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'build-script-v2\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npx='
npx --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/"
return 0
fi
log "构建前端 (Vite)..."
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
npx vite build
mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/"
fi
}
# ── 构建后端 ──────────────────────────────────────────────────────────────────
if [ "$FRONTEND_ONLY" -eq 0 ]; then
build_backend() {
if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链"
fi
@@ -180,66 +218,62 @@ if [ "$FRONTEND_ONLY" -eq 0 ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
fi
if [ "$RELEASE" -eq 1 ]; then
if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..."
cargo build --release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..."
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi"
else
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi"
fi
fi
}
# ── 构建多租户 ComfyUI 镜像 ───────────────────────────────────────────────────
# 多租户 ComfyUI 镜像 (boosapi-comfyui:multi-tenant) 由 ComfyUIPool 在每次
# 拉新用户容器时使用。镜像很重(~700MB),不必每次 ./build-and-run.sh 都
# 重打。优先级:
# 1. 已存在同名镜像 → 跳过(--rebuild-image 强制重建)
# 2. 有 docker/boosapi-comfyui-multi-tenant-*.tar 备份 → docker load
# 3. 都没有 → docker build
#
# `docker info` 用来确认 daemon 真的在跑,避免在只装了 client 的环境里
# 卡 build。失败的话整段跳过,API 仍可走 legacy 单实例路径。
if [ "$FRONTEND_ONLY" -eq 0 ]; then
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
IMAGE="boosapi-comfyui:multi-tenant"
SAVED_TAR="$(ls "$ROOT_DIR"/docker/boosapi-comfyui-multi-tenant-*.tar 2>/dev/null | head -1 || true)"
DOCKERFILE="$ROOT_DIR/docker/Dockerfile.comfyui.multi-tenant"
if [ "$REBUILD_IMAGE" -eq 0 ] && docker image inspect "$IMAGE" >/dev/null 2>&1; then
log "镜像 $IMAGE 已存在,跳过构建(--rebuild-image 强制重建)"
elif [ "$REBUILD_IMAGE" -eq 0 ] && [ -n "$SAVED_TAR" ]; then
log "从 $SAVED_TAR 加载 $IMAGE..."
docker load -i "$SAVED_TAR"
ok "镜像已加载 → $IMAGE"
elif [ -f "$DOCKERFILE" ]; then
log "构建多租户 ComfyUI 镜像 ($IMAGE)..."
docker build \
-f "$DOCKERFILE" \
-t "$IMAGE" \
"$ROOT_DIR"
ok "ComfyUI 多租户镜像构建完成 → $IMAGE"
else
log "未找到 $DOCKERFILE,跳过镜像构建"
fi
else
log "docker 不可用(未安装或 daemon 未运行),跳过多租户 ComfyUI 镜像准备"
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
}
# ── 启动服务 ──────────────────────────────────────────────────────────────────
# ── 构建 / 启动 ────────────────────────────────────────────────────────────────
if [ "$NO_RUN" -eq 1 ] || [ "$FRONTEND_ONLY" -eq 1 ]; then
log "跳过启动(--no-run 或 --frontend-only)"
if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend
log "跳过启动(--frontend-only)"
exit 0
fi
if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend
fi
build_backend
if [ "$NO_RUN" -eq 1 ]; then
log "跳过启动(--no-run)"
exit 0
fi
# 启动前清理旧后端进程和端口占用,避免 exec 后绑定端口失败。
stop_previous_server
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"
-136
View File
@@ -1,136 +0,0 @@
# comfy_patches
Local patches that ride on top of vanilla ComfyUI (the `comfy_source/` git
submodule).
`overlay/` mirrors the ComfyUI tree — every file under it is copied
verbatim over the matching path inside `comfy_source/` by
`apply.sh`. Run that whenever you bump the submodule or after a fresh
clone with `--recurse-submodules`.
## Why an overlay instead of a fork
Pinning a fork would mean rebasing it every time we bump ComfyUI. An
overlay keeps the upstream submodule clean: the patches are visible in
git, easy to diff against vanilla, and trivial to drop once they're
upstreamed.
## Patch inventory
| File | Type | Notes |
|---|---|
| `comfy_api_nodes/util/_helpers.py` | BoosAPI integration | `get_auth_header` falls back to `BOOSAPI_BUILTIN_KEY`; `default_base_url()` reads `--comfy-api-base`. Required for the multi-tenant pool — the per-user containers authenticate to BoosAPI through this. |
| `app/frontend_management.py` + `server.py` + `openapi.yaml` | Feature | Adds `comfy_package_versions` to `/system_stats` so the admin UI can surface drift across `comfy*` packages. Candidate for upstreaming. |
| `comfy_extras/nodes_audio.py` | Hardening | Guards Save/Preview/Trim/Split nodes against `audio is None` (happens when source video has no audio track). Candidate for upstreaming. |
| `requirements.txt` | Bump | `comfyui-workflow-templates==0.9.77` (vanilla pins 0.9.75). |
| `tests-unit/app_test/frontend_manager_test.py` | Test | Covers the new `get_comfy_package_versions()` path. |
| `tests-unit/comfy_test/model_detection_test.py` | Test | Adjusted assertions for the audio-node guards. |
| `README.md`, `SECURITY.md` | Docs | Local edits. Not load-bearing. |
## Apply
```bash
./comfy_patches/apply.sh
```
Idempotent — safe to re-run. Aborts if `comfy_source/` is dirty (so you
don't silently clobber upstream work-in-progress).
## Upgrading vanilla ComfyUI
This is the SOP whenever you want to bump the `comfy_source/` submodule
to a newer upstream commit / tag.
```bash
cd /root/metapi
# 1. Pull the new upstream commit into the submodule.
# Use --remote to follow the branch tracked in .gitmodules; pin to a
# specific tag with `git -C comfy_source checkout <tag>` instead.
git submodule update --remote comfy_source
# 2. Re-apply the local overlay on top.
# apply.sh aborts if comfy_source/ is dirty — commit or stash first.
./comfy_patches/apply.sh
# 3. Rebuild the multi-tenant image.
# --no-run skips starting the boosapi binary; we only want the image.
./build-and-run.sh --no-run
# 4. Smoke-test the new image without touching the live pool.
docker run --rm -p 18188:8188 \
-e BOOSAPI_BUILTIN_KEY=test \
-e BOOSAPI_BASE=http://host.docker.internal:8787 \
-e BOOSAPI_KEY=test \
boosapi-comfyui:multi-tenant &
sleep 5
curl -s http://localhost:18188/system_stats | jq .comfy_package_versions
# Expect a JSON object with comfy / comfyui_frontend_package versions.
# Ctrl-C the container when done; --rm cleans it up.
```
### When upstream changes a file we patch
If `apply.sh` finds that vanilla has changed under one of the overlay
paths, it copies our overlay over anyway — vanilla's new content is
silently lost. To detect this before bumping:
```bash
# Diff our overlay against the new vanilla, file by file.
for f in $(cd comfy_patches/overlay && find . -type f); do
diff -u "comfy_source/$f" "comfy_patches/overlay/$f" \
| head -40
done
```
For each diff that shows upstream behaviour we want to keep (not just
formatting), edit `comfy_patches/overlay/<file>` to merge the
intent — then re-run `apply.sh`. The patch-inventory table above is
the canonical list of files to check.
### Backout
If the new upstream breaks something:
```bash
# Roll the submodule back to whatever commit was pinned in HEAD.
git submodule update comfy_source
./comfy_patches/apply.sh
./build-and-run.sh --no-run
```
The previous image is still in `docker images` (we don't auto-prune),
so you can also fall back to it via tag pin in production until the
upgrade is fixed.
### Restoring from the saved image
A known-good build of the image is exported at
`docker/boosapi-comfyui-multi-tenant-20260520.tar` (754 MiB). To
restore on this or any other host:
```bash
docker load -i docker/boosapi-comfyui-multi-tenant-20260520.tar
# → boosapi-comfyui:multi-tenant
```
Use this when a rebuild fails (network, dependency drift) and you need
the running pool back fast — load the tar, the per-user containers
will pick the image up on next launch.
## Runtime contract: ComfyUI calls our API, not the upstream's
The point of the `_helpers.py` overlay is to redirect every "call the
ComfyUI cloud" code path inside the per-user container to BoosAPI:
- `--comfy-api-base http://host.docker.internal:8787/v1` is forwarded
by `docker/comfyui-entrypoint.sh` from the `BOOSAPI_BASE` env var.
- `BOOSAPI_BUILTIN_KEY` / `BOOSAPI_KEY` become the auth header that
ComfyUI's API nodes send. BoosAPI verifies it, looks up the
`token_routes` row for the requested model, picks a `route_channels`
row, and forwards to the real upstream account.
So the per-user ComfyUI instances never talk to upstream platforms
directly — every API node call funnels through BoosAPI's routing
layer (the same path serving `/v1/chat/completions` for the rest of
the product).
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Apply BoosAPI overlay patches on top of the vanilla ComfyUI submodule.
#
# Run after `git submodule update --init` or after bumping the submodule
# pointer. Idempotent — files are overwritten with the overlay copy.
#
# Refuses to clobber a dirty submodule working tree so you don't lose
# upstream work-in-progress.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OVERLAY="$ROOT/comfy_patches/overlay"
TARGET="$ROOT/comfy_source"
if [ ! -d "$TARGET" ]; then
echo "comfy_source/ does not exist — did you forget --recurse-submodules?" >&2
exit 1
fi
# Refuse to apply over a dirty submodule (anything other than our overlay diff).
# The overlay files _will_ show as modified after we apply, so we only check
# for OTHER changes by comparing against the upstream tree.
if [ -d "$TARGET/.git" ] || [ -f "$TARGET/.git" ]; then
cd "$TARGET"
# Stash any pre-existing local changes that aren't part of the overlay.
# We list overlay paths and exclude them from the dirty check.
overlay_paths=$(cd "$OVERLAY" && find . -type f -printf '%P\n')
unexpected=$(git status --porcelain | awk '{print $2}' | grep -vxF -f <(printf '%s\n' "$overlay_paths") || true)
if [ -n "$unexpected" ]; then
echo "comfy_source/ has unexpected local changes — aborting:" >&2
echo "$unexpected" >&2
echo "" >&2
echo "Commit/stash inside the submodule, or rm -rf and re-init it." >&2
exit 1
fi
cd "$ROOT"
fi
count=0
while IFS= read -r -d '' src; do
rel="${src#"$OVERLAY/"}"
dst="$TARGET/$rel"
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
count=$((count + 1))
done < <(find "$OVERLAY" -type f -print0)
echo "Applied $count overlay file(s) to comfy_source/."
-479
View File
@@ -1,479 +0,0 @@
<div align="center">
# ComfyUI
**The most powerful and modular AI engine for content creation.**
[![Website][website-shield]][website-url]
[![Dynamic JSON Badge][discord-shield]][discord-url]
[![Twitter][twitter-shield]][twitter-url]
[![Matrix][matrix-shield]][matrix-url]
<br>
[![][github-release-shield]][github-release-link]
[![][github-release-date-shield]][github-release-link]
[![][github-downloads-shield]][github-downloads-link]
[![][github-downloads-latest-shield]][github-downloads-link]
[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white
[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat
[website-url]: https://www.comfy.org/
<!-- Workaround to display total user from https://github.com/badges/shields/issues/4500#issuecomment-2060079995 -->
[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total
[discord-url]: https://www.comfy.org/discord
[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI
[twitter-url]: https://x.com/ComfyUI
[github-release-shield]: https://img.shields.io/github/v/release/comfyanonymous/ComfyUI?style=flat&sort=semver
[github-release-link]: https://github.com/comfyanonymous/ComfyUI/releases
[github-release-date-shield]: https://img.shields.io/github/release-date/comfyanonymous/ComfyUI?style=flat
[github-downloads-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/total?style=flat
[github-downloads-latest-shield]: https://img.shields.io/github/downloads/comfyanonymous/ComfyUI/latest/total?style=flat&label=downloads%40latest
[github-downloads-link]: https://github.com/comfyanonymous/ComfyUI/releases
<img width="1590" height="795" alt="ComfyUI Screenshot" src="https://github.com/user-attachments/assets/36e065e0-bfae-4456-8c7f-8369d5ea48a2" />
<br>
</div>
ComfyUI is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Its powerful and modular node graph interface empowers creatives to generate images, videos, 3D models, audio, and more...
- ComfyUI natively supports the latest open-source state of the art models.
- API nodes provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc.
- It is available on Windows, Linux, and macOS, locally with our desktop application or on our cloud.
- The most sophisticated workflows can be exposed through a simple UI thanks to App Mode.
- It integrates seamlessly into production pipelines with our API endpoints.
## Get Started
### Local
#### [Desktop Application](https://www.comfy.org/download)
- The easiest way to get started.
- Available on Windows & macOS.
#### [Windows Portable Package](#installing)
- Get the latest commits and completely portable.
- Available on Windows.
#### [Manual Install](#manual-install-windows-linux)
Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
### Cloud
#### [Comfy Cloud](https://www.comfy.org/cloud)
- Our official paid cloud version for those who can't afford local hardware.
## Examples
See what ComfyUI can do with the [newer template workflows](https://comfy.org/workflows) or old [example workflows](https://comfyanonymous.github.io/ComfyUI_examples/).
## Features
- Nodes/graph/flowchart interface to experiment and create complex Stable Diffusion workflows without needing to code anything.
- NOTE: There are many more models supported than the list below, if you want to see what is supported see our templates list inside ComfyUI.
- Image Models
- SD1.x, SD2.x ([unCLIP](https://comfyanonymous.github.io/ComfyUI_examples/unclip/))
- [SDXL](https://comfyanonymous.github.io/ComfyUI_examples/sdxl/), [SDXL Turbo](https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/)
- [Stable Cascade](https://comfyanonymous.github.io/ComfyUI_examples/stable_cascade/)
- [SD3 and SD3.5](https://comfyanonymous.github.io/ComfyUI_examples/sd3/)
- Pixart Alpha and Sigma
- [AuraFlow](https://comfyanonymous.github.io/ComfyUI_examples/aura_flow/)
- [HunyuanDiT](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_dit/)
- [Flux](https://comfyanonymous.github.io/ComfyUI_examples/flux/)
- [Lumina Image 2.0](https://comfyanonymous.github.io/ComfyUI_examples/lumina2/)
- [HiDream](https://comfyanonymous.github.io/ComfyUI_examples/hidream/)
- [Qwen Image](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/)
- [Hunyuan Image 2.1](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_image/)
- [Flux 2](https://comfyanonymous.github.io/ComfyUI_examples/flux2/)
- [Z Image](https://comfyanonymous.github.io/ComfyUI_examples/z_image/)
- Ernie Image
- Image Editing Models
- [Omnigen 2](https://comfyanonymous.github.io/ComfyUI_examples/omnigen/)
- [Flux Kontext](https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-kontext-image-editing-model)
- [HiDream E1.1](https://comfyanonymous.github.io/ComfyUI_examples/hidream/#hidream-e11)
- [Qwen Image Edit](https://comfyanonymous.github.io/ComfyUI_examples/qwen_image/#edit-model)
- Video Models
- [Stable Video Diffusion](https://comfyanonymous.github.io/ComfyUI_examples/video/)
- [Mochi](https://comfyanonymous.github.io/ComfyUI_examples/mochi/)
- [LTX-Video](https://comfyanonymous.github.io/ComfyUI_examples/ltxv/)
- [Hunyuan Video](https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/)
- [Wan 2.1](https://comfyanonymous.github.io/ComfyUI_examples/wan/)
- [Wan 2.2](https://comfyanonymous.github.io/ComfyUI_examples/wan22/)
- [Hunyuan Video 1.5](https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video-1-5)
- Audio Models
- [Stable Audio](https://comfyanonymous.github.io/ComfyUI_examples/audio/)
- [ACE Step](https://comfyanonymous.github.io/ComfyUI_examples/audio/)
- 3D Models
- [Hunyuan3D 2.0](https://docs.comfy.org/tutorials/3d/hunyuan3D-2)
- Asynchronous Queue system
- Many optimizations: Only re-executes the parts of the workflow that changes between executions.
- Smart memory management: can automatically run large models on GPUs with as low as 1GB vram with smart offloading.
- Works even if you don't have a GPU with: ```--cpu``` (slow)
- Can load ckpt and safetensors: All in one checkpoints or standalone diffusion models, VAEs and CLIP models.
- Safe loading of ckpt, pt, pth, etc.. files.
- Embeddings/Textual inversion
- [Loras (regular, locon and loha)](https://comfyanonymous.github.io/ComfyUI_examples/lora/)
- [Hypernetworks](https://comfyanonymous.github.io/ComfyUI_examples/hypernetworks/)
- Loading full workflows (with seeds) from generated PNG, WebP and FLAC files.
- Saving/Loading workflows as Json files.
- Nodes interface can be used to create complex workflows like one for [Hires fix](https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/) or much more advanced ones.
- [Area Composition](https://comfyanonymous.github.io/ComfyUI_examples/area_composition/)
- [Inpainting](https://comfyanonymous.github.io/ComfyUI_examples/inpaint/) with both regular and inpainting models.
- [ControlNet and T2I-Adapter](https://comfyanonymous.github.io/ComfyUI_examples/controlnet/)
- [Upscale Models (ESRGAN, ESRGAN variants, SwinIR, Swin2SR, etc...)](https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/)
- [GLIGEN](https://comfyanonymous.github.io/ComfyUI_examples/gligen/)
- [Model Merging](https://comfyanonymous.github.io/ComfyUI_examples/model_merging/)
- [LCM models and Loras](https://comfyanonymous.github.io/ComfyUI_examples/lcm/)
- Latent previews with [TAESD](#how-to-show-high-quality-previews)
- Works fully offline: core will never download anything unless you want to.
- Optional API nodes to use paid models from external providers through the online [Comfy API](https://docs.comfy.org/tutorials/api-nodes/overview) disable with: `--disable-api-nodes`
- [Config file](extra_model_paths.yaml.example) to set the search paths for models.
Workflow examples can be found on the [Examples page](https://comfyanonymous.github.io/ComfyUI_examples/)
## Release Process
ComfyUI follows a weekly release cycle targeting Monday but this regularly changes because of model releases or large changes to the codebase. There are three interconnected repositories:
1. **[ComfyUI Core](https://github.com/comfyanonymous/ComfyUI)**
- Releases a new major stable version (e.g., v0.7.0) roughly every 2 weeks.
- Starting from v0.4.0 patch versions will be used for fixes backported onto the current stable release.
- Minor versions will be used for releases off the master branch.
- Patch versions may still be used for releases on the master branch in cases where a backport would not make sense.
- Commits outside of the stable release tags may be very unstable and break many custom nodes.
- Serves as the foundation for the desktop release
2. **[ComfyUI Desktop](https://github.com/Comfy-Org/desktop)**
- Builds a new release using the latest stable core version
3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)**
- Every 2+ weeks frontend updates are merged into the core repository
- Features are frozen for the upcoming core release
- Development continues for the next release cycle
## Shortcuts
| Keybind | Explanation |
|------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| `Ctrl` + `Enter` | Queue up current graph for generation |
| `Ctrl` + `Shift` + `Enter` | Queue up current graph as first for generation |
| `Ctrl` + `Alt` + `Enter` | Cancel current generation |
| `Ctrl` + `Z`/`Ctrl` + `Y` | Undo/Redo |
| `Ctrl` + `S` | Save workflow |
| `Ctrl` + `O` | Load workflow |
| `Ctrl` + `A` | Select all nodes |
| `Alt `+ `C` | Collapse/uncollapse selected nodes |
| `Ctrl` + `M` | Mute/unmute selected nodes |
| `Ctrl` + `B` | Bypass selected nodes (acts like the node was removed from the graph and the wires reconnected through) |
| `Delete`/`Backspace` | Delete selected nodes |
| `Ctrl` + `Backspace` | Delete the current graph |
| `Space` | Move the canvas around when held and moving the cursor |
| `Ctrl`/`Shift` + `Click` | Add clicked node to selection |
| `Ctrl` + `C`/`Ctrl` + `V` | Copy and paste selected nodes (without maintaining connections to outputs of unselected nodes) |
| `Ctrl` + `C`/`Ctrl` + `Shift` + `V` | Copy and paste selected nodes (maintaining connections from outputs of unselected nodes to inputs of pasted nodes) |
| `Shift` + `Drag` | Move multiple selected nodes at the same time |
| `Ctrl` + `D` | Load default graph |
| `Alt` + `+` | Canvas Zoom in |
| `Alt` + `-` | Canvas Zoom out |
| `Ctrl` + `Shift` + LMB + Vertical drag | Canvas Zoom in/out |
| `P` | Pin/Unpin selected nodes |
| `Ctrl` + `G` | Group selected nodes |
| `Q` | Toggle visibility of the queue |
| `H` | Toggle visibility of history |
| `R` | Refresh graph |
| `F` | Show/Hide menu |
| `.` | Fit view to selection (Whole graph when nothing is selected) |
| Double-Click LMB | Open node quick search palette |
| `Shift` + Drag | Move multiple wires at once |
| `Ctrl` + `Alt` + LMB | Disconnect all wires from clicked slot |
`Ctrl` can also be replaced with `Cmd` instead for macOS users
# Installing
## Windows Portable
There is a portable standalone build for Windows that should work for running on Nvidia GPUs or for running on your CPU only on the [releases page](https://github.com/comfyanonymous/ComfyUI/releases).
### [Direct link to download](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z)
Simply download, extract with [7-Zip](https://7-zip.org) or with the windows explorer on recent windows versions and run. For smaller models you normally only need to put the checkpoints (the huge ckpt/safetensors files) in: ComfyUI\models\checkpoints but many of the larger models have multiple files. Make sure to follow the instructions to know which subfolder to put them in ComfyUI\models\
If you have trouble extracting it, right click the file -> properties -> unblock
The portable above currently comes with python 3.13 and pytorch cuda 13.0. Update your Nvidia drivers if it doesn't start.
#### All Official Portable Downloads:
[Portable for AMD GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_amd.7z)
[Portable for Intel GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_intel.7z)
[Portable for Nvidia GPUs](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia.7z) (supports 20 series and above).
[Portable for Nvidia GPUs with pytorch cuda 12.6 and python 3.12](https://github.com/comfyanonymous/ComfyUI/releases/latest/download/ComfyUI_windows_portable_nvidia_cu126.7z) (Supports Nvidia 10 series and older GPUs).
#### How do I share models between another UI and ComfyUI?
See the [Config file](extra_model_paths.yaml.example) to set the search paths for models. In the standalone windows build you can find this file in the ComfyUI directory. Rename this file to extra_model_paths.yaml and edit it with your favorite text editor.
## [comfy-cli](https://docs.comfy.org/comfy-cli/getting-started)
You can install and start ComfyUI using comfy-cli:
```bash
pip install comfy-cli
comfy install
```
## Manual Install (Windows, Linux)
Python 3.14 works but some custom nodes may have issues. The free threaded variant works but some dependencies will enable the GIL so it's not fully supported.
Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12
torch 2.4 and above is supported but some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old.
### Instructions:
Git clone this repo.
Put your SD checkpoints (the huge ckpt/safetensors files) in: models/checkpoints
Put your VAE in: models/vae
### AMD GPUs (Linux)
AMD users can install rocm and pytorch with pip if you don't have it already installed, this is the command to install the stable version:
```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm7.2```
This is the command to install the nightly with ROCm 7.2 which might have some performance improvements:
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2```
### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only.
These have less hardware support than the builds above but they work on windows. You also need to install the pytorch version specific to your hardware.
RDNA 3 (RX 7000 series):
```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/```
RDNA 3.5 (Strix halo/Ryzen AI Max+ 365):
```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx1151/```
RDNA 4 (RX 9000 series):
```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx120X-all/```
### Intel GPUs (Windows and Linux)
Intel Arc GPU users can install native PyTorch with torch.xpu support using pip. More information can be found [here](https://pytorch.org/docs/main/notes/get_start_xpu.html)
1. To install PyTorch xpu, use the following command:
```pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu```
This is the command to install the Pytorch xpu nightly which might have some performance improvements:
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/xpu```
### NVIDIA
Nvidia users should install stable pytorch using this command:
```pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130```
This is the command to install pytorch nightly instead which might have performance improvements.
```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu132```
#### Troubleshooting
If you get the "Torch not compiled with CUDA enabled" error, uninstall torch with:
```pip uninstall torch```
And install it again with the command above.
### Dependencies
Install the dependencies by opening your terminal inside the ComfyUI folder and:
```pip install -r requirements.txt```
After this you should have everything installed and can proceed to running ComfyUI.
### Others:
#### Apple Mac silicon
You can install ComfyUI in Apple Mac silicon (M1 or M2) with any recent macOS version.
1. Install pytorch nightly. For instructions, read the [Accelerated PyTorch training on Mac](https://developer.apple.com/metal/pytorch/) Apple Developer guide (make sure to install the latest pytorch nightly).
1. Follow the [ComfyUI manual installation](#manual-install-windows-linux) instructions for Windows and Linux.
1. Install the ComfyUI [dependencies](#dependencies). If you have another Stable Diffusion UI [you might be able to reuse the dependencies](#i-already-have-another-ui-for-stable-diffusion-installed-do-i-really-have-to-install-all-of-these-dependencies).
1. Launch ComfyUI by running `python main.py`
> **Note**: Remember to add your models, VAE, LoRAs etc. to the corresponding Comfy folders, as discussed in [ComfyUI manual installation](#manual-install-windows-linux).
#### Ascend NPUs
For models compatible with Ascend Extension for PyTorch (torch_npu). To get started, ensure your environment meets the prerequisites outlined on the [installation](https://ascend.github.io/docs/sources/ascend/quick_install.html) page. Here's a step-by-step guide tailored to your platform and installation method:
1. Begin by installing the recommended or newer kernel version for Linux as specified in the Installation page of torch-npu, if necessary.
2. Proceed with the installation of Ascend Basekit, which includes the driver, firmware, and CANN, following the instructions provided for your specific platform.
3. Next, install the necessary packages for torch-npu by adhering to the platform-specific instructions on the [Installation](https://ascend.github.io/docs/sources/pytorch/install.html#pytorch) page.
4. Finally, adhere to the [ComfyUI manual installation](#manual-install-windows-linux) guide for Linux. Once all components are installed, you can run ComfyUI as described earlier.
#### Cambricon MLUs
For models compatible with Cambricon Extension for PyTorch (torch_mlu). Here's a step-by-step guide tailored to your platform and installation method:
1. Install the Cambricon CNToolkit by adhering to the platform-specific instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cntoolkit_3.7.2/cntoolkit_install_3.7.2/index.html)
2. Next, install the PyTorch(torch_mlu) following the instructions on the [Installation](https://www.cambricon.com/docs/sdk_1.15.0/cambricon_pytorch_1.17.0/user_guide_1.9/index.html)
3. Launch ComfyUI by running `python main.py`
#### Iluvatar Corex
For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step guide tailored to your platform and installation method:
1. Install the Iluvatar Corex Toolkit by adhering to the platform-specific instructions on the [Installation](https://support.iluvatar.com/#/DocumentCentre?id=1&nameCenter=2&productId=520117912052801536)
2. Launch ComfyUI by running `python main.py`
## [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager/tree/manager-v4)
**ComfyUI-Manager** is an extension that allows you to easily install, update, and manage custom nodes for ComfyUI.
### Setup
1. Install the manager dependencies:
```bash
pip install -r manager_requirements.txt
```
2. Enable the manager with the `--enable-manager` flag when running ComfyUI:
```bash
python main.py --enable-manager
```
### Command Line Options
| Flag | Description |
|------|-------------|
| `--enable-manager` | Enable ComfyUI-Manager |
| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (requires `--enable-manager`) |
| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) |
# Running
```python main.py```
### For AMD cards not officially supported by ROCm
Try running it with this command if you have issues:
For 6700, 6600 and maybe other RDNA2 or older: ```HSA_OVERRIDE_GFX_VERSION=10.3.0 python main.py```
For AMD 7600 and maybe other RDNA3 cards: ```HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py```
### AMD ROCm Tips
You can enable experimental memory efficient attention on recent pytorch in ComfyUI on some AMD GPUs using this command, it should already be enabled by default on RDNA3. If this improves speed for you on latest pytorch on your GPU please report it so that I can enable it by default.
```TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 python main.py --use-pytorch-cross-attention```
You can also try setting this env variable `PYTORCH_TUNABLEOP_ENABLED=1` which might speed things up at the cost of a very slow initial run.
# Notes
Only parts of the graph that have an output with all the correct inputs will be executed.
Only parts of the graph that change from each execution to the next will be executed, if you submit the same graph twice only the first will be executed. If you change the last part of the graph only the part you changed and the part that depends on it will be executed.
Dragging a generated png on the webpage or loading one will give you the full workflow including seeds that were used to create it.
You can use () to change emphasis of a word or phrase like: (good code:1.2) or (bad code:0.8). The default emphasis for () is 1.1. To use () characters in your actual prompt escape them like \\( or \\).
You can use {day|night}, for wildcard/dynamic prompts. With this syntax "{wild|card|test}" will be randomly replaced by either "wild", "card" or "test" by the frontend every time you queue the prompt. To use {} characters in your actual prompt escape them like: \\{ or \\}.
Dynamic prompts also support C-style comments, like `// comment` or `/* comment */`.
To use a textual inversion concepts/embeddings in a text prompt put them in the models/embeddings directory and use them in the CLIPTextEncode node like this (you can omit the .pt extension):
```embedding:embedding_filename.pt```
## How to show high-quality previews?
Use ```--preview-method auto``` to enable previews.
The default installation includes a fast latent preview method that's low-resolution. To enable higher-quality previews with [TAESD](https://github.com/madebyollin/taesd), download the [taesd_decoder.pth, taesdxl_decoder.pth, taesd3_decoder.pth and taef1_decoder.pth](https://github.com/madebyollin/taesd/) and place them in the `models/vae_approx` folder. Once they're installed, restart ComfyUI and launch it with `--preview-method taesd` to enable high-quality previews.
## How to use TLS/SSL?
Generate a self-signed certificate (not appropriate for shared/production use) and key by running the command: `openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname"`
Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app will now be accessible with `https://...` instead of `http://...`.
> Note: Windows users can use [alexisrolland/docker-openssl](https://github.com/alexisrolland/docker-openssl) or one of the [3rd party binary distributions](https://wiki.openssl.org/index.php/Binaries) to run the command example above.
<br/><br/>If you use a container, note that the volume mount `-v` can be a relative path so `... -v ".\:/openssl-certs" ...` would create the key & cert files in the current directory of your command prompt or powershell terminal.
## Support and dev channel
[Discord](https://comfy.org/discord): Try the #help or #feedback channels.
[Matrix space: #comfyui_space:matrix.org](https://app.element.io/#/room/%23comfyui_space%3Amatrix.org) (it's like discord but open source).
See also: [https://www.comfy.org/](https://www.comfy.org/)
> _psst — we're hiring!_ Help build ComfyUI: [comfy.org/careers](https://www.comfy.org/careers)
## Frontend Development
As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). This repository now hosts the compiled JS (from TS/Vue) under the `web/` directory.
### Reporting Issues and Requesting Features
For any bugs, issues, or feature requests related to the frontend, please use the [ComfyUI Frontend repository](https://github.com/Comfy-Org/ComfyUI_frontend). This will help us manage and address frontend-specific concerns more efficiently.
### Using the Latest Frontend
The new frontend is now the default for ComfyUI. However, please note:
1. The frontend in the main ComfyUI repository is updated fortnightly.
2. Daily releases are available in the separate frontend repository.
To use the most up-to-date frontend version:
1. For the latest daily release, launch ComfyUI with this command line argument:
```
--front-end-version Comfy-Org/ComfyUI_frontend@latest
```
2. For a specific version, replace `latest` with the desired version number:
```
--front-end-version Comfy-Org/ComfyUI_frontend@1.2.2
```
This approach allows you to easily switch between the stable fortnightly release and the cutting-edge daily updates, or even specific versions for testing purposes.
### Accessing the Legacy Frontend
If you need to use the legacy frontend for any reason, you can access it using the following command line argument:
```
--front-end-version Comfy-Org/ComfyUI_legacy_frontend@latest
```
This will use a snapshot of the legacy frontend preserved in the [ComfyUI Legacy Frontend repository](https://github.com/Comfy-Org/ComfyUI_legacy_frontend).
# QA
### Which GPU should I buy for this?
[See this page for some recommendations](https://github.com/comfyanonymous/ComfyUI/wiki/Which-GPU-should-I-buy-for-ComfyUI)
-44
View File
@@ -1,44 +0,0 @@
# Security Policy
## Scope
ComfyUI is designed to run locally. By default, the server binds to `127.0.0.1`, meaning only the user's own machine can reach it. Our threat model assumes:
- The user installed ComfyUI through a supported channel: the desktop application, the portable build, or a manual install following the README.
- The user has not installed untrusted custom nodes. Custom nodes are arbitrary Python code and are trusted as much as any other software the user chooses to install.
- Anyone with access to the ComfyUI URL is trusted (a direct consequence of the localhost-only default).
- PyTorch and other dependencies are at the versions we ship or recommend in the README.
A report is in scope only if it affects a user operating within this threat model.
## What We Consider a Vulnerability
We want to hear about issues where a **reasonable user** — someone who does not install random untrusted nodes and who reads UI prompts and warnings before clicking through them — can be harmed by ComfyUI itself.
The clearest example: a workflow file that such a user might plausibly load and run, using only built-in nodes, that results in **untrusted code execution, arbitrary file read/write outside expected directories, or credential/data exfiltration**.
When submitting a report, please include a clear description of *why this is a problem for a typical local ComfyUI user*. Reports without this context are difficult to act on.
## What We Do Not Consider a Security Vulnerability
Please report the following through our regular [GitHub issues](https://github.com/comfyanonymous/ComfyUI/issues) instead. Filing them as security reports will likely cause them to be deprioritized or closed.
- **Issues requiring `--listen` or any non-default network exposure.** ComfyUI binds to localhost by default. If a remote attacker needs to reach the server for the attack to work, the user has chosen to expose it and is responsible for securing that deployment (firewall, reverse proxy, authentication, etc.). These are bugs, not vulnerabilities.
- **`torch.load` and related deserialization issues in old PyTorch versions.** These are upstream PyTorch issues. Our distributions ship with — and our documentation recommends — recent PyTorch versions where these are addressed.
- **Vulnerabilities that depend on outdated library versions** that we neither ship nor recommend (e.g., requiring PyTorch 2.6 or older).
- **Issues that require a specific custom node to be installed.** Custom nodes are third-party code. Report these to the maintainer of that node.
- **Crashes, hangs, or resource exhaustion from a loaded workflow.** Annoying, but not a security issue in our model. File a regular bug.
- **Social-engineering scenarios** where the user is expected to ignore an explicit UI warning or prompt.
## Reporting
If you believe you have found an issue that falls within the scope above, please report it privately via GitHub's [Report a vulnerability](https://github.com/comfyanonymous/ComfyUI/security/advisories/new) feature rather than opening a public issue.
Please include:
1. A description of the vulnerability and the affected component.
2. Reproduction steps, ideally with a minimal workflow file or proof-of-concept.
3. The ComfyUI version, install method (desktop / portable / manual), and OS.
4. An explanation of how this affects a typical local user as described in the threat model.
We will acknowledge valid reports and coordinate a fix and disclosure timeline with you.
@@ -1,440 +0,0 @@
from __future__ import annotations
import argparse
import logging
import os
import re
import sys
import tempfile
import zipfile
import importlib
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Dict, TypedDict, Optional
from aiohttp import web
from importlib.metadata import version
import requests
from typing_extensions import NotRequired
from utils.install_util import get_missing_requirements_message, get_required_packages_versions
from comfy.cli_args import DEFAULT_VERSION_STRING
import app.logger
def frontend_install_warning_message():
return f"""
{get_missing_requirements_message()}
The ComfyUI frontend is shipped in a pip package so it needs to be updated separately from the ComfyUI code.
""".strip()
def parse_version(version: str) -> tuple[int, int, int]:
return tuple(map(int, version.split(".")))
def is_valid_version(version: str) -> bool:
"""Validate if a string is a valid semantic version (X.Y.Z format)."""
pattern = r"^(\d+)\.(\d+)\.(\d+)$"
return bool(re.match(pattern, version))
def get_required_frontend_version():
return get_required_packages_versions().get("comfyui-frontend-package", None)
COMFY_PACKAGE_VERSIONS = []
def get_comfy_package_versions():
"""List installed/required versions for every comfy* package in requirements.txt."""
if COMFY_PACKAGE_VERSIONS:
return COMFY_PACKAGE_VERSIONS.copy()
out = COMFY_PACKAGE_VERSIONS
for name, required in (get_required_packages_versions() or {}).items():
if not name.startswith("comfy"):
continue
try:
installed = version(name)
except Exception:
installed = None
out.append({"name": name, "installed": installed, "required": required})
return out.copy()
def check_comfy_packages_versions():
"""Warn for every comfy* package whose installed version is below requirements.txt."""
from packaging.version import InvalidVersion, parse as parse_pep440
for pkg in get_comfy_package_versions():
installed_str = pkg["installed"]
required_str = pkg["required"]
if not installed_str or not required_str:
continue
try:
outdated = parse_pep440(installed_str) < parse_pep440(required_str)
except InvalidVersion as e:
logging.error(f"Failed to check {pkg['name']} version: {e}")
continue
if outdated:
app.logger.log_startup_warning(
f"""
________________________________________________________________________
WARNING WARNING WARNING WARNING WARNING
Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}.
{get_missing_requirements_message()}
________________________________________________________________________
""".strip()
)
else:
logging.info("{} version: {}".format(pkg["name"], installed_str))
REQUEST_TIMEOUT = 10 # seconds
class Asset(TypedDict):
url: str
class Release(TypedDict):
id: int
tag_name: str
name: str
prerelease: bool
created_at: str
published_at: str
body: str
assets: NotRequired[list[Asset]]
@dataclass
class FrontEndProvider:
owner: str
repo: str
@property
def folder_name(self) -> str:
return f"{self.owner}_{self.repo}"
@property
def release_url(self) -> str:
return f"https://api.github.com/repos/{self.owner}/{self.repo}/releases"
@cached_property
def all_releases(self) -> list[Release]:
releases = []
api_url = self.release_url
while api_url:
response = requests.get(api_url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() # Raises an HTTPError if the response was an error
releases.extend(response.json())
# GitHub uses the Link header to provide pagination links. Check if it exists and update api_url accordingly.
if "next" in response.links:
api_url = response.links["next"]["url"]
else:
api_url = None
return releases
@cached_property
def latest_release(self) -> Release:
latest_release_url = f"{self.release_url}/latest"
response = requests.get(latest_release_url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() # Raises an HTTPError if the response was an error
return response.json()
@cached_property
def latest_prerelease(self) -> Release:
"""Get the latest pre-release version - even if it's older than the latest release"""
release = [release for release in self.all_releases if release["prerelease"]]
if not release:
raise ValueError("No pre-releases found")
# GitHub returns releases in reverse chronological order, so first is latest
return release[0]
def get_release(self, version: str) -> Release:
if version == "latest":
return self.latest_release
elif version == "prerelease":
return self.latest_prerelease
else:
for release in self.all_releases:
if release["tag_name"] in [version, f"v{version}"]:
return release
raise ValueError(f"Version {version} not found in releases")
def download_release_asset_zip(release: Release, destination_path: str) -> None:
"""Download dist.zip from github release."""
asset_url = None
for asset in release.get("assets", []):
if asset["name"] == "dist.zip":
asset_url = asset["url"]
break
if not asset_url:
raise ValueError("dist.zip not found in the release assets")
# Use a temporary file to download the zip content
with tempfile.TemporaryFile() as tmp_file:
headers = {"Accept": "application/octet-stream"}
response = requests.get(
asset_url, headers=headers, allow_redirects=True, timeout=REQUEST_TIMEOUT
)
response.raise_for_status() # Ensure we got a successful response
# Write the content to the temporary file
tmp_file.write(response.content)
# Go back to the beginning of the temporary file
tmp_file.seek(0)
# Extract the zip file content to the destination path
with zipfile.ZipFile(tmp_file, "r") as zip_ref:
zip_ref.extractall(destination_path)
class FrontendManager:
CUSTOM_FRONTENDS_ROOT = str(Path(__file__).parents[1] / "web_custom_versions")
@classmethod
def get_required_frontend_version(cls) -> str:
"""Get the required frontend package version."""
return get_required_frontend_version()
@classmethod
def get_installed_templates_version(cls) -> str:
"""Get the currently installed workflow templates package version."""
try:
templates_version_str = version("comfyui-workflow-templates")
return templates_version_str
except Exception:
return None
@classmethod
def get_required_templates_version(cls) -> str:
return get_required_packages_versions().get("comfyui-workflow-templates", None)
@classmethod
def get_comfy_package_versions(cls):
"""List installed/required versions for every comfy* package in requirements.txt."""
return get_comfy_package_versions()
@classmethod
def default_frontend_path(cls) -> str:
try:
import comfyui_frontend_package
return str(importlib.resources.files(comfyui_frontend_package) / "static")
except ImportError:
logging.error(
f"""
********** ERROR ***********
comfyui-frontend-package is not installed.
{frontend_install_warning_message()}
********** ERROR ***********
""".strip()
)
sys.exit(-1)
@classmethod
def template_asset_map(cls) -> Optional[Dict[str, str]]:
"""Return a mapping of template asset names to their absolute paths."""
try:
from comfyui_workflow_templates import (
get_asset_path,
iter_templates,
)
except ImportError:
logging.error(
f"""
********** ERROR ***********
comfyui-workflow-templates is not installed.
{frontend_install_warning_message()}
********** ERROR ***********
""".strip()
)
return None
try:
template_entries = list(iter_templates())
except Exception as exc:
logging.error(f"Failed to enumerate workflow templates: {exc}")
return None
asset_map: Dict[str, str] = {}
try:
for entry in template_entries:
for asset in entry.assets:
asset_map[asset.filename] = get_asset_path(
entry.template_id, asset.filename
)
except Exception as exc:
logging.error(f"Failed to resolve template asset paths: {exc}")
return None
if not asset_map:
logging.error("No workflow template assets found. Did the packages install correctly?")
return None
return asset_map
@classmethod
def legacy_templates_path(cls) -> Optional[str]:
"""Return the legacy templates directory shipped inside the meta package."""
try:
import comfyui_workflow_templates
return str(
importlib.resources.files(comfyui_workflow_templates) / "templates"
)
except ImportError:
logging.error(
f"""
********** ERROR ***********
comfyui-workflow-templates is not installed.
{frontend_install_warning_message()}
********** ERROR ***********
""".strip()
)
return None
@classmethod
def embedded_docs_path(cls) -> str:
"""Get the path to embedded documentation"""
try:
import comfyui_embedded_docs
return str(
importlib.resources.files(comfyui_embedded_docs) / "docs"
)
except ImportError:
logging.info("comfyui-embedded-docs package not found")
return None
@classmethod
def parse_version_string(cls, value: str) -> tuple[str, str, str]:
"""
Args:
value (str): The version string to parse.
Returns:
tuple[str, str]: A tuple containing provider name and version.
Raises:
argparse.ArgumentTypeError: If the version string is invalid.
"""
VERSION_PATTERN = r"^([a-zA-Z0-9][a-zA-Z0-9-]{0,38})/([a-zA-Z0-9_.-]+)@(v?\d+\.\d+\.\d+[-._a-zA-Z0-9]*|latest|prerelease)$"
match_result = re.match(VERSION_PATTERN, value)
if match_result is None:
raise argparse.ArgumentTypeError(f"Invalid version string: {value}")
return match_result.group(1), match_result.group(2), match_result.group(3)
@classmethod
def init_frontend_unsafe(
cls, version_string: str, provider: Optional[FrontEndProvider] = None
) -> str:
"""
Initializes the frontend for the specified version.
Args:
version_string (str): The version string.
provider (FrontEndProvider, optional): The provider to use. Defaults to None.
Returns:
str: The path to the initialized frontend.
Raises:
Exception: If there is an error during the initialization process.
main error source might be request timeout or invalid URL.
"""
if version_string == DEFAULT_VERSION_STRING:
check_comfy_packages_versions()
return cls.default_frontend_path()
repo_owner, repo_name, version = cls.parse_version_string(version_string)
if version.startswith("v"):
expected_path = str(
Path(cls.CUSTOM_FRONTENDS_ROOT)
/ f"{repo_owner}_{repo_name}"
/ version.lstrip("v")
)
if os.path.exists(expected_path):
logging.info(
f"Using existing copy of specific frontend version tag: {repo_owner}/{repo_name}@{version}"
)
return expected_path
logging.info(
f"Initializing frontend: {repo_owner}/{repo_name}@{version}, requesting version details from GitHub..."
)
provider = provider or FrontEndProvider(repo_owner, repo_name)
release = provider.get_release(version)
semantic_version = release["tag_name"].lstrip("v")
web_root = str(
Path(cls.CUSTOM_FRONTENDS_ROOT) / provider.folder_name / semantic_version
)
if not os.path.exists(web_root):
try:
os.makedirs(web_root, exist_ok=True)
logging.info(
"Downloading frontend(%s) version(%s) to (%s)",
provider.folder_name,
semantic_version,
web_root,
)
logging.debug(release)
download_release_asset_zip(release, destination_path=web_root)
finally:
# Clean up the directory if it is empty, i.e. the download failed
if not os.listdir(web_root):
os.rmdir(web_root)
return web_root
@classmethod
def init_frontend(cls, version_string: str) -> str:
"""
Initializes the frontend with the specified version string.
Args:
version_string (str): The version string to initialize the frontend with.
Returns:
str: The path of the initialized frontend.
"""
try:
return cls.init_frontend_unsafe(version_string)
except Exception as e:
logging.error("Failed to initialize frontend: %s", e)
logging.info("Falling back to the default frontend.")
check_comfy_packages_versions()
return cls.default_frontend_path()
@classmethod
def template_asset_handler(cls):
assets = cls.template_asset_map()
if not assets:
return None
async def serve_template(request: web.Request) -> web.StreamResponse:
rel_path = request.match_info.get("path", "")
target = assets.get(rel_path)
if target is None:
raise web.HTTPNotFound()
return web.FileResponse(target)
return serve_template
@@ -1,98 +0,0 @@
import asyncio
import contextlib
import os
import re
import time
from collections.abc import Callable
from io import BytesIO
from yarl import URL
from comfy.cli_args import args
from comfy.model_management import processing_interrupted
from comfy_api.latest import IO
from .common_exceptions import ProcessingInterrupted
_HAS_PCT_ESC = re.compile(r"%[0-9A-Fa-f]{2}") # any % followed by 2 hex digits
_HAS_BAD_PCT = re.compile(r"%(?![0-9A-Fa-f]{2})") # any % not followed by 2 hex digits
def is_processing_interrupted() -> bool:
"""Return True if user/runtime requested interruption."""
return processing_interrupted()
def get_node_id(node_cls: type[IO.ComfyNode]) -> str:
return node_cls.hidden.unique_id
def get_auth_header(node_cls: type[IO.ComfyNode]) -> dict[str, str]:
if node_cls.hidden.auth_token_comfy_org:
return {"Authorization": f"Bearer {node_cls.hidden.auth_token_comfy_org}"}
if node_cls.hidden.api_key_comfy_org:
return {"X-API-KEY": node_cls.hidden.api_key_comfy_org}
# BoosAPI fallback: when ComfyUI runs as an embedded subprocess of
# BoosAPI, the parent injects the built-in `comfyui-agent` downstream
# key via this env var so usage flows through the standard billing
# pipeline. See src/services/comfyui_process.rs.
builtin_key = os.environ.get("BOOSAPI_BUILTIN_KEY")
if builtin_key:
return {"Authorization": f"Bearer {builtin_key}"}
return {}
def default_base_url() -> str:
return getattr(args, "comfy_api_base", "https://api.comfy.org")
async def sleep_with_interrupt(
seconds: float,
node_cls: type[IO.ComfyNode] | None,
label: str | None = None,
start_ts: float | None = None,
estimated_total: int | None = None,
*,
display_callback: Callable[[type[IO.ComfyNode], str, int, int | None], None] | None = None,
):
"""
Sleep in 1s slices while:
- Checking for interruption (raises ProcessingInterrupted).
- Optionally emitting time progress via display_callback (if provided).
"""
end = time.monotonic() + seconds
while True:
if is_processing_interrupted():
raise ProcessingInterrupted("Task cancelled")
now = time.monotonic()
if start_ts is not None and label and display_callback:
with contextlib.suppress(Exception):
display_callback(node_cls, label, int(now - start_ts), estimated_total)
if now >= end:
break
await asyncio.sleep(min(1.0, end - now))
def mimetype_to_extension(mime_type: str) -> str:
"""Converts a MIME type to a file extension."""
return mime_type.split("/")[-1].lower()
def get_fs_object_size(path_or_object: str | BytesIO) -> int:
if isinstance(path_or_object, str):
return os.path.getsize(path_or_object)
return len(path_or_object.getvalue())
def to_aiohttp_url(url: str) -> URL:
"""If `url` appears to be already percent-encoded (contains at least one valid %HH
escape and no malformed '%' sequences) and contains no raw whitespace/control
characters preserve the original encoding byte-for-byte (important for signed/presigned URLs).
Otherwise, return `URL(url)` and allow yarl to normalize/quote as needed."""
if any(c.isspace() for c in url) or any(ord(c) < 0x20 for c in url):
# Avoid encoded=True if URL contains raw whitespace/control chars
return URL(url)
if _HAS_PCT_ESC.search(url) and not _HAS_BAD_PCT.search(url):
# Preserve encoding only if it appears pre-encoded AND has no invalid % sequences
return URL(url, encoded=True)
return URL(url)
@@ -1,841 +0,0 @@
from __future__ import annotations
import av
import torchaudio
import torch
import comfy.model_management
import folder_paths
import os
import hashlib
import node_helpers
import logging
from typing_extensions import override
from comfy_api.latest import ComfyExtension, IO, UI
class EmptyLatentAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="EmptyLatentAudio",
display_name="Empty Latent Audio",
category="latent/audio",
essentials_category="Audio",
inputs=[
IO.Float.Input("seconds", default=47.6, min=1.0, max=1000.0, step=0.1),
IO.Int.Input(
"batch_size", default=1, min=1, max=4096, tooltip="The number of latent images in the batch.",
),
],
outputs=[IO.Latent.Output()],
)
@classmethod
def execute(cls, seconds, batch_size) -> IO.NodeOutput:
length = round((seconds * 44100 / 2048) / 2) * 2
latent = torch.zeros([batch_size, 64, length], device=comfy.model_management.intermediate_device())
return IO.NodeOutput({"samples":latent, "type": "audio"})
generate = execute # TODO: remove
class ConditioningStableAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="ConditioningStableAudio",
category="conditioning",
inputs=[
IO.Conditioning.Input("positive"),
IO.Conditioning.Input("negative"),
IO.Float.Input("seconds_start", default=0.0, min=0.0, max=1000.0, step=0.1),
IO.Float.Input("seconds_total", default=47.0, min=0.0, max=1000.0, step=0.1),
],
outputs=[
IO.Conditioning.Output(display_name="positive"),
IO.Conditioning.Output(display_name="negative"),
],
)
@classmethod
def execute(cls, positive, negative, seconds_start, seconds_total) -> IO.NodeOutput:
positive = node_helpers.conditioning_set_values(positive, {"seconds_start": seconds_start, "seconds_total": seconds_total})
negative = node_helpers.conditioning_set_values(negative, {"seconds_start": seconds_start, "seconds_total": seconds_total})
return IO.NodeOutput(positive, negative)
append = execute # TODO: remove
class VAEEncodeAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="VAEEncodeAudio",
search_aliases=["audio to latent"],
display_name="VAE Encode Audio",
category="latent/audio",
inputs=[
IO.Audio.Input("audio"),
IO.Vae.Input("vae"),
],
outputs=[IO.Latent.Output()],
)
@classmethod
def execute(cls, vae, audio) -> IO.NodeOutput:
if audio is None:
raise ValueError("VAEEncodeAudio: input audio is None (source video may have no audio track).")
sample_rate = audio["sample_rate"]
vae_sample_rate = getattr(vae, "audio_sample_rate", 44100)
if vae_sample_rate != sample_rate:
waveform = torchaudio.functional.resample(audio["waveform"], sample_rate, vae_sample_rate)
else:
waveform = audio["waveform"]
t = vae.encode(waveform.movedim(1, -1))
return IO.NodeOutput({"samples": t})
encode = execute # TODO: remove
def vae_decode_audio(vae, samples, tile=None, overlap=None):
if tile is not None:
audio = vae.decode_tiled(samples["samples"], tile_x=tile, tile_y=tile, overlap=overlap).movedim(-1, 1)
else:
audio = vae.decode(samples["samples"]).movedim(-1, 1)
std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
std[std < 1.0] = 1.0
audio /= std
vae_sample_rate = getattr(vae, "audio_sample_rate_output", getattr(vae, "audio_sample_rate", 44100))
return {"waveform": audio, "sample_rate": vae_sample_rate if "sample_rate" not in samples else samples["sample_rate"]}
class VAEDecodeAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="VAEDecodeAudio",
search_aliases=["latent to audio"],
display_name="VAE Decode Audio",
category="latent/audio",
inputs=[
IO.Latent.Input("samples"),
IO.Vae.Input("vae"),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, vae, samples) -> IO.NodeOutput:
return IO.NodeOutput(vae_decode_audio(vae, samples))
decode = execute # TODO: remove
class VAEDecodeAudioTiled(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="VAEDecodeAudioTiled",
search_aliases=["latent to audio"],
display_name="VAE Decode Audio (Tiled)",
category="latent/audio",
inputs=[
IO.Latent.Input("samples"),
IO.Vae.Input("vae"),
IO.Int.Input("tile_size", default=512, min=32, max=8192, step=8),
IO.Int.Input("overlap", default=64, min=0, max=1024, step=8),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, vae, samples, tile_size, overlap) -> IO.NodeOutput:
return IO.NodeOutput(vae_decode_audio(vae, samples, tile_size, overlap))
class SaveAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveAudio",
search_aliases=["export flac"],
display_name="Save Audio (FLAC)",
category="audio",
essentials_category="Audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="flac") -> IO.NodeOutput:
if audio is None:
raise ValueError("SaveAudio: input audio is None (source video may have no audio track).")
return IO.NodeOutput(
ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=format)
)
save_flac = execute # TODO: remove
class SaveAudioMP3(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveAudioMP3",
search_aliases=["export mp3"],
display_name="Save Audio (MP3)",
category="audio",
essentials_category="Audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),
IO.Combo.Input("quality", options=["V0", "128k", "320k"], default="V0"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="mp3", quality="128k") -> IO.NodeOutput:
if audio is None:
raise ValueError("SaveAudioMP3: input audio is None (source video may have no audio track).")
return IO.NodeOutput(
ui=UI.AudioSaveHelper.get_save_audio_ui(
audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality
)
)
save_mp3 = execute # TODO: remove
class SaveAudioOpus(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SaveAudioOpus",
search_aliases=["export opus"],
display_name="Save Audio (Opus)",
category="audio",
inputs=[
IO.Audio.Input("audio"),
IO.String.Input("filename_prefix", default="audio/ComfyUI"),
IO.Combo.Input("quality", options=["64k", "96k", "128k", "192k", "320k"], default="128k"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio, filename_prefix="ComfyUI", format="opus", quality="V3") -> IO.NodeOutput:
if audio is None:
raise ValueError("SaveAudioOpus: input audio is None (source video may have no audio track).")
return IO.NodeOutput(
ui=UI.AudioSaveHelper.get_save_audio_ui(
audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality
)
)
save_opus = execute # TODO: remove
class PreviewAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="PreviewAudio",
search_aliases=["play audio"],
display_name="Preview Audio",
category="audio",
inputs=[
IO.Audio.Input("audio"),
],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
if audio is None:
raise ValueError("PreviewAudio: input audio is None (source video may have no audio track).")
return IO.NodeOutput(ui=UI.PreviewAudio(audio, cls=cls))
save_flac = execute # TODO: remove
def f32_pcm(wav: torch.Tensor) -> torch.Tensor:
"""Convert audio to float 32 bits PCM format."""
if wav.dtype.is_floating_point:
return wav
elif wav.dtype == torch.int16:
return wav.float() / (2 ** 15)
elif wav.dtype == torch.int32:
return wav.float() / (2 ** 31)
raise ValueError(f"Unsupported wav dtype: {wav.dtype}")
def load(filepath: str) -> tuple[torch.Tensor, int]:
with av.open(filepath) as af:
if not af.streams.audio:
raise ValueError("No audio stream found in the file.")
stream = af.streams.audio[0]
sr = stream.codec_context.sample_rate
n_channels = stream.channels
frames = []
length = 0
for frame in af.decode(streams=stream.index):
buf = torch.from_numpy(frame.to_ndarray())
if buf.shape[0] != n_channels:
buf = buf.view(-1, n_channels).t()
frames.append(buf)
length += buf.shape[1]
if not frames:
raise ValueError("No audio frames decoded.")
wav = torch.cat(frames, dim=1)
wav = f32_pcm(wav)
return wav, sr
class LoadAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
input_dir = folder_paths.get_input_directory()
os.makedirs(input_dir, exist_ok=True)
files = folder_paths.filter_files_content_types(os.listdir(input_dir), ["audio", "video"])
return IO.Schema(
node_id="LoadAudio",
search_aliases=["import audio", "open audio", "audio file"],
display_name="Load Audio",
category="audio",
essentials_category="Audio",
inputs=[
IO.Combo.Input("audio", upload=IO.UploadType.audio, options=sorted(files)),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
audio_path = folder_paths.get_annotated_filepath(audio)
waveform, sample_rate = load(audio_path)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return IO.NodeOutput(audio)
@classmethod
def fingerprint_inputs(cls, audio):
image_path = folder_paths.get_annotated_filepath(audio)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def validate_inputs(cls, audio):
if not folder_paths.exists_annotated_filepath(audio):
return "Invalid audio file: {}".format(audio)
return True
load = execute # TODO: remove
class RecordAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="RecordAudio",
search_aliases=["microphone input", "audio capture", "voice input"],
display_name="Record Audio",
category="audio",
inputs=[
IO.Custom("AUDIO_RECORD").Input("audio"),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
audio_path = folder_paths.get_annotated_filepath(audio)
waveform, sample_rate = load(audio_path)
audio = {"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate}
return IO.NodeOutput(audio)
load = execute # TODO: remove
class TrimAudioDuration(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="TrimAudioDuration",
search_aliases=["cut audio", "audio clip", "shorten audio"],
display_name="Trim Audio Duration",
description="Trim audio tensor into chosen time range.",
category="audio",
inputs=[
IO.Audio.Input("audio"),
IO.Float.Input(
"start_index",
default=0.0,
min=-0xffffffffffffffff,
max=0xffffffffffffffff,
step=0.01,
tooltip="Start time in seconds, can be negative to count from the end (supports sub-seconds).",
),
IO.Float.Input(
"duration",
default=60.0,
min=0.0,
step=0.01,
tooltip="Duration in seconds",
),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio, start_index, duration) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
audio_length = waveform.shape[-1]
if audio_length == 0:
return IO.NodeOutput(audio)
if start_index < 0:
start_frame = audio_length + int(round(start_index * sample_rate))
else:
start_frame = int(round(start_index * sample_rate))
start_frame = max(0, min(start_frame, audio_length))
end_frame = start_frame + int(round(duration * sample_rate))
end_frame = max(0, min(end_frame, audio_length))
if start_frame >= end_frame:
raise ValueError("TrimAudioDuration: Start time must be less than end time and be within the audio length.")
return IO.NodeOutput({"waveform": waveform[..., start_frame:end_frame], "sample_rate": sample_rate})
trim = execute # TODO: remove
class SplitAudioChannels(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="SplitAudioChannels",
search_aliases=["stereo to mono"],
display_name="Split Audio Channels",
description="Separates the audio into left and right channels.",
category="audio",
inputs=[
IO.Audio.Input("audio"),
],
outputs=[
IO.Audio.Output(display_name="left"),
IO.Audio.Output(display_name="right"),
],
)
@classmethod
def execute(cls, audio) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None, None)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
if waveform.shape[1] != 2:
raise ValueError(f"AudioSplit: Input audio must be stereo (2 channels), got {waveform.shape[1]} channel(s).")
left_channel = waveform[..., 0:1, :]
right_channel = waveform[..., 1:2, :]
return IO.NodeOutput({"waveform": left_channel, "sample_rate": sample_rate}, {"waveform": right_channel, "sample_rate": sample_rate})
separate = execute # TODO: remove
class JoinAudioChannels(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="JoinAudioChannels",
display_name="Join Audio Channels",
description="Joins left and right mono audio channels into a stereo audio.",
category="audio",
inputs=[
IO.Audio.Input("audio_left"),
IO.Audio.Input("audio_right"),
],
outputs=[
IO.Audio.Output(display_name="audio"),
],
)
@classmethod
def execute(cls, audio_left, audio_right) -> IO.NodeOutput:
if audio_left is None and audio_right is None:
return IO.NodeOutput(None)
if audio_left is None:
return IO.NodeOutput(audio_right)
if audio_right is None:
return IO.NodeOutput(audio_left)
waveform_left = audio_left["waveform"]
sample_rate_left = audio_left["sample_rate"]
waveform_right = audio_right["waveform"]
sample_rate_right = audio_right["sample_rate"]
if waveform_left.shape[1] != 1 or waveform_right.shape[1] != 1:
raise ValueError("AudioJoin: Both input audios must be mono.")
# Handle different sample rates by resampling to the higher rate
waveform_left, waveform_right, output_sample_rate = match_audio_sample_rates(
waveform_left, sample_rate_left, waveform_right, sample_rate_right
)
# Handle different lengths by trimming to the shorter length
length_left = waveform_left.shape[-1]
length_right = waveform_right.shape[-1]
if length_left != length_right:
min_length = min(length_left, length_right)
if length_left > min_length:
logging.info(f"JoinAudioChannels: Trimming left channel from {length_left} to {min_length} samples.")
waveform_left = waveform_left[..., :min_length]
if length_right > min_length:
logging.info(f"JoinAudioChannels: Trimming right channel from {length_right} to {min_length} samples.")
waveform_right = waveform_right[..., :min_length]
# Join the channels into stereo
left_channel = waveform_left[..., 0:1, :]
right_channel = waveform_right[..., 0:1, :]
stereo_waveform = torch.cat([left_channel, right_channel], dim=1)
return IO.NodeOutput({"waveform": stereo_waveform, "sample_rate": output_sample_rate})
def match_audio_sample_rates(waveform_1, sample_rate_1, waveform_2, sample_rate_2):
if sample_rate_1 != sample_rate_2:
if sample_rate_1 > sample_rate_2:
waveform_2 = torchaudio.functional.resample(waveform_2, sample_rate_2, sample_rate_1)
output_sample_rate = sample_rate_1
logging.info(f"Resampling audio2 from {sample_rate_2}Hz to {sample_rate_1}Hz for merging.")
else:
waveform_1 = torchaudio.functional.resample(waveform_1, sample_rate_1, sample_rate_2)
output_sample_rate = sample_rate_2
logging.info(f"Resampling audio1 from {sample_rate_1}Hz to {sample_rate_2}Hz for merging.")
else:
output_sample_rate = sample_rate_1
return waveform_1, waveform_2, output_sample_rate
class AudioConcat(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioConcat",
search_aliases=["join audio", "combine audio", "append audio"],
display_name="Audio Concat",
description="Concatenates the audio1 to audio2 in the specified direction.",
category="audio",
inputs=[
IO.Audio.Input("audio1"),
IO.Audio.Input("audio2"),
IO.Combo.Input(
"direction",
options=['after', 'before'],
default="after",
tooltip="Whether to append audio2 after or before audio1.",
)
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio1, audio2, direction) -> IO.NodeOutput:
if audio1 is None and audio2 is None:
return IO.NodeOutput(None)
if audio1 is None:
return IO.NodeOutput(audio2)
if audio2 is None:
return IO.NodeOutput(audio1)
waveform_1 = audio1["waveform"]
waveform_2 = audio2["waveform"]
sample_rate_1 = audio1["sample_rate"]
sample_rate_2 = audio2["sample_rate"]
if waveform_1.shape[1] == 1:
waveform_1 = waveform_1.repeat(1, 2, 1)
logging.info("AudioConcat: Converted mono audio1 to stereo by duplicating the channel.")
if waveform_2.shape[1] == 1:
waveform_2 = waveform_2.repeat(1, 2, 1)
logging.info("AudioConcat: Converted mono audio2 to stereo by duplicating the channel.")
waveform_1, waveform_2, output_sample_rate = match_audio_sample_rates(waveform_1, sample_rate_1, waveform_2, sample_rate_2)
if direction == 'after':
concatenated_audio = torch.cat((waveform_1, waveform_2), dim=2)
elif direction == 'before':
concatenated_audio = torch.cat((waveform_2, waveform_1), dim=2)
return IO.NodeOutput({"waveform": concatenated_audio, "sample_rate": output_sample_rate})
concat = execute # TODO: remove
class AudioMerge(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioMerge",
search_aliases=["mix audio", "overlay audio", "layer audio"],
display_name="Audio Merge",
description="Combine two audio tracks by overlaying their waveforms.",
category="audio",
inputs=[
IO.Audio.Input("audio1"),
IO.Audio.Input("audio2"),
IO.Combo.Input(
"merge_method",
options=["add", "mean", "subtract", "multiply"],
tooltip="The method used to combine the audio waveforms.",
)
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio1, audio2, merge_method) -> IO.NodeOutput:
if audio1 is None and audio2 is None:
return IO.NodeOutput(None)
if audio1 is None:
return IO.NodeOutput(audio2)
if audio2 is None:
return IO.NodeOutput(audio1)
waveform_1 = audio1["waveform"]
waveform_2 = audio2["waveform"]
sample_rate_1 = audio1["sample_rate"]
sample_rate_2 = audio2["sample_rate"]
waveform_1, waveform_2, output_sample_rate = match_audio_sample_rates(waveform_1, sample_rate_1, waveform_2, sample_rate_2)
length_1 = waveform_1.shape[-1]
length_2 = waveform_2.shape[-1]
if length_1 == 0 or length_2 == 0:
return IO.NodeOutput({"waveform": waveform_1, "sample_rate": output_sample_rate})
if length_2 > length_1:
logging.info(f"AudioMerge: Trimming audio2 from {length_2} to {length_1} samples to match audio1 length.")
waveform_2 = waveform_2[..., :length_1]
elif length_2 < length_1:
logging.info(f"AudioMerge: Padding audio2 from {length_2} to {length_1} samples to match audio1 length.")
pad_shape = list(waveform_2.shape)
pad_shape[-1] = length_1 - length_2
pad_tensor = torch.zeros(pad_shape, dtype=waveform_2.dtype, device=waveform_2.device)
waveform_2 = torch.cat((waveform_2, pad_tensor), dim=-1)
if merge_method == "add":
waveform = waveform_1 + waveform_2
elif merge_method == "subtract":
waveform = waveform_1 - waveform_2
elif merge_method == "multiply":
waveform = waveform_1 * waveform_2
elif merge_method == "mean":
waveform = (waveform_1 + waveform_2) / 2
max_val = waveform.abs().max()
if max_val > 1.0:
waveform = waveform / max_val
return IO.NodeOutput({"waveform": waveform, "sample_rate": output_sample_rate})
merge = execute # TODO: remove
class AudioAdjustVolume(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioAdjustVolume",
search_aliases=["audio gain", "loudness", "audio level"],
display_name="Audio Adjust Volume",
category="audio",
inputs=[
IO.Audio.Input("audio"),
IO.Int.Input(
"volume",
default=1,
min=-100,
max=100,
tooltip="Volume adjustment in decibels (dB). 0 = no change, +6 = double, -6 = half, etc",
)
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio, volume) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None)
if volume == 0:
return IO.NodeOutput(audio)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
gain = 10 ** (volume / 20)
waveform = waveform * gain
return IO.NodeOutput({"waveform": waveform, "sample_rate": sample_rate})
adjust_volume = execute # TODO: remove
class EmptyAudio(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="EmptyAudio",
search_aliases=["blank audio"],
display_name="Empty Audio",
category="audio",
inputs=[
IO.Float.Input(
"duration",
default=60.0,
min=0.0,
max=0xffffffffffffffff,
step=0.01,
tooltip="Duration of the empty audio clip in seconds",
),
IO.Int.Input(
"sample_rate",
default=44100,
tooltip="Sample rate of the empty audio clip.",
min=1,
max=192000,
advanced=True,
),
IO.Int.Input(
"channels",
default=2,
min=1,
max=2,
tooltip="Number of audio channels (1 for mono, 2 for stereo).",
advanced=True,
),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, duration, sample_rate, channels) -> IO.NodeOutput:
num_samples = int(round(duration * sample_rate))
waveform = torch.zeros((1, channels, num_samples), dtype=torch.float32)
return IO.NodeOutput({"waveform": waveform, "sample_rate": sample_rate})
create_empty_audio = execute # TODO: remove
class AudioEqualizer3Band(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="AudioEqualizer3Band",
search_aliases=["eq", "bass boost", "treble boost", "equalizer"],
display_name="Audio Equalizer (3-Band)",
category="audio",
is_experimental=True,
inputs=[
IO.Audio.Input("audio"),
IO.Float.Input("low_gain_dB", default=0.0, min=-24.0, max=24.0, step=0.1, tooltip="Gain for Low frequencies (Bass)"),
IO.Int.Input("low_freq", default=100, min=20, max=500, tooltip="Cutoff frequency for Low shelf"),
IO.Float.Input("mid_gain_dB", default=0.0, min=-24.0, max=24.0, step=0.1, tooltip="Gain for Mid frequencies"),
IO.Int.Input("mid_freq", default=1000, min=200, max=4000, tooltip="Center frequency for Mids"),
IO.Float.Input("mid_q", default=0.707, min=0.1, max=10.0, step=0.1, tooltip="Q factor (bandwidth) for Mids"),
IO.Float.Input("high_gain_dB", default=0.0, min=-24.0, max=24.0, step=0.1, tooltip="Gain for High frequencies (Treble)"),
IO.Int.Input("high_freq", default=5000, min=1000, max=15000, tooltip="Cutoff frequency for High shelf"),
],
outputs=[IO.Audio.Output()],
)
@classmethod
def execute(cls, audio, low_gain_dB, low_freq, mid_gain_dB, mid_freq, mid_q, high_gain_dB, high_freq) -> IO.NodeOutput:
if audio is None:
return IO.NodeOutput(None)
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
if waveform.shape[-1] == 0:
return IO.NodeOutput(audio)
eq_waveform = waveform.clone()
# 1. Apply Low Shelf (Bass)
if low_gain_dB != 0:
eq_waveform = torchaudio.functional.bass_biquad(
eq_waveform,
sample_rate,
gain=low_gain_dB,
central_freq=float(low_freq),
Q=0.707
)
# 2. Apply Peaking EQ (Mids)
if mid_gain_dB != 0:
eq_waveform = torchaudio.functional.equalizer_biquad(
eq_waveform,
sample_rate,
center_freq=float(mid_freq),
gain=mid_gain_dB,
Q=mid_q
)
# 3. Apply High Shelf (Treble)
if high_gain_dB != 0:
eq_waveform = torchaudio.functional.treble_biquad(
eq_waveform,
sample_rate,
gain=high_gain_dB,
central_freq=float(high_freq),
Q=0.707
)
return IO.NodeOutput({"waveform": eq_waveform, "sample_rate": sample_rate})
class AudioExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
EmptyLatentAudio,
VAEEncodeAudio,
VAEDecodeAudio,
VAEDecodeAudioTiled,
SaveAudio,
SaveAudioMP3,
SaveAudioOpus,
LoadAudio,
PreviewAudio,
ConditioningStableAudio,
RecordAudio,
TrimAudioDuration,
SplitAudioChannels,
JoinAudioChannels,
AudioConcat,
AudioMerge,
AudioAdjustVolume,
EmptyAudio,
AudioEqualizer3Band,
]
async def comfy_entrypoint() -> AudioExtension:
return AudioExtension()
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
comfyui-frontend-package==1.43.18
comfyui-workflow-templates==0.9.77
comfyui-embedded-docs==0.5.0
torch
torchsde
torchvision
torchaudio
numpy>=1.25.0
einops
transformers>=4.50.3
tokenizers>=0.13.3
sentencepiece
safetensors>=0.4.2
aiohttp>=3.11.8
yarl>=1.18.0
pyyaml
Pillow
scipy
tqdm
psutil
alembic
SQLAlchemy>=2.0.0
filelock
av>=14.2.0
comfy-kitchen>=0.2.8
comfy-aimdo==0.3.0
requests
simpleeval>=1.0.0
blake3
#non essential dependencies:
kornia>=0.7.1
spandrel
pydantic~=2.0
pydantic-settings~=2.0
PyOpenGL
glfw
File diff suppressed because it is too large Load Diff
@@ -1,289 +0,0 @@
import argparse
import pytest
from requests.exceptions import HTTPError
from unittest.mock import patch, mock_open
from app.frontend_management import (
FrontendManager,
FrontEndProvider,
Release,
)
from comfy.cli_args import DEFAULT_VERSION_STRING
@pytest.fixture
def mock_releases():
return [
Release(
id=1,
tag_name="1.0.0",
name="Release 1.0.0",
prerelease=False,
created_at="2022-01-01T00:00:00Z",
published_at="2022-01-01T00:00:00Z",
body="Release notes for 1.0.0",
assets=[{"name": "dist.zip", "url": "https://example.com/dist.zip"}],
),
Release(
id=2,
tag_name="2.0.0",
name="Release 2.0.0",
prerelease=False,
created_at="2022-02-01T00:00:00Z",
published_at="2022-02-01T00:00:00Z",
body="Release notes for 2.0.0",
assets=[{"name": "dist.zip", "url": "https://example.com/dist.zip"}],
),
]
@pytest.fixture
def mock_provider(mock_releases):
provider = FrontEndProvider(
owner="test-owner",
repo="test-repo",
)
provider.all_releases = mock_releases
provider.latest_release = mock_releases[1]
FrontendManager.PROVIDERS = [provider]
return provider
@pytest.fixture(autouse=True)
def clear_cache():
import utils.install_util
import app.frontend_management
utils.install_util.PACKAGE_VERSIONS = {}
app.frontend_management.COMFY_PACKAGE_VERSIONS = []
def test_get_release(mock_provider, mock_releases):
version = "1.0.0"
release = mock_provider.get_release(version)
assert release == mock_releases[0]
def test_get_release_latest(mock_provider, mock_releases):
version = "latest"
release = mock_provider.get_release(version)
assert release == mock_releases[1]
def test_get_release_invalid_version(mock_provider):
version = "invalid"
with pytest.raises(ValueError):
mock_provider.get_release(version)
def test_init_frontend_default():
version_string = DEFAULT_VERSION_STRING
frontend_path = FrontendManager.init_frontend(version_string)
assert frontend_path == FrontendManager.default_frontend_path()
def test_init_frontend_invalid_version():
version_string = "test-owner/test-repo@1.100.99"
with pytest.raises(HTTPError):
FrontendManager.init_frontend_unsafe(version_string)
def test_init_frontend_invalid_provider():
version_string = "invalid/invalid@latest"
with pytest.raises(HTTPError):
FrontendManager.init_frontend_unsafe(version_string)
@pytest.fixture
def mock_os_functions():
with (
patch("app.frontend_management.os.makedirs") as mock_makedirs,
patch("app.frontend_management.os.listdir") as mock_listdir,
patch("app.frontend_management.os.rmdir") as mock_rmdir,
):
mock_listdir.return_value = [] # Simulate empty directory
yield mock_makedirs, mock_listdir, mock_rmdir
@pytest.fixture
def mock_download():
with patch("app.frontend_management.download_release_asset_zip") as mock:
mock.side_effect = Exception("Download failed") # Simulate download failure
yield mock
def test_finally_block(mock_os_functions, mock_download, mock_provider):
# Arrange
mock_makedirs, mock_listdir, mock_rmdir = mock_os_functions
version_string = "test-owner/test-repo@1.0.0"
# Act & Assert
with pytest.raises(Exception):
FrontendManager.init_frontend_unsafe(version_string, mock_provider)
# Assert
mock_makedirs.assert_called_once()
mock_download.assert_called_once()
mock_listdir.assert_called_once()
mock_rmdir.assert_called_once()
def test_parse_version_string():
version_string = "owner/repo@1.0.0"
repo_owner, repo_name, version = FrontendManager.parse_version_string(
version_string
)
assert repo_owner == "owner"
assert repo_name == "repo"
assert version == "1.0.0"
def test_parse_version_string_invalid():
version_string = "invalid"
with pytest.raises(argparse.ArgumentTypeError):
FrontendManager.parse_version_string(version_string)
def test_init_frontend_default_with_mocks():
# Arrange
version_string = DEFAULT_VERSION_STRING
# Act
with (
patch("app.frontend_management.check_comfy_packages_versions") as mock_check,
patch.object(
FrontendManager, "default_frontend_path", return_value="/mocked/path"
),
):
frontend_path = FrontendManager.init_frontend(version_string)
# Assert
assert frontend_path == "/mocked/path"
mock_check.assert_called_once()
def test_init_frontend_fallback_on_error():
# Arrange
version_string = "test-owner/test-repo@1.0.0"
# Act
with (
patch.object(
FrontendManager, "init_frontend_unsafe", side_effect=Exception("Test error")
),
patch("app.frontend_management.check_comfy_packages_versions") as mock_check,
patch.object(
FrontendManager, "default_frontend_path", return_value="/default/path"
),
):
frontend_path = FrontendManager.init_frontend(version_string)
# Assert
assert frontend_path == "/default/path"
mock_check.assert_called_once()
def test_get_frontend_version():
# Arrange
expected_version = "1.25.0"
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_frontend_version()
# Assert
assert version == expected_version
def test_get_frontend_version_invalid_semver():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.29.3.75
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_frontend_version()
# Assert
assert version is None
def test_get_templates_version():
# Arrange
expected_version = "0.1.41"
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
comfyui-workflow-templates==0.1.41
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version == expected_version
def test_get_templates_version_not_found():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version is None
def test_get_templates_version_invalid_semver():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-workflow-templates==1.0.0.beta
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version is None
def test_get_installed_templates_version():
# Arrange
expected_version = "0.1.40"
# Act
with patch("app.frontend_management.version", return_value=expected_version):
version = FrontendManager.get_installed_templates_version()
# Assert
assert version == expected_version
def test_get_installed_templates_version_not_installed():
# Act
with patch(
"app.frontend_management.version", side_effect=Exception("Package not found")
):
version = FrontendManager.get_installed_templates_version()
# Assert
assert version is None
@@ -1,144 +0,0 @@
from collections import defaultdict
import torch
from comfy.model_detection import detect_unet_config, model_config_from_unet_config
import comfy.supported_models
def _freeze(value):
"""Recursively convert a value to a hashable form so configs can be
compared/used as dict keys or set members."""
if isinstance(value, dict):
return frozenset((k, _freeze(v)) for k, v in value.items())
if isinstance(value, (list, tuple)):
return tuple(_freeze(v) for v in value)
if isinstance(value, set):
return frozenset(_freeze(v) for v in value)
return value
def _make_longcat_comfyui_sd():
"""Minimal ComfyUI-format state dict for pre-converted LongCat-Image weights."""
sd = {}
H = 32 # Reduce hidden state dimension to reduce memory usage
C_IN = 16
C_CTX = 3584
sd["img_in.weight"] = torch.empty(H, C_IN * 4)
sd["img_in.bias"] = torch.empty(H)
sd["txt_in.weight"] = torch.empty(H, C_CTX)
sd["txt_in.bias"] = torch.empty(H)
sd["time_in.in_layer.weight"] = torch.empty(H, 256)
sd["time_in.in_layer.bias"] = torch.empty(H)
sd["time_in.out_layer.weight"] = torch.empty(H, H)
sd["time_in.out_layer.bias"] = torch.empty(H)
sd["final_layer.adaLN_modulation.1.weight"] = torch.empty(2 * H, H)
sd["final_layer.adaLN_modulation.1.bias"] = torch.empty(2 * H)
sd["final_layer.linear.weight"] = torch.empty(C_IN * 4, H)
sd["final_layer.linear.bias"] = torch.empty(C_IN * 4)
for i in range(19):
sd[f"double_blocks.{i}.img_attn.norm.key_norm.weight"] = torch.empty(128)
sd[f"double_blocks.{i}.img_attn.qkv.weight"] = torch.empty(3 * H, H)
sd[f"double_blocks.{i}.img_mod.lin.weight"] = torch.empty(H, H)
for i in range(38):
sd[f"single_blocks.{i}.modulation.lin.weight"] = torch.empty(H, H)
return sd
def _make_flux_schnell_comfyui_sd():
"""Minimal ComfyUI-format state dict for standard Flux Schnell."""
sd = {}
H = 32 # Reduce hidden state dimension to reduce memory usage
C_IN = 16
sd["img_in.weight"] = torch.empty(H, C_IN * 4)
sd["img_in.bias"] = torch.empty(H)
sd["txt_in.weight"] = torch.empty(H, 4096)
sd["txt_in.bias"] = torch.empty(H)
sd["double_blocks.0.img_attn.norm.key_norm.weight"] = torch.empty(128)
sd["double_blocks.0.img_attn.qkv.weight"] = torch.empty(3 * H, H)
sd["double_blocks.0.img_mod.lin.weight"] = torch.empty(H, H)
for i in range(19):
sd[f"double_blocks.{i}.img_attn.norm.key_norm.weight"] = torch.empty(128)
for i in range(38):
sd[f"single_blocks.{i}.modulation.lin.weight"] = torch.empty(H, H)
return sd
class TestModelDetection:
"""Verify that first-match model detection selects the correct model
based on list ordering and unet_config specificity."""
def test_longcat_before_schnell_in_models_list(self):
"""LongCatImage must appear before FluxSchnell in the models list."""
models = comfy.supported_models.models
longcat_idx = next(i for i, m in enumerate(models) if m.__name__ == "LongCatImage")
schnell_idx = next(i for i, m in enumerate(models) if m.__name__ == "FluxSchnell")
assert longcat_idx < schnell_idx, (
f"LongCatImage (index {longcat_idx}) must come before "
f"FluxSchnell (index {schnell_idx}) in the models list"
)
def test_longcat_comfyui_detected_as_longcat(self):
sd = _make_longcat_comfyui_sd()
unet_config = detect_unet_config(sd, "")
assert unet_config is not None
assert unet_config["image_model"] == "flux"
assert unet_config["context_in_dim"] == 3584
assert unet_config["vec_in_dim"] is None
assert unet_config["guidance_embed"] is False
assert unet_config["txt_ids_dims"] == [1, 2]
model_config = model_config_from_unet_config(unet_config, sd)
assert model_config is not None
assert type(model_config).__name__ == "LongCatImage"
def test_longcat_comfyui_keys_pass_through_unchanged(self):
"""Pre-converted weights should not be transformed by process_unet_state_dict."""
sd = _make_longcat_comfyui_sd()
unet_config = detect_unet_config(sd, "")
model_config = model_config_from_unet_config(unet_config, sd)
processed = model_config.process_unet_state_dict(dict(sd))
assert "img_in.weight" in processed
assert "txt_in.weight" in processed
assert "time_in.in_layer.weight" in processed
assert "final_layer.linear.weight" in processed
def test_flux_schnell_comfyui_detected_as_flux_schnell(self):
sd = _make_flux_schnell_comfyui_sd()
unet_config = detect_unet_config(sd, "")
assert unet_config is not None
assert unet_config["image_model"] == "flux"
assert unet_config["context_in_dim"] == 4096
assert unet_config["txt_ids_dims"] == []
model_config = model_config_from_unet_config(unet_config, sd)
assert model_config is not None
assert type(model_config).__name__ == "FluxSchnell"
def test_unet_config_and_required_keys_combination_is_unique(self):
"""Each model in the registry must have a unique combination of
``unet_config`` and ``required_keys``. If two models share the same
combination, ``BASE.matches`` cannot disambiguate between them and the
first one in the list will always win."""
models = comfy.supported_models.models
groups = defaultdict(list)
for model in models:
key = (_freeze(model.unet_config), _freeze(model.required_keys))
groups[key].append(model.__name__)
duplicates = {k: names for k, names in groups.items() if len(names) > 1}
assert not duplicates, (
"Found models sharing the same (unet_config, required_keys) "
"combination, which makes detection ambiguous: "
+ "; ".join(", ".join(names) for names in duplicates.values())
)
Submodule comfy_source deleted from 9afc945331
+5
View File
@@ -8,6 +8,11 @@ cors_origins = ["*"]
url = "sqlite:boosapi.db?mode=rwc"
max_connections = 10
ssl = false
# PostgreSQL preparation example:
# url = "postgres://boosapi:password@127.0.0.1:5432/boosapi"
# Note: this build can create/migrate PostgreSQL schemas and copy SQLite data
# with `boosapi migrate-sqlite-to-postgres`; full runtime query compatibility
# is completed in a follow-up SQL dialect pass.
[auth]
jwt_secret = "boosapi-dev-secret-change-in-production"
-37
View File
@@ -1,37 +0,0 @@
FROM python:3.11-slim
WORKDIR /workspace
# Use USTC mirror for faster apt
RUN sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl \
&& rm -rf /var/lib/apt/lists/*
# Copy ComfyUI from local source (avoid slow GitHub clone)
COPY comfy_source /workspace/ComfyUI
WORKDIR /workspace/ComfyUI
# Install git (needed for some ComfyUI custom node managers)
RUN git init
# Python venv + deps (CPU-only, generation goes through BoosAPI)
RUN python3 -m venv venv && \
. venv/bin/activate && \
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip install --upgrade pip && \
pip config set global.extra-index-url https://download.pytorch.org/whl/cpu && \
pip install torch torchvision torchaudio && \
grep -v kornia_rs requirements.txt | pip install -r /dev/stdin && \
pip install six && \
rm -rf ~/.cache/pip
ENV PATH="/workspace/ComfyUI/venv/bin:${PATH}"
EXPOSE 8188
CMD ["python3", "main.py", "--listen", "0.0.0.0", "--port", "8188", "--cpu"]
-124
View File
@@ -1,124 +0,0 @@
# syntax=docker/dockerfile:1.4
# Multi-tenant ComfyUI image — one container per BoosAPI user.
#
# Lineage: based on the same Python 3.11 / CPU-only profile the legacy
# bosapi-comfyui:ready image used. Differences from the singleton image:
# 1. Bakes `comfy_patches/overlay` into the image (proxy-base
# default + auth-header fallback, server/runtime hardening, docs)
# so containers do NOT need a host bind-mount of `comfy_source/`.
# 2. Bakes the BoosAPI custom nodes from `comfy/boosapi_comfy_nodes.py`
# under `/workspace/ComfyUI/custom_nodes/` so workflows referencing
# `BoosAPITextGen` etc. resolve without any host mount.
# 3. Reads `BOOSAPI_BUILTIN_KEY`, `BOOSAPI_BASE`, `BOOSAPI_KEY` at
# runtime via the entrypoint shim and forwards them as
# `--comfy-api-base` and the documented env vars.
# 4. No host source mounts — all ComfyUI code lives inside the image
# so the container can run with `--read-only` and tmpfs `/tmp`.
#
# Build context MUST be the repo root (`docker build -f docker/Dockerfile.comfyui.multi-tenant .`).
#
# Requires BuildKit (Docker 20.10+ has it on by default; build-and-run.sh
# also exports DOCKER_BUILDKIT=1 defensively). The `--mount=type=cache`
# instructions below are no-ops without BuildKit.
FROM python:3.11-slim
WORKDIR /workspace
# USTC mirror for faster apt
RUN sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl tini \
&& rm -rf /var/lib/apt/lists/*
# uv: 5-10x faster than pip on resolution + install. Installed from PyPI
# via the Tsinghua mirror that's already used by the rest of the build,
# instead of curling the upstream GitHub release tarball —
# `github.com/astral-sh/uv/releases/...` is unreliable from mainland
# networks (TLS handshake completes, then body read truncates with
# `SSL_read: unexpected eof`). The PyPI wheel bundles the same self-
# contained binary and drops `uv` into the system Python's scripts dir
# (already on PATH). Pinned for reproducibility.
RUN pip install --no-cache-dir \
--index-url https://pypi.tuna.tsinghua.edu.cn/simple \
uv==0.5.31 \
&& uv --version
# ── Layer-caching strategy ────────────────────────────────────────────
# Copy ONLY requirements.txt first. The heavy install layers (torch +
# ComfyUI deps, ~200 MB combined) then live in their own cache lines
# that only invalidate when requirements.txt actually changes. Most
# rebuilds change other comfy_source/ files (workflow scripts, custom
# nodes, etc.), so this swap turns a 5-10 minute reinstall into a
# zero-network layer-cache hit.
#
# The full `COPY comfy_source` happens after the install. It overlays
# the rest of ComfyUI on top of the venv directory (which lives at
# /workspace/ComfyUI/venv) without disturbing it — venv/ isn't present
# in the source tree, and requirements.txt is just rewritten with
# identical content.
COPY comfy_source/requirements.txt /workspace/ComfyUI/requirements.txt
WORKDIR /workspace/ComfyUI
RUN uv venv venv --python python3.11
ENV PATH="/workspace/ComfyUI/venv/bin:${PATH}"
# ── PyTorch CPU wheels ────────────────────────────────────────────────
# ~200 MB combined and the single biggest network draw of the build.
# Two reliability levers:
# 1. Tsinghua pytorch-wheels mirror replaces upstream
# `download.pytorch.org/whl/cpu`. Upstream is slow + flaky from
# mainland networks (TLS handshake completes, body read truncates
# with `SSL_read: unexpected eof`). Tsinghua mirrors the same
# wheels and is documented as a drop-in `--extra-index-url`.
# 2. BuildKit cache mount on /root/.cache/uv preserves the downloaded
# wheels across rebuilds. Even if this layer is invalidated (e.g.
# after a torch version bump), uv reuses cached wheels and only
# fetches what changed.
RUN --mount=type=cache,target=/root/.cache/uv \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
uv pip install --python venv/bin/python torch torchvision torchaudio
# ── ComfyUI deps ──────────────────────────────────────────────────────
# kornia_rs is a Rust-built wheel that compiles from source on install,
# adding minutes to the build. The BoosAPI workflows we care about
# don't use it, so strip it. Cache mount handles re-downloads when
# requirements.txt changes (most lines stay the same → most wheels
# reuse from cache).
RUN --mount=type=cache,target=/root/.cache/uv \
grep -v kornia_rs requirements.txt > /tmp/requirements.txt && \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python -r /tmp/requirements.txt && \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python six && \
rm /tmp/requirements.txt
# ── Full ComfyUI source ───────────────────────────────────────────────
# This overlay COPY brings in everything else under comfy_source/. The
# venv at /workspace/ComfyUI/venv survives because the source tree
# doesn't contain a venv/ directory; requirements.txt is overwritten
# with identical bytes (no-op).
COPY comfy_source /workspace/ComfyUI
RUN git init
# ── Bake the BoosAPI patches ──────────────────────────────────────────
# Keep the image self-contained: overlay patches are tracked in the parent
# repo, while comfy_source remains a vanilla ComfyUI submodule.
COPY comfy_patches/overlay /workspace/ComfyUI
# BoosAPI custom nodes (BoosAPITextGen, image gen, etc.).
RUN mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py \
/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
# Entrypoint shim — forwards env vars to ComfyUI's CLI flags.
COPY docker/comfyui-entrypoint.sh /usr/local/bin/comfyui-entrypoint
RUN chmod +x /usr/local/bin/comfyui-entrypoint
EXPOSE 8188
# tini reaps any subprocesses ComfyUI spawns; the shim execs into Python.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/comfyui-entrypoint"]
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Multi-tenant ComfyUI entrypoint.
#
# Reads the per-user secrets the ComfyUIPool injects:
# BOOSAPI_BUILTIN_KEY the BoosAPI downstream-key secret used by the
# patched _helpers.py for built-in OpenAI nodes.
# BOOSAPI_BASE the BoosAPI loopback base, e.g. http://host:4000/v1
# BOOSAPI_KEY the per-user proxy key for boosapi_comfy_nodes.
#
# Then execs ComfyUI with --listen 0.0.0.0 --port 8188 and the
# --comfy-api-base flag pointing at the BoosAPI agent shim so all
# built-in OpenAI nodes route through us.
set -euo pipefail
: "${BOOSAPI_BASE:?BOOSAPI_BASE is required (BoosAPI /v1 base URL)}"
: "${BOOSAPI_BUILTIN_KEY:?BOOSAPI_BUILTIN_KEY is required (downstream-key secret)}"
# BOOSAPI_KEY is per-user; tolerate it being unset (then BoosAPITextGen
# nodes will fall back to BOOSAPI_BUILTIN_KEY via the helper patch).
export BOOSAPI_KEY="${BOOSAPI_KEY:-$BOOSAPI_BUILTIN_KEY}"
# Strip a trailing /v1 for the agent base (the helper appends it back).
BOOSAPI_HOST="${BOOSAPI_BASE%/v1}"
BOOSAPI_HOST="${BOOSAPI_HOST%/}"
COMFY_API_BASE="${BOOSAPI_HOST}/api/comfyui-agent"
cd /workspace/ComfyUI
exec python main.py \
--listen 0.0.0.0 \
--port 8188 \
--cpu \
--comfy-api-base "${COMFY_API_BASE}"
-22
View File
@@ -1,22 +0,0 @@
# docker-compose.comfyui.yml
# 开发环境:在 BoosAPI 旁边运行 ComfyUI
# 使用: docker compose -f docker/docker-compose.yml -f docker/docker-compose.comfyui.yml up -d
services:
comfyui:
build:
context: ..
dockerfile: docker/Dockerfile.comfyui
ports:
- "127.0.0.1:8188:8188"
volumes:
- comfyui-data:/workspace
- ../boosapi_comfy_nodes.py:/workspace/ComfyUI/custom_nodes/boosapi_comfy_nodes.py
environment:
- BOOSAPI_BASE=http://host.docker.internal:4000/v1
- BOOSAPI_KEY=${BOOSAPI_KEY:-sk-demo-user}
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
comfyui-data:
@@ -0,0 +1,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);
+868
View File
@@ -0,0 +1,868 @@
-- BoosAPI initial schema
-- Generated from the existing SQLite database (hub.db)
-- This migration creates all tables, indexes, and constraints.
-- ============================================================
-- SITES
-- ============================================================
CREATE TABLE IF NOT EXISTS sites (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT,
proxy_url TEXT,
use_system_proxy BOOLEAN DEFAULT FALSE,
custom_headers TEXT,
external_checkin_url TEXT,
global_weight DOUBLE PRECISION DEFAULT 1,
post_refresh_probe_enabled BOOLEAN DEFAULT FALSE,
post_refresh_probe_model TEXT DEFAULT '',
post_refresh_probe_scope TEXT DEFAULT 'single',
post_refresh_probe_latency_threshold_ms BIGINT DEFAULT 0,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS sites_platform_url_unique
ON sites (platform, url);
CREATE INDEX IF NOT EXISTS sites_status_idx
ON sites (status);
-- ============================================================
-- USERS
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique
ON users (email);
CREATE INDEX IF NOT EXISTS users_status_idx
ON users (status);
-- ============================================================
-- TOKEN ROUTES
-- ============================================================
CREATE TABLE IF NOT EXISTS token_routes (
id BIGSERIAL PRIMARY KEY,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
display_name TEXT,
display_icon TEXT,
decision_snapshot TEXT,
decision_refreshed_at TEXT,
routing_strategy TEXT DEFAULT 'weighted',
route_mode TEXT DEFAULT 'pattern',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx
ON token_routes (model_pattern);
CREATE INDEX IF NOT EXISTS token_routes_enabled_idx
ON token_routes (enabled);
-- ============================================================
-- ACCOUNTS
-- ============================================================
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
username TEXT,
access_token TEXT NOT NULL,
api_token TEXT,
balance DOUBLE PRECISION DEFAULT 0,
balance_used DOUBLE PRECISION DEFAULT 0,
quota DOUBLE PRECISION DEFAULT 0,
unit_cost DOUBLE PRECISION,
value_score DOUBLE PRECISION DEFAULT 0,
status TEXT DEFAULT 'active',
checkin_enabled BOOLEAN DEFAULT TRUE,
last_checkin_at TEXT,
last_balance_refresh TEXT,
extra_config TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
oauth_provider TEXT,
oauth_account_key TEXT,
oauth_project_id TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS accounts_site_id_idx
ON accounts (site_id);
CREATE INDEX IF NOT EXISTS accounts_status_idx
ON accounts (status);
CREATE INDEX IF NOT EXISTS accounts_site_status_idx
ON accounts (site_id, status);
CREATE INDEX IF NOT EXISTS accounts_oauth_provider_idx
ON accounts (oauth_provider);
CREATE INDEX IF NOT EXISTS accounts_oauth_identity_idx
ON accounts (oauth_provider, oauth_account_key, oauth_project_id);
-- ============================================================
-- DOWNSTREAM API KEYS
-- ============================================================
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled BOOLEAN DEFAULT TRUE,
expires_at TEXT,
max_cost DOUBLE PRECISION,
used_cost DOUBLE PRECISION DEFAULT 0,
max_requests BIGINT,
used_requests BIGINT DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id BIGINT REFERENCES users(id),
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys (key);
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys (name);
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys (enabled);
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys (expires_at);
-- ============================================================
-- SITE DISABLED MODELS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_disabled_models (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique
ON site_disabled_models (site_id, model_name);
CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx
ON site_disabled_models (site_id);
-- ============================================================
-- SITE API ENDPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_api_endpoints (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
url TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
sort_order BIGINT DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until);
-- ============================================================
-- ACCOUNT TOKENS
-- ============================================================
CREATE TABLE IF NOT EXISTS account_tokens (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
source TEXT DEFAULT 'manual',
token_group TEXT,
value_status TEXT NOT NULL DEFAULT 'ready',
enabled BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx
ON account_tokens (account_id);
CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx
ON account_tokens (account_id, enabled);
CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx
ON account_tokens (enabled);
-- ============================================================
-- CHECKIN LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS checkin_logs (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
status TEXT NOT NULL,
message TEXT,
reward TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx
ON checkin_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx
ON checkin_logs (created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_status_idx
ON checkin_logs (status);
-- ============================================================
-- MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS model_availability (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
is_manual BOOLEAN DEFAULT FALSE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique
ON model_availability (account_id, model_name);
CREATE INDEX IF NOT EXISTS model_availability_account_available_idx
ON model_availability (account_id, available);
CREATE INDEX IF NOT EXISTS model_availability_model_name_idx
ON model_availability (model_name);
-- ============================================================
-- TOKEN MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS token_model_availability (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique
ON token_model_availability (token_id, model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx
ON token_model_availability (token_id, available);
CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx
ON token_model_availability (model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_available_idx
ON token_model_availability (available);
-- ============================================================
-- ROUTE CHANNELS
-- ============================================================
CREATE TABLE IF NOT EXISTS route_channels (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
token_id BIGINT,
oauth_route_unit_id BIGINT,
source_model TEXT,
priority BIGINT DEFAULT 0,
weight BIGINT DEFAULT 10,
enabled BOOLEAN DEFAULT TRUE,
manual_override BOOLEAN DEFAULT FALSE,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
FOREIGN KEY (route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS route_channels_route_id_idx
ON route_channels (route_id);
CREATE INDEX IF NOT EXISTS route_channels_account_id_idx
ON route_channels (account_id);
CREATE INDEX IF NOT EXISTS route_channels_token_id_idx
ON route_channels (token_id);
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx
ON route_channels (route_id, enabled);
CREATE INDEX IF NOT EXISTS route_channels_route_token_idx
ON route_channels (route_id, token_id);
CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id);
-- ============================================================
-- ROUTE GROUP SOURCES
-- ============================================================
CREATE TABLE IF NOT EXISTS route_group_sources (
id BIGSERIAL PRIMARY KEY,
group_route_id BIGINT NOT NULL,
source_route_id BIGINT NOT NULL,
FOREIGN KEY (group_route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (source_route_id) REFERENCES token_routes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique
ON route_group_sources (group_route_id, source_route_id);
CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx
ON route_group_sources (source_route_id);
-- ============================================================
-- PROXY LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_logs (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT,
channel_id BIGINT,
account_id BIGINT,
downstream_api_key_id BIGINT,
model_requested TEXT,
model_actual TEXT,
status TEXT,
http_status BIGINT,
latency_ms BIGINT,
first_byte_latency_ms BIGINT,
prompt_tokens BIGINT,
completion_tokens BIGINT,
total_tokens BIGINT,
estimated_cost DOUBLE PRECISION,
error_message TEXT,
retry_count BIGINT DEFAULT 0,
is_stream BIGINT,
client_family TEXT,
client_app_id TEXT,
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx
ON proxy_logs (created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx
ON proxy_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx
ON proxy_logs (status, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx
ON proxy_logs (model_actual, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx
ON proxy_logs (downstream_api_key_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at);
-- ============================================================
-- PROXY VIDEO TASKS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
upstream_video_id TEXT NOT NULL,
site_url TEXT NOT NULL,
token_value TEXT NOT NULL,
requested_model TEXT,
actual_model TEXT,
channel_id BIGINT,
account_id BIGINT,
status_snapshot TEXT,
upstream_response_meta TEXT,
last_upstream_status BIGINT,
last_polled_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks (public_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks (upstream_video_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx
ON proxy_video_tasks (created_at);
-- ============================================================
-- PROXY FILES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_files (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
purpose TEXT,
byte_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
content_base64 TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files (public_id);
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files (owner_type, owner_id, deleted_at);
-- ============================================================
-- PROXY DEBUG TRACES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id BIGSERIAL PRIMARY KEY,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id BIGINT,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id BIGINT,
selected_channel_id BIGINT,
selected_route_id BIGINT,
selected_account_id BIGINT,
selected_site_id BIGINT,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status BIGINT,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at);
-- ============================================================
-- PROXY DEBUG ATTEMPTS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id BIGSERIAL PRIMARY KEY,
trace_id BIGINT NOT NULL,
attempt_index BIGINT NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status BIGINT,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied BOOLEAN DEFAULT FALSE,
downgrade_decision BOOLEAN DEFAULT FALSE,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index);
CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at);
-- ============================================================
-- EVENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
"read" BOOLEAN DEFAULT FALSE,
related_id BIGINT,
related_type TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS events_read_created_at_idx
ON events ("read", created_at);
CREATE INDEX IF NOT EXISTS events_type_created_at_idx
ON events (type, created_at);
CREATE INDEX IF NOT EXISTS events_created_at_idx
ON events (created_at);
-- ============================================================
-- SITE ANNOUNCEMENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_announcements (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
platform TEXT NOT NULL,
source_key TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
source_url TEXT,
starts_at TEXT,
ends_at TEXT,
upstream_created_at TEXT,
upstream_updated_at TEXT,
first_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
last_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
read_at TEXT,
dismissed_at TEXT,
raw_payload TEXT,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_announcements_site_source_key_unique
ON site_announcements (site_id, source_key);
CREATE INDEX IF NOT EXISTS site_announcements_site_id_first_seen_at_idx
ON site_announcements (site_id, first_seen_at);
CREATE INDEX IF NOT EXISTS site_announcements_read_at_idx
ON site_announcements (read_at);
-- ============================================================
-- OAUTH ROUTE UNITS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_units (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider);
CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled);
-- ============================================================
-- OAUTH ROUTE UNIT MEMBERS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id BIGSERIAL PRIMARY KEY,
unit_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
sort_order BIGINT DEFAULT 0,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until);
-- ============================================================
-- SETTINGS
-- ============================================================
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
-- ============================================================
-- ADMIN SNAPSHOTS
-- ============================================================
CREATE TABLE IF NOT EXISTS admin_snapshots (
id BIGSERIAL PRIMARY KEY,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key);
CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at);
CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until);
-- ============================================================
-- ANALYTICS PROJECTION CHECKPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id BIGINT NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id BIGINT,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at);
-- ============================================================
-- MODEL DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS model_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
model TEXT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT model_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model);
CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day);
CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id);
CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model);
-- ============================================================
-- SITE DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id);
CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day);
CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id);
-- ============================================================
-- SITE HOUR USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_hour_usage (
id BIGSERIAL PRIMARY KEY,
bucket_start_utc TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_hour_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id);
CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc);
CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id);
@@ -0,0 +1,24 @@
-- Background tasks tracking
CREATE TABLE IF NOT EXISTS background_tasks (
id TEXT PRIMARY KEY NOT NULL,
task_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT DEFAULT '',
error TEXT,
dedupe_key TEXT,
success_title TEXT,
failure_title TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
result_json TEXT,
progress BIGINT DEFAULT 0,
progress_total BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS background_tasks_status_idx
ON background_tasks (status);
@@ -0,0 +1,53 @@
-- Organizations, members, and invitations for multi-tenant support
CREATE TABLE IF NOT EXISTS organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
ON organizations (slug);
CREATE INDEX IF NOT EXISTS org_plan_idx
ON organizations (plan);
CREATE TABLE IF NOT EXISTS organization_members (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
joined_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
ON organization_members (org_id, user_id);
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
ON organization_members (org_id);
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
ON organization_members (user_id);
CREATE TABLE IF NOT EXISTS invitations (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
invited_by BIGINT REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
ON invitations (token);
CREATE INDEX IF NOT EXISTS invitations_status_idx
ON invitations (status);
@@ -0,0 +1,37 @@
-- Usage tracking tables migrated from the Node.js backend.
CREATE TABLE IF NOT EXISTS usage_logs (
id BIGSERIAL PRIMARY KEY,
downstream_key_id BIGINT,
model TEXT,
prompt_tokens BIGINT DEFAULT 0,
completion_tokens BIGINT DEFAULT 0,
total_tokens BIGINT DEFAULT 0,
cost DOUBLE PRECISION DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_logs_downstream_key_created_idx
ON usage_logs (downstream_key_id, created_at);
CREATE INDEX IF NOT EXISTS usage_logs_created_idx
ON usage_logs (created_at);
CREATE TABLE IF NOT EXISTS usage_daily_snapshots (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
downstream_key_id BIGINT,
request_count BIGINT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_cost DOUBLE PRECISION NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_snapshots_day_idx
ON usage_daily_snapshots (local_day);
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
read_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template BIGINT NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
workflow_id BIGINT NOT NULL,
user_id BIGINT,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
+2 -1
View File
@@ -30,7 +30,7 @@
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
"build:web": "npm run desktop:icons && vite build",
"build:web": "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",
@@ -52,6 +52,7 @@
"comfyui:install": "node scripts/comfyui/install.mjs",
"comfyui:start": "node scripts/comfyui/start.mjs",
"comfyui:stop": "node scripts/comfyui/stop.mjs",
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/server/db/migrate.ts",
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
+34
View File
@@ -0,0 +1,34 @@
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 || 'https://github.com/Comfy-Org/ComfyUI_frontend.git';
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.9.19';
const DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd || ROOT,
env: { ...process.env, ...(options.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}`));
});
});
}
await fs.mkdir(path.dirname(DIR), { recursive: true });
await fs.rm(DIR, { recursive: true, force: true });
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, DIR]);
await run('npm', ['ci'], { cwd: DIR });
await run('npm', ['run', 'build'], { cwd: DIR });
await fs.rm(DIST, { recursive: true, force: true });
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
console.log(`workflow-ui installed to ${DIST}`);
+100 -1
View File
@@ -1,12 +1,111 @@
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
any::{install_default_drivers, AnyPoolOptions},
postgres::{PgPool, PgPoolOptions},
sqlite::{SqlitePool, SqlitePoolOptions},
};
pub mod models;
pub mod queries;
pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DbKind {
Sqlite,
Postgres,
}
impl DbKind {
pub fn from_url(database_url: &str) -> anyhow::Result<Self> {
let normalized = database_url.trim().to_ascii_lowercase();
if normalized.starts_with("sqlite:") {
Ok(Self::Sqlite)
} else if normalized.starts_with("postgres://") || normalized.starts_with("postgresql://") {
Ok(Self::Postgres)
} else {
bail!("unsupported database URL; expected sqlite:, postgres:// or postgresql://")
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Sqlite => "sqlite",
Self::Postgres => "postgres",
}
}
}
pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result<SqlitePool> {
if DbKind::from_url(database_url)? != DbKind::Sqlite {
bail!(
"runtime PostgreSQL query support is not enabled yet; use migrate-sqlite-to-postgres to prepare PostgreSQL data"
);
}
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn create_pg_pool(database_url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect PostgreSQL")?;
Ok(pool)
}
pub async fn test_connection(database_url: &str, max_connections: u32) -> anyhow::Result<DbKind> {
match DbKind::from_url(database_url)? {
DbKind::Sqlite => {
SqlitePoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect SQLite")?
.close()
.await;
Ok(DbKind::Sqlite)
}
DbKind::Postgres => {
create_pg_pool(database_url, max_connections)
.await?
.close()
.await;
Ok(DbKind::Postgres)
}
}
}
pub async fn run_migrations(
kind: DbKind,
sqlite_pool: Option<&SqlitePool>,
pg_pool: Option<&PgPool>,
) -> anyhow::Result<()> {
match kind {
DbKind::Sqlite => {
let pool = sqlite_pool.context("SQLite pool required for SQLite migrations")?;
sqlx::migrate!("./migrations").run(pool).await?;
}
DbKind::Postgres => {
let pool = pg_pool.context("PostgreSQL pool required for PostgreSQL migrations")?;
sqlx::migrate!("./migrations_pg").run(pool).await?;
}
}
Ok(())
}
pub async fn create_any_pool(
database_url: &str,
max_connections: u32,
) -> anyhow::Result<sqlx::AnyPool> {
install_default_drivers();
Ok(AnyPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?)
}
+463
View File
@@ -0,0 +1,463 @@
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),
],
},
];
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()
}
}
+17 -1
View File
@@ -24,6 +24,12 @@ pub enum AppError {
#[error("Rate limited")]
RateLimited,
#[error("Rate limited: {message}")]
RateLimitedWithRetry {
message: String,
retry_after_secs: u64,
},
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
@@ -46,18 +52,28 @@ impl IntoResponse for AppError {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
AppError::RateLimitedWithRetry { message, .. } => {
(StatusCode::TOO_MANY_REQUESTS, message.clone())
}
AppError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
let retry_after_secs = match &self {
AppError::RateLimitedWithRetry {
retry_after_secs, ..
} => Some(*retry_after_secs),
_ => None,
};
(
status,
Json(json!({
"error": {
"message": message,
"type": status.canonical_reason(),
}
},
"retry_after_secs": retry_after_secs,
})),
)
.into_response()
+5 -11
View File
@@ -7,6 +7,7 @@ pub mod routes;
pub mod services;
pub mod shared;
pub mod transformers;
pub mod workflow;
use sqlx::SqlitePool;
use std::sync::Arc;
@@ -16,8 +17,8 @@ pub struct AppState {
pub config: config::AppConfig,
pub db: Arc<SqlitePool>,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub comfyui_pool: Arc<services::comfyui_pool::ComfyUIPool>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
}
impl AppState {
@@ -40,21 +41,14 @@ impl AppState {
comfyui_agent_secret.clone(),
);
// Per-user multi-tenant ComfyUI pool. Backed by Docker today; the
// ContainerRuntime trait makes K3S a drop-in v2.
let docker = Arc::new(services::docker_runtime::DockerRuntime::new());
let pool_cfg = services::comfyui_pool::PoolConfig::from_env(
format!("{}/v1", boosapi_url.trim_end_matches('/')),
comfyui_agent_secret,
);
let comfyui_pool = services::comfyui_pool::ComfyUIPool::new(docker, pool_cfg);
Self {
config,
db: Arc::new(db),
comfyui,
comfyui_pool,
builtin_keys: Arc::new(builtin_keys),
registration_limiter: Arc::new(
services::registration_limiter::RegistrationLimiter::new(),
),
}
}
}
+40 -24
View File
@@ -1,7 +1,7 @@
use anyhow::Context;
use axum::serve;
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
use clap::Parser;
use clap::{Parser, Subcommand};
use std::ffi::OsString;
use std::net::SocketAddr;
use tokio::signal;
@@ -15,6 +15,9 @@ use tracing_subscriber::EnvFilter;
about = "AI API Gateway — Rust rewrite of BoosAPI"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Config file path
#[arg(short, long, default_value = "config.toml")]
config: String,
@@ -40,10 +43,42 @@ struct Cli {
check_config: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Copy core data from SQLite into PostgreSQL and exit
MigrateSqliteToPostgres {
/// Source SQLite URL, e.g. sqlite:boosapi.db?mode=rwc
#[arg(long)]
sqlite_url: String,
/// Target PostgreSQL URL, e.g. postgres://user:pass@host:5432/boosapi
#[arg(long)]
postgres_url: String,
/// Truncate target business tables before importing
#[arg(long)]
force_truncate: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Cli::parse_from(normalize_legacy_args());
if let Some(Commands::MigrateSqliteToPostgres {
sqlite_url,
postgres_url,
force_truncate,
}) = &args.command
{
let sqlite = db::create_pool(sqlite_url, 1).await?;
let pg = db::create_pg_pool(postgres_url, 2).await?;
db::run_migrations(db::DbKind::Postgres, None, Some(&pg)).await?;
let summary = db::sqlite_to_postgres::migrate(&sqlite, &pg, *force_truncate).await?;
println!("{}", serde_json::to_string_pretty(&summary)?);
return Ok(());
}
// Load config
let mut config = AppConfig::load_from_file(&args.config)?;
@@ -73,12 +108,14 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Loaded configuration from {}", args.config);
let db_kind = db::DbKind::from_url(&config.db.url)?;
// Create database pool
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
tracing::info!("Database connected");
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
db::run_migrations(db_kind, Some(&pool), None).await?;
tracing::info!("Database migrations applied");
if args.migrate_only {
@@ -105,20 +142,6 @@ async fn main() -> anyhow::Result<()> {
}
let comfyui_process = state.comfyui.clone();
// Multi-tenant ComfyUI pool: adopt any containers that survived a
// previous API restart, then start the idle-reaper task. Only
// wired in when the `multi_tenant_comfy` feature is on; under the
// legacy singleton path the pool is constructed but unused, and
// `comfyui_pool` is `None` so we skip shutdown_all on exit.
#[cfg(feature = "multi_tenant_comfy")]
let comfyui_pool = {
state.comfyui_pool.reconcile_on_startup().await;
boosapi::services::comfyui_pool::spawn_reaper(state.comfyui_pool.clone());
Some(state.comfyui_pool.clone())
};
#[cfg(not(feature = "multi_tenant_comfy"))]
let comfyui_pool: Option<std::sync::Arc<boosapi::services::comfyui_pool::ComfyUIPool>> = None;
// Build router
let app = boosapi::routes::build_router(state);
@@ -128,7 +151,7 @@ async fn main() -> anyhow::Result<()> {
serve(
tokio::net::TcpListener::bind(addr).await?,
app.into_make_service(),
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
@@ -137,13 +160,6 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
}
// Tear down any per-user ComfyUI containers we spawned. This is
// best-effort — a hard kill of the API leaves them running and the
// next startup will adopt them via reconcile_on_startup().
if let Some(pool) = comfyui_pool {
pool.shutdown_all().await;
}
Ok(())
}
+1
View File
@@ -267,6 +267,7 @@ mod tests {
route_id: Some(10),
site_id: 20,
account_id: Some(30),
account_unit_cost: None,
base_url: "https://example.test/v1".into(),
api_key: "key".into(),
model: "gpt-5.5".into(),
+1
View File
@@ -11,6 +11,7 @@ pub struct SelectedChannel {
pub route_id: Option<i64>,
pub site_id: i64,
pub account_id: Option<i64>,
pub account_unit_cost: Option<f64>,
pub base_url: String,
pub api_key: String,
pub model: String,
+28 -24
View File
@@ -76,6 +76,23 @@ fn looks_like_sse(text: &str) -> bool {
text.trim_start().starts_with("data:")
}
fn openai_compatible_base_url(base_url: &str) -> String {
let trimmed = base_url.trim().trim_end_matches('/');
if trimmed.ends_with("/v1") || trimmed.contains("/v1/") {
trimmed.to_string()
} else {
format!("{trimmed}/v1")
}
}
fn upstream_url(base_url: &str, path_suffix: &str) -> String {
format!(
"{}/{}",
openai_compatible_base_url(base_url),
path_suffix.trim_start_matches('/')
)
}
fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
let mut id: Option<String> = None;
let mut created: Option<i64> = None;
@@ -162,7 +179,7 @@ pub async fn execute_chat_completions(
body: Value,
) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!("{}/chat/completions", route.base_url.trim_end_matches('/'));
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
let resp = client
.post(&upstream_url)
@@ -279,8 +296,8 @@ pub async fn execute_anthropic_messages_stream(
let mut stream = resp.bytes_stream();
use futures::StreamExt;
while let Some(chunk_result) = stream.next().await {
let bytes = chunk_result
.map_err(|e| AppError::UpstreamError(format!("Stream error: {e}")))?;
let bytes =
chunk_result.map_err(|e| AppError::UpstreamError(format!("Stream error: {e}")))?;
let text = String::from_utf8_lossy(&bytes).to_string();
frames.extend(normalize_anthropic_sse_frames(&text));
}
@@ -328,7 +345,9 @@ fn normalize_anthropic_sse_frame(frame: &str) -> Option<String> {
let choice = &event.choices[0];
if choice.delta.role.is_some() {
let id = event.id.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
let id = event
.id
.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple()));
return Some(format!(
"event: message_start\ndata: {}\n\n",
serde_json::json!({
@@ -392,11 +411,7 @@ pub async fn execute_streaming_request(
body: Value,
) -> AppResult<Vec<String>> {
let client = http_client();
let upstream_url = format!(
"{}/{}",
route.base_url.trim_end_matches('/'),
path_suffix.trim_start_matches('/')
);
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.post(&upstream_url)
@@ -448,11 +463,7 @@ pub async fn execute_proxy_request(
body: Value,
) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!(
"{}/{}",
route.base_url.trim_end_matches('/'),
path_suffix.trim_start_matches('/')
);
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.post(&upstream_url)
@@ -483,11 +494,7 @@ pub async fn execute_proxy_request(
/// Generic GET proxy request to an upstream endpoint path.
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!(
"{}/{}",
route.base_url.trim_end_matches('/'),
path_suffix.trim_start_matches('/')
);
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.get(&upstream_url)
@@ -602,10 +609,7 @@ pub async fn execute_image_generation(
body: Value,
) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!(
"{}/images/generations",
route.base_url.trim_end_matches('/')
);
let upstream_url = upstream_url(&route.base_url, "/images/generations");
let resp = client
.post(&upstream_url)
@@ -664,7 +668,7 @@ pub async fn execute_image_edit(
use reqwest::multipart::{Form, Part};
let client = http_client();
let upstream_url = format!("{}/images/edits", route.base_url.trim_end_matches('/'));
let upstream_url = upstream_url(&route.base_url, "/images/edits");
let mut form = Form::new();
for field in fields {
+3
View File
@@ -29,6 +29,7 @@ struct ChannelRow {
site_api_key: Option<String>,
account_access_token: String,
account_api_token: Option<String>,
account_unit_cost: Option<f64>,
token_name: Option<String>,
token_value: Option<String>,
source_model: Option<String>,
@@ -148,6 +149,7 @@ impl TokenRouter {
s.api_key AS site_api_key,
a.access_token AS account_access_token,
a.api_token AS account_api_token,
a.unit_cost AS account_unit_cost,
at.name AS token_name,
at.token AS token_value,
rc.source_model AS source_model
@@ -198,6 +200,7 @@ impl TokenRouter {
route_id: Some(row.route_id),
site_id: row.site_id,
account_id: Some(row.account_id),
account_unit_cost: row.account_unit_cost,
base_url: row.base_url,
api_key: api_key
.trim()
+93 -6
View File
@@ -100,6 +100,14 @@ struct IdPath {
id: i64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DatabaseConnectionRequest {
dialect: Option<String>,
connection_string: Option<String>,
ssl: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RouteChannelPath {
route_id: i64,
@@ -599,23 +607,102 @@ async fn test_system_proxy() -> Json<Value> {
}
async fn database_runtime(State(state): State<AppState>) -> Json<Value> {
let dialect = crate::db::DbKind::from_url(&state.config.db.url)
.map(|kind| kind.as_str().to_string())
.unwrap_or_else(|_| "unknown".to_string());
Json(json!({
"dialect": "sqlite",
"connectionString": state.config.db.url,
"dialect": dialect,
"connectionString": mask_connection_string(&state.config.db.url),
"ssl": state.config.db.ssl,
"active": {
"dialect": dialect,
"connection": mask_connection_string(&state.config.db.url),
"ssl": state.config.db.ssl,
},
"saved": state.config.db.runtime_db_url.as_ref().map(|url| json!({
"dialect": state.config.db.runtime_db_dialect.as_deref().unwrap_or("postgres"),
"connection": mask_connection_string(url),
"ssl": state.config.db.runtime_db_ssl.unwrap_or(false),
})),
"restartRequired": false,
}))
}
async fn update_database_runtime() -> Json<Value> {
Json(json!({ "success": true, "message": "database config is managed by config.toml" }))
Json(json!({
"success": false,
"message": "数据库运行配置由 config.toml / BOOSAPI_DB__URL 管理;修改后需要重启服务",
"restartRequired": true,
}))
}
async fn test_database_connection() -> Json<Value> {
Json(json!({ "success": true }))
async fn test_database_connection(
Json(req): Json<DatabaseConnectionRequest>,
) -> AppResult<Json<Value>> {
let connection = req
.connection_string
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::BadRequest("connectionString is required".into()))?;
let detected = crate::db::test_connection(connection, 2)
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?;
if let Some(expected) = req
.dialect
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if expected != detected.as_str() {
return Err(AppError::BadRequest(format!(
"dialect mismatch: selected {}, detected {}",
expected,
detected.as_str()
)));
}
}
Ok(Json(json!({
"success": true,
"dialect": detected.as_str(),
"connection": mask_connection_string(connection),
"ssl": req.ssl.unwrap_or(false),
})))
}
async fn database_migrate() -> Json<Value> {
Json(json!({ "success": true }))
Json(json!({
"success": false,
"message": "Web 迁移入口已禁用。请在服务器执行 boosapi migrate-sqlite-to-postgres --sqlite-url <sqlite-url> --postgres-url <postgres-url>,需要覆盖目标库时追加 --force-truncate。",
"cliOnly": true,
}))
}
fn mask_connection_string(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.starts_with("sqlite:") {
return trimmed.to_string();
}
let Some(scheme_end) = trimmed.find("://") else {
return trimmed.to_string();
};
let after_scheme = scheme_end + 3;
let Some(at_rel) = trimmed[after_scheme..].find('@') else {
return trimmed.to_string();
};
let at = after_scheme + at_rel;
let credentials = &trimmed[after_scheme..at];
let masked_credentials = if let Some(colon) = credentials.find(':') {
format!("{}:****", &credentials[..colon])
} else {
"****".to_string()
};
format!(
"{}{}{}",
&trimmed[..after_scheme],
masked_credentials,
&trimmed[at..]
)
}
async fn export_backup(
+2 -1
View File
@@ -19,7 +19,7 @@ mod accounts;
mod compat;
mod downstream_keys;
mod model_candidates;
mod models_marketplace;
pub(crate) mod models_marketplace;
mod sites;
mod tokens;
mod users;
@@ -63,6 +63,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/sites",
get(sites::list_sites).post(sites::create_site),
)
.route("/api/sites/detect", post(sites::detect_site))
.route(
"/api/sites/{id}",
get(sites::get_site)
+113
View File
@@ -12,6 +12,11 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct DetectSiteRequest {
pub url: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateSiteRequest {
pub name: String,
@@ -90,6 +95,18 @@ struct SiteApiEndpointRow {
last_failure_reason: Option<String>,
}
/// POST /api/sites/detect
pub async fn detect_site(Json(payload): Json<DetectSiteRequest>) -> AppResult<Json<Value>> {
let url = normalize_site_url(&payload.url)?;
let (platform, initialization_preset_id) = detect_site_platform(&url);
Ok(Json(json!({
"url": url,
"platform": platform,
"initializationPresetId": initialization_preset_id,
})))
}
/// GET /api/sites
pub async fn list_sites(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing all sites");
@@ -435,6 +452,102 @@ pub async fn update_site_disabled_models(
Ok(Json(json!({ "models": normalised })))
}
fn normalize_site_url(raw: &str) -> AppResult<String> {
let trimmed = raw.trim().trim_end_matches('/').to_string();
if trimmed.is_empty() {
return Err(AppError::BadRequest("url is required".into()));
}
let lower = trimmed.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
return Err(AppError::BadRequest(
"url must start with http:// or https://".into(),
));
}
Ok(trimmed)
}
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
let lower = url.to_ascii_lowercase();
if lower.starts_with("https://coding.dashscope.aliyuncs.com/apps/anthropic") {
return ("claude", Some("codingplan-claude"));
}
if lower.starts_with("https://coding.dashscope.aliyuncs.com/v1") {
return ("openai", Some("codingplan-openai"));
}
if lower.starts_with("https://open.bigmodel.cn/api/anthropic") {
return ("claude", Some("zhipu-coding-plan-claude"));
}
if lower.starts_with("https://open.bigmodel.cn/api/coding/paas/v4") {
return ("openai", Some("zhipu-coding-plan-openai"));
}
if lower.starts_with("https://api.deepseek.com/anthropic") {
return ("claude", Some("deepseek-claude"));
}
if lower.starts_with("https://api.deepseek.com/v1") || lower == "https://api.deepseek.com" {
return ("openai", Some("deepseek-openai"));
}
if lower.starts_with("https://api.moonshot.cn/anthropic") {
return ("claude", Some("moonshot-claude"));
}
if lower.starts_with("https://api.moonshot.cn/v1") {
return ("openai", Some("moonshot-openai"));
}
if lower.starts_with("https://api.minimaxi.com/anthropic") {
return ("claude", Some("minimax-claude"));
}
if lower.starts_with("https://api.minimaxi.com/v1") {
return ("openai", Some("minimax-openai"));
}
if lower.starts_with("https://api-inference.modelscope.cn/v1") {
return ("openai", Some("modelscope-openai"));
}
if lower.starts_with("https://api-inference.modelscope.cn") {
return ("claude", Some("modelscope-claude"));
}
if lower.starts_with("https://ark.cn-beijing.volces.com/api/coding/v3") {
return ("openai", Some("doubao-coding-openai"));
}
if lower.starts_with("https://ark.cn-beijing.volces.com/api/v3")
|| lower.starts_with("https://ark.cn-beijing.volces.com")
{
return ("openai", Some("volcengine-ark-openai"));
}
if lower.contains("anthropic") || lower.contains("claude") {
return ("claude", None);
}
if lower.contains("gemini") || lower.contains("googleapis.com") {
return ("gemini", None);
}
if lower.contains("new-api") {
return ("new-api", None);
}
if lower.contains("one-api") {
return ("one-api", None);
}
if lower.contains("anyrouter") {
return ("anyrouter", None);
}
if lower.contains("veloera") {
return ("veloera", None);
}
if lower.contains("one-hub") {
return ("one-hub", None);
}
if lower.contains("done-hub") {
return ("done-hub", None);
}
if lower.contains("sub2api") {
return ("sub2api", None);
}
if lower.contains("cliproxy") || lower.contains("cpa") {
return ("cliproxyapi", None);
}
("openai", None)
}
async fn replace_site_api_endpoints(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
site_id: i64,
+40 -4
View File
@@ -8,13 +8,14 @@ use crate::{
AppState,
};
use axum::{
extract::{Path, State},
extract::{ConnectInfo, Path, State},
http::HeaderMap,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::net::SocketAddr;
use tracing::info;
// ── Request / Response types ─────────────────────────────────────────
@@ -75,6 +76,7 @@ pub fn routes() -> Router<AppState> {
/// POST /api/auth/register
async fn register(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(payload): Json<RegisterRequest>,
) -> AppResult<Json<Value>> {
info!("Registering user: {}", payload.email);
@@ -90,6 +92,23 @@ async fn register(
));
}
let client_ip = Some(addr.ip());
if let Err(limit) = state
.registration_limiter
.check_and_record_attempt(client_ip, &payload.email)
{
tracing::warn!(
email = %payload.email,
client_ip = ?client_ip,
retry_after_secs = limit.retry_after_secs,
"registration rate limited",
);
return Err(AppError::RateLimitedWithRetry {
message: limit.message.to_string(),
retry_after_secs: limit.retry_after_secs,
});
}
// Check if email already exists
let existing: Option<User> = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&payload.email)
@@ -103,17 +122,33 @@ async fn register(
let password_hash = crate::auth::password::hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let mut tx = state.db.begin().await?;
let result = sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.execute(&*state.db)
.execute(&mut *tx)
.await?;
let user_id = result.last_insert_rowid();
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, 1)",
)
.bind(&default_key_name)
.bind(&default_key)
.bind(user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
state.registration_limiter.record_success(client_ip);
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid())
.bind(user_id)
.fetch_one(&*state.db)
.await?;
@@ -144,7 +179,8 @@ async fn register(
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
}
},
"defaultApiKey": default_key
}
})))
}
-457
View File
@@ -1,457 +0,0 @@
//! `/comfy/*` reverse proxy.
//!
//! Authenticates the caller via JWT/admin token (Authorization header, cookie,
//! or `?token=` query param where browsers can't set headers),
//! looks up the per-user container in [`ComfyUIPool`], and forwards
//! both HTTP and WS traffic to that container's loopback port.
//!
//! The proxy strips `/comfy` on requests so ComfyUI can keep serving from
//! its normal root while browsers see a scoped iframe URL.
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
body::Body,
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
OriginalUri, RawQuery, State,
},
http::{
header::{
CONNECTION, HOST, PROXY_AUTHENTICATE, PROXY_AUTHORIZATION, TE, TRAILER,
TRANSFER_ENCODING, UPGRADE,
},
HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri,
},
response::IntoResponse,
routing::{any, get},
Router,
};
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as TMessage;
use tracing::{debug, warn};
use crate::services::comfyui_pool::{ComfyUIPool, ContainerState, PoolError};
use crate::AppState;
const COLD_START_HOLD: Duration = Duration::from_secs(30);
const COLD_START_POLL_INTERVAL: Duration = Duration::from_millis(500);
const COMFY_SESSION_COOKIE: &str = "boosapi_comfy_token";
const ADMIN_WORKSPACE_USER_ID: i64 = 0;
pub fn routes() -> Router<AppState> {
Router::new()
// WS upgrade endpoints — must be matched before the catch-all.
.route("/comfy/ws", get(ws_handler))
.route("/comfy/ws/", get(ws_handler))
// Catch-all for HTTP. `{*path}` is axum 0.8 wildcard syntax.
.route("/comfy", any(http_handler))
.route("/comfy/", any(http_handler))
.route("/comfy/{*path}", any(http_handler))
}
// ── Auth ───────────────────────────────────────────────────────────────
fn user_id_from_token(state: &AppState, token: &str) -> Option<i64> {
let token = token.trim();
if token.is_empty() {
return None;
}
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Some(ADMIN_WORKSPACE_USER_ID);
}
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, token).ok()?;
claims.sub.parse::<i64>().ok()
}
/// Resolve the calling user from `Authorization: Bearer <token>`.
fn user_id_from_headers(state: &AppState, headers: &HeaderMap) -> Option<i64> {
let raw = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())?;
let token = raw.strip_prefix("Bearer ").unwrap_or(raw).trim();
user_id_from_token(state, token)
}
fn user_id_from_cookie(state: &AppState, headers: &HeaderMap) -> Option<i64> {
let raw = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())?;
for part in raw.split(';') {
let mut it = part.trim().splitn(2, '=');
let name = it.next()?.trim();
let value = it.next().unwrap_or("").trim();
if name == COMFY_SESSION_COOKIE {
let token = urlencoding::decode(value).ok()?;
return user_id_from_token(state, token.as_ref());
}
}
None
}
fn token_from_query(raw_query: Option<&str>) -> Option<String> {
let q = raw_query?;
for pair in q.split('&') {
let mut it = pair.splitn(2, '=');
let k = it.next()?;
let v = it.next().unwrap_or("");
if k == "token" {
return Some(urlencoding::decode(v).ok()?.into_owned());
}
}
None
}
fn query_without_token(raw_query: Option<&str>) -> Option<String> {
let query = raw_query?;
let kept: Vec<&str> = query
.split('&')
.filter(|pair| pair.splitn(2, '=').next() != Some("token"))
.filter(|pair| !pair.is_empty())
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join("&"))
}
}
fn comfy_session_cookie(token: &str) -> String {
format!(
"{}={}; Path=/comfy; HttpOnly; SameSite=Lax",
COMFY_SESSION_COOKIE,
urlencoding::encode(token)
)
}
// ── HTTP forwarder ─────────────────────────────────────────────────────
async fn http_handler(State(state): State<AppState>, req: Request<Body>) -> Response<Body> {
let query_token = token_from_query(req.uri().query());
let user_id = user_id_from_headers(&state, req.headers())
.or_else(|| user_id_from_cookie(&state, req.headers()))
.or_else(|| {
query_token
.as_deref()
.and_then(|token| user_id_from_token(&state, token))
});
let Some(user_id) = user_id else {
return unauthorized();
};
let entry = match state.comfyui_pool.get_or_start(user_id).await {
Ok(e) => e,
Err(PoolError::AtCapacity(_)) => return at_capacity(),
Err(e) => return upstream_error(format!("pool: {}", e)),
};
if entry.state != ContainerState::Ready {
if let Err(e) = wait_for_ready(&state.comfyui_pool, user_id).await {
return upstream_error(e);
}
}
state.comfyui_pool.touch(user_id).await;
let path = req.uri().path();
let upstream_path = path.strip_prefix("/comfy").unwrap_or(path);
let upstream_path = if upstream_path.is_empty() {
"/"
} else {
upstream_path
};
let query = query_without_token(req.uri().query());
let port = match state.comfyui_pool.entry(user_id).await {
Some(e) => e.host_port,
None => return upstream_error("container vanished".into()),
};
let upstream = match build_upstream_uri("http", port, upstream_path, query.as_deref()) {
Ok(u) => u,
Err(e) => return upstream_error(e),
};
let mut response = forward_http(state.comfyui_pool.clone(), user_id, req, upstream).await;
if let Some(token) = query_token {
if let Ok(value) = HeaderValue::from_str(&comfy_session_cookie(&token)) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
response
}
fn build_upstream_uri(
scheme: &str,
port: u16,
path: &str,
query: Option<&str>,
) -> Result<Uri, String> {
let mut s = format!("{}://127.0.0.1:{}{}", scheme, port, path);
if let Some(q) = query {
s.push('?');
s.push_str(q);
}
Uri::try_from(s).map_err(|e| format!("bad upstream uri: {}", e))
}
async fn forward_http(
pool: Arc<ComfyUIPool>,
user_id: i64,
req: Request<Body>,
upstream: Uri,
) -> Response<Body> {
let (parts, body) = req.into_parts();
let client = match reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(120))
.build()
{
Ok(c) => c,
Err(e) => return upstream_error(format!("client: {}", e)),
};
let body_bytes = match axum::body::to_bytes(body, 64 * 1024 * 1024).await {
Ok(b) => b,
Err(e) => return upstream_error(format!("read body: {}", e)),
};
let mut rb = client.request(reqwest_method(&parts.method), upstream.to_string());
for (name, value) in parts.headers.iter() {
if is_hop_by_hop(name) || is_browser_context_header(name) {
continue;
}
if let Ok(v) = value.to_str() {
rb = rb.header(name.as_str(), v);
}
}
rb = rb.body(body_bytes);
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => return upstream_error(format!("upstream send: {}", e)),
};
pool.touch(user_id).await;
let status = resp.status();
let mut builder = Response::builder().status(status);
for (name, value) in resp.headers().iter() {
if is_hop_by_hop(name) {
continue;
}
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
builder = builder.header(name.as_str(), v);
}
}
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => return upstream_error(format!("upstream read: {}", e)),
};
builder.body(Body::from(bytes)).unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::empty())
.unwrap()
})
}
fn reqwest_method(m: &Method) -> reqwest::Method {
reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET)
}
fn is_hop_by_hop(name: &HeaderName) -> bool {
name == &CONNECTION
|| name == &PROXY_AUTHENTICATE
|| name == &PROXY_AUTHORIZATION
|| name == &TE
|| name == &TRAILER
|| name == &TRANSFER_ENCODING
|| name == &UPGRADE
|| name == &HOST
|| name.as_str().eq_ignore_ascii_case("keep-alive")
}
fn is_browser_context_header(name: &HeaderName) -> bool {
let name = name.as_str();
name.eq_ignore_ascii_case("origin")
|| name.eq_ignore_ascii_case("referer")
|| name.eq_ignore_ascii_case("sec-fetch-site")
|| name.eq_ignore_ascii_case("sec-fetch-mode")
|| name.eq_ignore_ascii_case("sec-fetch-dest")
|| name.eq_ignore_ascii_case("sec-fetch-user")
}
// ── WebSocket forwarder ────────────────────────────────────────────────
async fn ws_handler(
State(state): State<AppState>,
OriginalUri(orig_uri): OriginalUri,
RawQuery(raw_query): RawQuery,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Response<Body> {
// Resolve user from header (preferred) or `?token=` (browser fallback).
let user_id = user_id_from_headers(&state, &headers)
.or_else(|| user_id_from_cookie(&state, &headers))
.or_else(|| {
token_from_query(raw_query.as_deref()).and_then(|t| user_id_from_token(&state, &t))
});
let Some(user_id) = user_id else {
return unauthorized();
};
let entry = match state.comfyui_pool.get_or_start(user_id).await {
Ok(e) => e,
Err(PoolError::AtCapacity(_)) => return at_capacity(),
Err(e) => return upstream_error(format!("pool: {}", e)),
};
if entry.state != ContainerState::Ready {
if let Err(e) = wait_for_ready(&state.comfyui_pool, user_id).await {
return upstream_error(e);
}
}
let port = match state.comfyui_pool.entry(user_id).await {
Some(e) => e.host_port,
None => return upstream_error("container vanished".into()),
};
// Forward whatever query the browser sent (clientId etc).
let upstream = match build_upstream_uri("ws", port, "/ws", orig_uri.query()) {
Ok(u) => u,
Err(e) => return upstream_error(e),
};
let pool = state.comfyui_pool.clone();
ws.on_upgrade(move |socket| async move {
pool.ws_open(user_id).await;
if let Err(e) = pump_ws(socket, upstream.to_string(), pool.clone(), user_id).await {
warn!(user_id, error = %e, "ws proxy ended with error");
}
pool.ws_close(user_id).await;
})
.into_response()
}
async fn pump_ws(
client: WebSocket,
upstream_url: String,
pool: Arc<ComfyUIPool>,
user_id: i64,
) -> Result<(), String> {
let (upstream_ws, _) = tokio_tungstenite::connect_async(&upstream_url)
.await
.map_err(|e| format!("ws connect: {}", e))?;
let (mut up_tx, mut up_rx) = upstream_ws.split();
let (mut cl_tx, mut cl_rx) = client.split();
let pool_a = pool.clone();
let pool_b = pool;
let to_upstream = async move {
while let Some(frame) = cl_rx.next().await {
let frame = match frame {
Ok(f) => f,
Err(e) => return Err::<(), String>(format!("client recv: {}", e)),
};
pool_a.touch(user_id).await;
let out = match frame {
Message::Text(t) => TMessage::Text(t.to_string().into()),
Message::Binary(b) => TMessage::Binary(b.to_vec()),
Message::Ping(p) => TMessage::Ping(p.to_vec()),
Message::Pong(p) => TMessage::Pong(p.to_vec()),
Message::Close(_) => TMessage::Close(None),
};
if up_tx.send(out).await.is_err() {
break;
}
}
Ok(())
};
let to_client = async move {
while let Some(frame) = up_rx.next().await {
let frame = match frame {
Ok(f) => f,
Err(e) => return Err::<(), String>(format!("upstream recv: {}", e)),
};
pool_b.touch(user_id).await;
let out = match frame {
TMessage::Text(t) => Message::Text(t.to_string().into()),
TMessage::Binary(b) => Message::Binary(b.into()),
TMessage::Ping(p) => Message::Ping(p.into()),
TMessage::Pong(p) => Message::Pong(p.into()),
TMessage::Close(_) => Message::Close(None),
TMessage::Frame(_) => continue,
};
if cl_tx.send(out).await.is_err() {
break;
}
}
Ok(())
};
tokio::select! {
r = to_upstream => r,
r = to_client => r,
}
}
// ── Cold-start hold ────────────────────────────────────────────────────
async fn wait_for_ready(pool: &ComfyUIPool, user_id: i64) -> Result<(), String> {
let deadline = Instant::now() + COLD_START_HOLD;
loop {
match pool.state(user_id).await {
ContainerState::Ready => return Ok(()),
ContainerState::Failed | ContainerState::Stopped => {
return Err("container failed to start".into());
}
ContainerState::Starting => {
if Instant::now() >= deadline {
return Err("cold start timeout".into());
}
tokio::time::sleep(COLD_START_POLL_INTERVAL).await;
}
}
}
}
// ── Error responses ────────────────────────────────────────────────────
fn unauthorized() -> Response<Body> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Bearer")
.body(Body::from(r#"{"error":"unauthorized"}"#))
.unwrap()
}
fn at_capacity() -> Response<Body> {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Retry-After", "60")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"error":"comfyui pool at capacity","retry_after":60}"#,
))
.unwrap()
}
fn upstream_error(msg: String) -> Response<Body> {
debug!(%msg, "comfy proxy upstream error");
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header("Content-Type", "application/json")
.body(Body::from(format!(
r#"{{"error":"comfy proxy: {}"}}"#,
msg.replace('"', "\\\"")
)))
.unwrap()
}
+152 -8
View File
@@ -63,6 +63,7 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/comfyui-agent/status", get(status_handler))
.route("/api/comfyui-agent/models", get(models_handler))
.route("/api/comfyui-agent/image-models", get(image_models_handler))
.route("/api/comfyui-agent/chat", post(chat_handler))
}
@@ -70,6 +71,8 @@ pub fn routes() -> Router<AppState> {
struct AgentChatRequest {
messages: Vec<AgentMessage>,
model: Option<String>,
#[serde(rename = "imageModel")]
image_model: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -85,6 +88,10 @@ struct AgentMessage {
struct AgentAttachment {
name: String,
mime_type: Option<String>,
#[serde(default, rename = "mediaKind")]
media_kind: Option<String>,
#[serde(default, rename = "referenceLabel")]
reference_label: Option<String>,
text: Option<String>,
data_url: Option<String>,
}
@@ -290,6 +297,52 @@ fn is_exact_model_pattern(value: &str) -> bool {
&& !normalized.contains('?')
}
fn is_image_model_pattern(value: &str) -> bool {
if !is_exact_model_pattern(value) {
return false;
}
let normalized = value.trim().to_ascii_lowercase();
let image_keywords = [
"gpt-image",
"dall-e",
"dalle",
"midjourney",
"recraft",
"kolors",
"cogview",
"seedream",
"sdxl",
"stable-diffusion",
"stablediffusion",
"pixart",
"playground",
"firefly",
"imagen",
"flux",
"image",
"img",
"canvas",
"painting",
"draw",
];
if image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
{
return true;
}
let chat_keywords = [
"claude", "chatgpt", "gpt-4", "gpt-3", "gpt-5", "o1", "o3", "o4", "gemini", "deepseek",
"qwen", "llama", "mistral", "command", "kimi", "doubao", "glm", "hunyuan", "spark",
"baichuan", "ernie", "wenxin", "grok",
];
!chat_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
}
async fn status_handler(
State(state): State<AppState>,
headers: HeaderMap,
@@ -353,6 +406,47 @@ async fn models_handler(
})))
}
async fn image_models_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
let _auth = resolve_agent_auth(&headers, &state).await?;
let rows: Vec<String> = sqlx::query_scalar(
"SELECT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await
.map_err(|err| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": err.to_string() })),
)
})?;
let mut seen = std::collections::HashSet::new();
let models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_image_model_pattern(value))
.filter(|value| seen.insert(value.clone()))
.collect();
Ok(Json(json!({
"success": true,
"models": models,
"defaultModel": models
.iter()
.find(|model| model.to_ascii_lowercase().contains("image"))
.or_else(|| models.first())
.cloned()
.unwrap_or_else(|| "gpt-image-2".to_string()),
})))
}
#[derive(Debug, Default)]
struct ParsedSections {
characters: Vec<CharacterSection>,
@@ -501,7 +595,12 @@ fn parse_sections(full_text: &str) -> ParsedSections {
sections
}
async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<String, String> {
async fn call_image_gen(
api_base: &str,
api_key: &str,
image_model: &str,
prompt: &str,
) -> Result<String, String> {
let client = Client::new();
let url = format!("{}/images/generations", api_base.trim_end_matches('/'));
@@ -509,7 +608,7 @@ async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<S
.post(&url)
.bearer_auth(api_key)
.json(&json!({
"model": "gpt-image-2",
"model": image_model,
"prompt": prompt,
"n": 1,
"size": "1024x1024"
@@ -587,6 +686,19 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
.unwrap_or("application/octet-stream");
let name = attachment.name.trim();
let name = if name.is_empty() { "untitled" } else { name };
let reference = attachment
.reference_label
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("@素材");
let media_kind = attachment
.media_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file");
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
if let Some(text) = attachment
.text
@@ -594,9 +706,9 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("\n\n[附件: {name}, 类型: {mime}]\n{text}")
format!("{header}\n{text}")
} else {
format!("\n\n[附件: {name}, 类型: {mime}]")
header
}
}
@@ -684,6 +796,12 @@ async fn chat_handler(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let requested_image_model = body
.image_model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let messages: Vec<Value> = body
.messages
@@ -699,6 +817,7 @@ async fn chat_handler(
"comfyui-agent: resolved upstream",
);
let agent_model = requested_model.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
let image_model = requested_image_model.unwrap_or_else(|| "gpt-image-2".to_string());
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
@@ -813,12 +932,35 @@ async fn chat_handler(
continue;
};
let content = parsed
let delta = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.and_then(|delta| delta.get("content"))
.cloned()
.unwrap_or(Value::Null);
let reasoning = delta
.get("reasoning_content")
.or_else(|| delta.get("reasoning"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !reasoning.is_empty() {
if !send_sse(
&tx,
json!({
"type": "reasoning",
"content": reasoning,
}),
) {
return;
}
}
let content = delta
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
@@ -893,7 +1035,7 @@ async fn chat_handler(
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
match call_image_gen(&api_base, &api_key, prompt).await {
match call_image_gen(&api_base, &api_key, &image_model, prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
@@ -927,7 +1069,7 @@ async fn chat_handler(
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
match call_image_gen(&api_base, &api_key, &scene.prompt).await {
match call_image_gen(&api_base, &api_key, &image_model, &scene.prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
@@ -1191,8 +1333,10 @@ mod tests {
messages: vec![AgentMessage {
role: "user".to_string(),
content: "hello".to_string(),
attachments: Vec::new(),
}],
model: None,
image_model: None,
}),
)
.await;
+160
View File
@@ -0,0 +1,160 @@
use crate::{
auth::jwt::validate_jwt,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
http::{header, HeaderMap},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
#[derive(Debug, Clone)]
struct DigitalHumanAuth {
outbound_key: String,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/digital-human/status", get(status))
.route("/api/digital-human/generate", post(generate))
}
fn local_v1_base(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
format!("http://{}:{}/v1", host, state.config.server.port)
}
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|token| !token.is_empty())
}
async fn user_downstream_key(state: &AppState, user_id: i64) -> Result<DownstreamApiKey, String> {
sqlx::query_as::<_, DownstreamApiKey>(
"SELECT *
FROM downstream_api_keys
WHERE user_id = ?
AND COALESCE(enabled, 1) = 1
AND (
expires_at IS NULL
OR expires_at = ''
OR datetime(expires_at) > datetime('now')
)
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&*state.db)
.await
.map_err(|err| err.to_string())?
.ok_or_else(|| "当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".to_string())
}
async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHumanAuth, AppError> {
let token = extract_bearer_token(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Ok(DigitalHumanAuth {
outbound_key: state.config.auth.proxy_token.clone(),
});
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role != "admin" && claims.role != "user" {
return Err(AppError::Forbidden("Forbidden".into()));
}
let user_id = claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
match user_downstream_key(state, user_id).await {
Ok(key) => Ok(DigitalHumanAuth {
outbound_key: key.key,
}),
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
outbound_key: state.config.auth.proxy_token.clone(),
}),
Err(message) => Err(AppError::Forbidden(message)),
}
}
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let _ = resolve_auth(&headers, &state).await?;
Ok(Json(json!({
"success": true,
"mode": "boosapi-video-route",
"capabilities": [
"text_to_avatar_video",
"image_to_avatar_video",
"audio_driven_avatar",
"multi_person_prompt",
"segmented_long_video"
]
})))
}
async fn generate(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
if body.get("model").and_then(Value::as_str).unwrap_or("").trim().is_empty() {
return Err(AppError::BadRequest("model is required".into()));
}
if body
.get("prompt")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.is_empty()
{
return Err(AppError::BadRequest("prompt is required".into()));
}
if let Some(object) = body.as_object_mut() {
object.insert("task_type".to_string(), json!("digital_human"));
}
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
.post(format!("{}/videos", local_v1_base(&state)))
.bearer_auth(auth.outbound_key)
.json(&body)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(Json(json!({
"success": true,
"request": body,
"response": value
})))
}
+138 -66
View File
@@ -1,22 +1,25 @@
use crate::AppState;
use axum::{
body::Body,
http::{Request, StatusCode},
response::Response,
routing::get,
Router,
http::{header, HeaderValue, Request, StatusCode},
response::{IntoResponse, Response},
routing::{any, get},
Json, Router,
};
use include_dir::{include_dir, Dir};
use serde_json::{json, Value};
use std::convert::Infallible;
use tower::service_fn;
use tower_http::services::ServeDir;
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
mod admin;
mod auth;
mod checkin;
#[cfg(feature = "multi_tenant_comfy")]
mod comfy_proxy;
mod comfyui;
mod comfyui_agent;
mod digital_human;
mod events;
mod health;
mod monitor;
@@ -31,19 +34,12 @@ mod tasks;
mod update_center;
mod user;
mod user_api_keys;
mod workspace;
// Two implementations of /api/video-agent/*: legacy singleton (default)
// vs per-user pool. Exactly one is mounted, picked at compile time by
// the `multi_tenant_comfy` feature flag.
#[cfg(feature = "multi_tenant_comfy")]
mod video_agent;
#[cfg(not(feature = "multi_tenant_comfy"))]
#[path = "video_agent_legacy.rs"]
mod video_agent;
mod workflow_ui;
mod workspace;
pub fn build_router(state: AppState) -> Router {
let web_dir = std::env::var("BOOSAPI_WEB_DIR").unwrap_or_else(|_| "dist/web".to_string());
// Build the API router
let api_routes = Router::new()
.route("/healthz", get(health::health_check))
@@ -51,12 +47,12 @@ pub fn build_router(state: AppState) -> Router {
.merge(proxy::routes())
.merge(picture_agent::routes())
.merge(comfyui_agent::routes())
.merge(comfyui::routes());
// /comfy/* reverse proxy is only mounted when the per-user pool is
// active; without it there's no per-user container to forward to.
#[cfg(feature = "multi_tenant_comfy")]
let api_routes = api_routes.merge(comfy_proxy::routes());
.merge(digital_human::routes())
.merge(comfyui::routes())
.merge(crate::workflow::routes::routes())
.route("/comfy", any(removed_comfy_workbench))
.route("/comfy/", any(removed_comfy_workbench))
.route("/comfy/{*path}", any(removed_comfy_workbench));
let api_routes = api_routes
.merge(auth::routes())
@@ -67,6 +63,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(organizations::routes())
.merge(search::routes())
.merge(site_announcements::routes())
.merge(workflow_ui::routes())
.merge(stats::routes())
.merge(tasks::routes())
.merge(update_center::routes())
@@ -76,50 +73,125 @@ pub fn build_router(state: AppState) -> Router {
.merge(video_agent::routes())
.with_state(state);
// Static file serving with SPA fallback
let web_dir_owned = web_dir.clone();
let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone();
async move {
let path = req.uri().path();
let router = Router::new().merge(api_routes);
match std::env::var("BOOSAPI_WEB_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(web_dir) => {
let web_dir_owned = web_dir.clone();
let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone();
async move {
let path = req.uri().path();
if is_api_path(path) {
return Ok::<_, Infallible>(api_not_found_response(path));
}
// API routes that don't match → return JSON error, not index.html
if path.starts_with("/api/") || path.starts_with("/v1/") {
return Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "application/json; charset=utf-8")
.body(Body::from(
serde_json::json!({"error": "Not found", "path": path}).to_string(),
))
.unwrap(),
);
}
// SPA fallback: serve index.html for client-side routing
let index_path = format!("{}/index.html", web_dir);
match tokio::fs::read_to_string(&index_path).await {
Ok(html) => Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap(),
),
Err(_) => Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap(),
),
}
}
}));
Router::new()
.merge(api_routes)
.fallback_service(static_service)
let index_path = format!("{}/index.html", web_dir);
match tokio::fs::read_to_string(&index_path).await {
Ok(html) => Ok::<_, Infallible>(html_response(html)),
Err(_) => Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
),
}
}
}));
router.fallback_service(static_service)
}
None => router.fallback(embedded_static_handler),
}
}
fn api_not_found_response(path: &str) -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(
serde_json::json!({"error": "Not found", "path": path}).to_string(),
))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
async fn embedded_static_handler(req: Request<Body>) -> Response {
let path = req.uri().path();
if is_api_path(path) {
return api_not_found_response(path);
}
let asset_path = embedded_asset_path(path);
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) {
return embedded_file_response(&asset_path, file.contents());
}
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
return embedded_file_response("index.html", file.contents());
}
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("embedded dist/web/index.html not found"))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
fn is_api_path(path: &str) -> bool {
path.starts_with("/api/") || path.starts_with("/v1/")
}
fn embedded_asset_path(path: &str) -> String {
let trimmed = path.trim_start_matches('/');
if trimmed.is_empty() || trimmed.ends_with('/') {
format!("{trimmed}index.html")
} else {
trimmed.to_string()
}
}
fn html_response(html: String) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
let content_type = mime_guess::from_path(path)
.first_or_octet_stream()
.essence_str()
.to_string();
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.body(Body::from(bytes.to_vec()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
if path.starts_with("assets/") || path.contains(".") && !path.ends_with("index.html") {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response
}
async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
(
StatusCode::GONE,
Json(json!({
"error": {
"message": "内置 Docker ComfyUI 工作台已移除。请配置外部 comfyui.base_url 后使用 /api/comfyui/proxy,或打开 /workflows 使用 ComfyUI 风格工作流画布。",
"type": "ComfyUIWorkbenchRemoved"
},
"redirect": "/workflows"
})),
)
}
+584 -29
View File
@@ -1,12 +1,13 @@
use crate::{
auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
body::Body,
extract::{Multipart, Query, State},
extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::{get, post},
@@ -19,6 +20,11 @@ use std::time::Duration;
const TEMPLATE_GALLERY_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
const TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
#[derive(Debug, Clone)]
struct PictureAgentAuth {
@@ -33,6 +39,13 @@ pub fn routes() -> Router<AppState> {
.route("/api/picture-agent/status", get(status_handler))
.route("/api/picture-agent/models", get(models_handler))
.route("/api/picture-agent/templates", get(templates_handler))
.route(
"/api/picture-agent/jobs",
get(list_jobs).post(create_generation_job),
)
.route("/api/picture-agent/jobs/clear", post(clear_jobs))
.route("/api/picture-agent/jobs/{id}", get(get_job))
.route("/api/picture-agent/jobs/{id}/cancel", post(cancel_job))
.route(
"/api/picture-agent/images/generations",
post(images_generations),
@@ -54,6 +67,11 @@ struct GalleryItem {
image_host_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JobListQuery {
limit: Option<i64>,
}
fn local_v1_base(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
@@ -149,6 +167,52 @@ fn is_exact_model_pattern(value: &str) -> bool {
&& !normalized.contains('?')
}
fn is_picture_model_pattern(value: &str) -> bool {
if !is_exact_model_pattern(value) {
return false;
}
let normalized = value.trim().to_ascii_lowercase();
let image_keywords = [
"gpt-image",
"dall-e",
"dalle",
"midjourney",
"recraft",
"kolors",
"cogview",
"seedream",
"sdxl",
"stable-diffusion",
"stablediffusion",
"pixart",
"playground",
"firefly",
"imagen",
"flux",
"image",
"img",
"canvas",
"painting",
"draw",
];
if image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
{
return true;
}
let chat_keywords = [
"claude", "chatgpt", "gpt-4", "gpt-3", "gpt-5", "o1", "o3", "o4", "gemini", "deepseek",
"qwen", "llama", "mistral", "command", "kimi", "doubao", "glm", "hunyuan", "spark",
"baichuan", "ernie", "wenxin", "grok",
];
!chat_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
}
async fn status_handler(
State(state): State<AppState>,
headers: HeaderMap,
@@ -183,7 +247,7 @@ async fn models_handler(
let models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_model_pattern(value))
.filter(|value| is_picture_model_pattern(value))
.filter(|value| seen.insert(value.clone()))
.collect();
@@ -199,6 +263,30 @@ async fn models_handler(
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picture_model_filter_keeps_image_models() {
assert!(is_picture_model_pattern("gpt-image-2"));
assert!(is_picture_model_pattern("dall-e-3"));
assert!(is_picture_model_pattern("flux-schnell"));
assert!(is_picture_model_pattern("replicate/recraft-v3"));
assert!(is_picture_model_pattern("google/imagen-3"));
assert!(is_picture_model_pattern("midjourney"));
}
#[test]
fn picture_model_filter_rejects_chat_models() {
assert!(!is_picture_model_pattern("gpt-4o"));
assert!(!is_picture_model_pattern("claude-3.7-sonnet"));
assert!(!is_picture_model_pattern("gemini-2.5-pro"));
assert!(!is_picture_model_pattern("deepseek-chat"));
assert!(!is_picture_model_pattern("qwen-plus"));
}
}
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
let clean = prompt
.split_whitespace()
@@ -255,8 +343,8 @@ async fn templates_handler(
Query(query): Query<TemplateQuery>,
) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(48).clamp(1, 96);
let items = proxy_client()
let limit = query.limit.unwrap_or(144).clamp(1, 240);
let items = template_client()
.get(TEMPLATE_GALLERY_URL)
.send()
.await
@@ -311,9 +399,20 @@ async fn templates_handler(
})))
}
fn proxy_client() -> Client {
fn template_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(180))
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| Client::new())
}
fn picture_generation_client() -> Client {
picture_generation_client_with_timeout(PICTURE_GENERATION_TIMEOUT_SECS)
}
fn picture_generation_client_with_timeout(timeout_secs: u64) -> Client {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.unwrap_or_else(|_| Client::new())
}
@@ -336,6 +435,473 @@ async fn archive_picture_agent_response(
.await;
}
async fn execute_generation_payload(
state: &AppState,
auth: &PictureAgentAuth,
body: Value,
) -> AppResult<(Value, Option<String>, Option<String>)> {
execute_generation_payload_with_timeout(state, auth, body, PICTURE_GENERATION_TIMEOUT_SECS)
.await
}
fn classify_upstream_failure(status: Option<StatusCode>, text: &str) -> String {
let status_text = status
.map(|s| format!("{s}"))
.unwrap_or_else(|| "unknown".to_string());
let lower = text.to_ascii_lowercase();
if lower.contains("524") || lower.contains("timeout") {
"图片上游超时,请稍后重试或缩短提示词".to_string()
} else if lower.contains("502") || lower.contains("bad gateway") {
"图片上游网关异常,请稍后重试".to_string()
} else if lower.contains("503") || lower.contains("service unavailable") {
"图片上游暂时不可用,请稍后重试".to_string()
} else if status.is_some_and(|s| s == StatusCode::GATEWAY_TIMEOUT) {
"图片上游请求超时,请稍后重试".to_string()
} else {
format!("Upstream returned {status_text}: {text}")
}
}
async fn execute_generation_payload_with_timeout(
state: &AppState,
auth: &PictureAgentAuth,
body: Value,
timeout_secs: u64,
) -> AppResult<(Value, Option<String>, Option<String>)> {
let prompt = body
.get("prompt")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let model = body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let requested_model = model
.clone()
.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
let response = picture_generation_client_with_timeout(timeout_secs)
.post(format!("{}/images/generations", local_v1_base(state)))
.bearer_auth(auth.outbound_key.clone())
.json(&body)
.send()
.await
.map_err(|err| {
let hint = if err.is_timeout() {
format!(
"图片上游在 {} 分钟内没有返回,请稍后重试、缩短提示词或切换图片模型通道",
PICTURE_GENERATION_TIMEOUT_SECS / 60
)
} else if err.is_connect() {
"无法连接本地 BoosAPI 图片路由,请确认后端服务仍在运行".to_string()
} else {
err.to_string()
};
AppError::UpstreamError(hint)
})?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
if !status.is_success() {
return Err(AppError::UpstreamError(classify_upstream_failure(
Some(status),
&text,
)));
}
let payload: Value = serde_json::from_str(&text).map_err(|err| {
AppError::UpstreamError(format!(
"Invalid JSON from upstream: {err}; body: {}",
text.chars().take(500).collect::<String>()
))
})?;
Ok((payload, prompt, Some(requested_model)))
}
async fn mark_stale_picture_jobs_failed(state: &AppState) -> AppResult<()> {
sqlx::query(
"UPDATE background_tasks
SET status = 'failed',
message = '生图任务已中断',
error = '后台任务长时间没有进展,可能是后端重启、上游超时或图片通道无响应。请重新提交。',
updated_at = datetime('now'),
finished_at = datetime('now')
WHERE task_type = ?
AND status IN ('pending', 'running')
AND datetime(COALESCE(updated_at, created_at)) < datetime('now', ?)",
)
.bind(TASK_TYPE_PICTURE_GENERATION)
.bind(format!("-{STALE_RUNNING_JOB_MINUTES} minutes"))
.execute(&*state.db)
.await?;
Ok(())
}
fn job_visible_to_auth(task: &BackgroundTask, auth: &PictureAgentAuth) -> bool {
if auth.role == "admin" && auth.user_id.is_none() {
return true;
}
let Some(raw) = task.result_json.as_deref() else {
return false;
};
let Ok(value) = serde_json::from_str::<Value>(raw) else {
return false;
};
value.get("userId").and_then(Value::as_i64) == auth.user_id
}
fn task_to_json(task: BackgroundTask) -> Value {
let result = task
.result_json
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.unwrap_or_else(|| json!({}));
json!({
"id": task.id,
"type": task.task_type,
"title": task.title,
"status": task.status,
"message": task.message,
"error": task.error,
"progress": task.progress.unwrap_or(0),
"progressTotal": task.progress_total.unwrap_or(4),
"result": result,
"createdAt": task.created_at,
"updatedAt": task.updated_at,
"startedAt": task.started_at,
"finishedAt": task.finished_at,
})
}
fn is_terminal_job_status(status: &str) -> bool {
matches!(status, "succeeded" | "failed" | "cancelled")
}
async fn load_picture_job(state: &AppState, id: &str, auth: &PictureAgentAuth) -> AppResult<Value> {
mark_stale_picture_jobs_failed(state).await?;
let task: BackgroundTask =
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
.bind(id)
.bind(TASK_TYPE_PICTURE_GENERATION)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("Picture job not found".into()))?;
if !job_visible_to_auth(&task, auth) {
return Err(AppError::NotFound("Picture job not found".into()));
}
Ok(task_to_json(task))
}
async fn update_job_progress(
state: &AppState,
id: &str,
progress: i64,
message: &str,
) -> AppResult<()> {
sqlx::query(
"UPDATE background_tasks
SET progress = ?, message = ?, updated_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(progress)
.bind(message)
.bind(id)
.execute(&*state.db)
.await?;
Ok(())
}
async fn is_job_cancelled(state: &AppState, id: &str) -> AppResult<bool> {
let status: Option<String> =
sqlx::query_scalar("SELECT status FROM background_tasks WHERE id = ? LIMIT 1")
.bind(id)
.fetch_optional(&*state.db)
.await?;
Ok(status.as_deref() == Some("cancelled"))
}
async fn create_generation_job(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
let prompt = body
.get("prompt")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let model = body
.get("model")
.and_then(Value::as_str)
.unwrap_or("gpt-image-2")
.trim()
.to_string();
if prompt.is_empty() {
return Err(AppError::BadRequest("prompt is required".into()));
}
if model.is_empty() {
return Err(AppError::BadRequest("model is required".into()));
}
let task_id = format!("pic_{}", uuid::Uuid::new_v4().simple());
let title = prompt.chars().take(30).collect::<String>();
let initial_result = json!({
"userId": auth.user_id,
"role": auth.role,
"keyName": auth.key_name,
"prompt": prompt,
"model": model,
"mode": "generate",
"request": body,
});
sqlx::query(
"INSERT INTO background_tasks
(id, task_type, title, status, message, dedupe_key, result_json, progress, progress_total, started_at)
VALUES (?, ?, ?, 'running', '任务已提交,等待路由', ?, ?, 1, 4, datetime('now'))",
)
.bind(&task_id)
.bind(TASK_TYPE_PICTURE_GENERATION)
.bind(if title.trim().is_empty() { "生图任务" } else { &title })
.bind(format!("picture-agent:{}", auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())))
.bind(initial_result.to_string())
.execute(&*state.db)
.await?;
let state_for_task = state.clone();
let task_id_for_spawn = task_id.clone();
let auth_for_task = auth.clone();
tokio::spawn(async move {
let result = async {
update_job_progress(
&state_for_task,
&task_id_for_spawn,
2,
"本地图片路由已确认,正在请求上游",
)
.await?;
let generation_result =
execute_generation_payload(&state_for_task, &auth_for_task, body.clone()).await;
let (payload, prompt, model) = match generation_result {
Ok(value) => value,
Err(AppError::UpstreamError(message))
if message.contains("超时") || message.contains("timeout") =>
{
update_job_progress(
&state_for_task,
&task_id_for_spawn,
2,
"上游超时,正在自动重试一次",
)
.await?;
execute_generation_payload_with_timeout(
&state_for_task,
&auth_for_task,
body,
PICTURE_GENERATION_RETRY_SECS,
)
.await?
}
Err(err) => return Err(err),
};
if is_job_cancelled(&state_for_task, &task_id_for_spawn).await? {
return Ok(());
}
update_job_progress(
&state_for_task,
&task_id_for_spawn,
3,
"图片已返回,正在归档到工作空间",
)
.await?;
archive_picture_agent_response(
&state_for_task,
&auth_for_task,
prompt.clone(),
model.clone(),
&payload,
)
.await;
let urls = payload
.get("data")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let final_result = json!({
"userId": auth_for_task.user_id,
"role": auth_for_task.role,
"keyName": auth_for_task.key_name,
"prompt": prompt,
"model": model,
"mode": "generate",
"response": payload,
"images": urls,
});
sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = '生图完成', result_json = ?, progress = 4,
updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(final_result.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await?;
Ok::<(), AppError>(())
}
.await;
if let Err(err) = result {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', message = '生图失败', error = ?, updated_at = datetime('now'), finished_at = datetime('now')
WHERE id = ? AND status IN ('pending', 'running')",
)
.bind(err.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await;
}
});
let task = load_picture_job(&state, &task_id, &auth).await?;
Ok(Json(json!({ "success": true, "job": task })))
}
async fn cancel_job(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
let task: BackgroundTask =
sqlx::query_as("SELECT * FROM background_tasks WHERE id = ? AND task_type = ? LIMIT 1")
.bind(&id)
.bind(TASK_TYPE_PICTURE_GENERATION)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("Picture job not found".into()))?;
if !job_visible_to_auth(&task, &auth) {
return Err(AppError::NotFound("Picture job not found".into()));
}
if task.status != "pending" && task.status != "running" {
return Ok(Json(json!({
"success": true,
"job": task_to_json(task),
})));
}
sqlx::query(
"UPDATE background_tasks
SET status = 'cancelled',
message = '已取消',
error = NULL,
updated_at = datetime('now'),
finished_at = datetime('now')
WHERE id = ? AND task_type = ? AND status IN ('pending', 'running')",
)
.bind(&id)
.bind(TASK_TYPE_PICTURE_GENERATION)
.execute(&*state.db)
.await?;
let job = load_picture_job(&state, &id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn list_jobs(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<JobListQuery>,
) -> AppResult<Json<Value>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
mark_stale_picture_jobs_failed(&state).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT ?",
)
.bind(TASK_TYPE_PICTURE_GENERATION)
.bind(limit * 3)
.fetch_all(&*state.db)
.await?;
let jobs: Vec<Value> = tasks
.into_iter()
.filter(|task| job_visible_to_auth(task, &auth))
.take(limit as usize)
.map(task_to_json)
.collect();
Ok(Json(json!({ "success": true, "jobs": jobs })))
}
async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
mark_stale_picture_jobs_failed(&state).await?;
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC",
)
.bind(TASK_TYPE_PICTURE_GENERATION)
.fetch_all(&*state.db)
.await?;
let mut cleared = 0_i64;
for task in tasks {
if !job_visible_to_auth(&task, &auth) || !is_terminal_job_status(&task.status) {
continue;
}
let affected = sqlx::query(
"DELETE FROM background_tasks
WHERE id = ? AND task_type = ?",
)
.bind(&task.id)
.bind(TASK_TYPE_PICTURE_GENERATION)
.execute(&*state.db)
.await?
.rows_affected();
cleared += i64::try_from(affected).unwrap_or(0);
}
Ok(Json(json!({
"success": true,
"cleared": cleared,
})))
}
async fn get_job(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
let job = load_picture_job(&state, &id, &auth).await?;
Ok(Json(json!({ "success": true, "job": job })))
}
async fn json_response_and_archive(
state: &AppState,
auth: &PictureAgentAuth,
payload: Value,
prompt: Option<String>,
model: Option<String>,
) -> Response<Body> {
archive_picture_agent_response(state, auth, prompt, model, &payload).await;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(payload.to_string()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
async fn upstream_json_response_to_axum_and_archive(
state: &AppState,
auth: &PictureAgentAuth,
@@ -375,27 +941,8 @@ async fn images_generations(
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
let auth = resolve_picture_agent_auth(&headers, &state).await?;
let prompt = body
.get("prompt")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let model = body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let url = format!("{}/images/generations", local_v1_base(&state));
let response = proxy_client()
.post(url)
.bearer_auth(auth.outbound_key.clone())
.json(&body)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
upstream_json_response_to_axum_and_archive(&state, &auth, response, prompt, model).await
let (payload, prompt, model) = execute_generation_payload(&state, &auth, body).await?;
Ok(json_response_and_archive(&state, &auth, payload, prompt, model).await)
}
async fn images_edits(
@@ -455,12 +1002,20 @@ async fn images_edits(
}
let url = format!("{}/images/edits", local_v1_base(&state));
let response = proxy_client()
let response = picture_generation_client()
.post(url)
.bearer_auth(auth.outbound_key.clone())
.multipart(form)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
upstream_json_response_to_axum_and_archive(&state, &auth, response, prompt, model).await
let response = upstream_json_response_to_axum_and_archive(
&state,
&auth,
response,
prompt.clone(),
model.clone(),
)
.await?;
Ok(response)
}
+162 -25
View File
@@ -103,10 +103,12 @@ async fn messages_handler(
let model = canonical.model.clone();
if canonical.stream {
let chunks = execute_messages_stream_with_conductor(state, key_id, model, canonical).await?;
let sse = Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
}));
let chunks =
execute_messages_stream_with_conductor(state, key_id, model, canonical).await?;
let sse =
Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
}));
Ok(sse.into_response())
} else {
let result = execute_messages_with_conductor(state, key_id, model, canonical).await?;
@@ -371,10 +373,53 @@ async fn images_generations(
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_image_generation(state.clone(), route, body).await?;
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
Ok(Json(result))
let started_at = Instant::now();
let selected = TokenRouter::new(state.db.clone())
.resolve_selected(&model)
.await?;
let body = body_with_selected_model(body, &selected);
let result = executors::execute_image_generation(
state.clone(),
selected_channel_to_route(selected.clone()),
body,
)
.await;
match result {
Ok(payload) => {
let cost = estimate_request_cost(&selected, 0, 0);
record_proxy_log_success(
&state,
key_id,
&selected,
&model,
Some(elapsed_ms_since(started_at)),
0,
0,
cost,
1,
false,
)
.await;
record_billing(&state, key_id, &model, 0, 0, cost).await;
Ok(Json(payload))
}
Err(err) => {
record_proxy_log_failure(
&state,
key_id,
Some(&selected),
&model,
upstream_status_from_error(&err),
Some(elapsed_ms_since(started_at)),
Some(&err.to_string()),
1,
false,
)
.await;
Err(err)
}
}
}
/// POST /v1/images/edits
@@ -452,21 +497,63 @@ async fn images_edits(
}
let model = model.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
let route = resolve_route(&state, &model).await?;
let started_at = Instant::now();
let selected = TokenRouter::new(state.db.clone())
.resolve_selected(&model)
.await?;
// Override the multipart `model` field with the conductor-resolved
// upstream model name (route may map "dall-e-3" → upstream-specific id).
if route.model != model {
if selected.actual_model != model {
for field in fields.iter_mut() {
if field.name == "model" && field.bytes.is_none() {
field.text = Some(route.model.clone());
field.text = Some(selected.actual_model.clone());
}
}
}
let result = executors::execute_image_edit(state.clone(), route, fields).await?;
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
Ok(Json(result))
let result = executors::execute_image_edit(
state.clone(),
selected_channel_to_route(selected.clone()),
fields,
)
.await;
match result {
Ok(payload) => {
let cost = estimate_request_cost(&selected, 0, 0);
record_proxy_log_success(
&state,
key_id,
&selected,
&model,
Some(elapsed_ms_since(started_at)),
0,
0,
cost,
1,
false,
)
.await;
record_billing(&state, key_id, &model, 0, 0, cost).await;
Ok(Json(payload))
}
Err(err) => {
record_proxy_log_failure(
&state,
key_id,
Some(&selected),
&model,
upstream_status_from_error(&err),
Some(elapsed_ms_since(started_at)),
Some(&err.to_string()),
1,
false,
)
.await;
Err(err)
}
}
}
/// GET /v1/models
@@ -564,6 +651,23 @@ async fn record_billing(
}
}
fn estimate_request_cost(
selected: &SelectedChannel,
prompt_tokens: i64,
completion_tokens: i64,
) -> f64 {
let unit_cost = selected.account_unit_cost.unwrap_or(0.0).max(0.0);
if unit_cost <= 0.0 {
return 0.0;
}
let total_tokens = (prompt_tokens.max(0) + completion_tokens.max(0)) as f64;
if total_tokens <= 0.0 {
unit_cost
} else {
total_tokens / 1_000_000.0 * unit_cost
}
}
async fn record_proxy_log_success(
state: &AppState,
key_id: Option<i64>,
@@ -699,6 +803,31 @@ fn body_with_selected_model(mut body: Value, selected: &SelectedChannel) -> Valu
body
}
fn ensure_stream_usage(mut body: Value) -> Value {
if let Some(object) = body.as_object_mut() {
match object.get_mut("stream_options") {
Some(options) => {
if let Some(map) = options.as_object_mut() {
map.entry("include_usage".to_string())
.or_insert_with(|| Value::Bool(true));
} else {
object.insert(
"stream_options".to_string(),
serde_json::json!({ "include_usage": true }),
);
}
}
None => {
object.insert(
"stream_options".to_string(),
serde_json::json!({ "include_usage": true }),
);
}
}
}
body
}
fn build_conductor(state: &AppState) -> ProxyConductor {
let db = state.db.clone();
let select_db = db.clone();
@@ -797,6 +926,7 @@ async fn execute_messages_with_conductor(
attempts,
} => {
let (prompt, completion) = extract_usage(&response);
let cost = estimate_request_cost(&selected, prompt, completion);
record_proxy_log_success(
&billing_state,
key_id,
@@ -805,7 +935,7 @@ async fn execute_messages_with_conductor(
Some(elapsed_ms_since(started_at)),
prompt,
completion,
0.0,
cost,
attempts,
false,
)
@@ -816,7 +946,7 @@ async fn execute_messages_with_conductor(
&billing_model,
prompt,
completion,
0.0,
cost,
)
.await;
Ok(response)
@@ -938,6 +1068,7 @@ async fn execute_messages_stream_with_conductor(
}
};
let cost = estimate_request_cost(&selected, 0, 0);
record_proxy_log_success(
&billing_state,
key_id,
@@ -946,12 +1077,12 @@ async fn execute_messages_stream_with_conductor(
Some(elapsed_ms_since(started_at)),
0,
0,
0.0,
cost,
attempts,
true,
)
.await;
record_billing(&billing_state, key_id, &billing_model, 0, 0, 0.0).await;
record_billing(&billing_state, key_id, &billing_model, 0, 0, cost).await;
Ok(chunks)
}
@@ -1005,6 +1136,7 @@ async fn execute_chat_completions_with_conductor(
attempts,
} => {
let (prompt, completion) = extract_usage(&response);
let cost = estimate_request_cost(&selected, prompt, completion);
record_proxy_log_success(
&billing_state,
key_id,
@@ -1013,7 +1145,7 @@ async fn execute_chat_completions_with_conductor(
Some(elapsed_ms_since(started_at)),
prompt,
completion,
0.0,
cost,
attempts,
false,
)
@@ -1024,7 +1156,7 @@ async fn execute_chat_completions_with_conductor(
&billing_model,
prompt,
completion,
0.0,
cost,
)
.await;
Ok(response)
@@ -1073,7 +1205,8 @@ async fn execute_chat_completions_stream_with_conductor(
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let state = state.clone();
let body = body_with_selected_model(body.clone(), &ctx.selected);
let body =
ensure_stream_usage(body_with_selected_model(body.clone(), &ctx.selected));
async move {
match executors::execute_chat_completions_stream(
state,
@@ -1160,8 +1293,11 @@ async fn execute_chat_completions_stream_with_conductor(
.iter()
.rev()
.find_map(|chunk| {
let payload = chunk.strip_prefix("data: ")?;
if payload.trim() == "[DONE]" {
let payload = chunk
.strip_prefix("data: ")
.or_else(|| chunk.strip_prefix("data:"))?
.trim();
if payload == "[DONE]" {
return None;
}
let value: Value = serde_json::from_str(payload).ok()?;
@@ -1173,6 +1309,7 @@ async fn execute_chat_completions_stream_with_conductor(
}
})
.unwrap_or((0, 0));
let cost = estimate_request_cost(&selected, prompt, completion);
record_proxy_log_success(
&billing_state,
key_id,
@@ -1181,7 +1318,7 @@ async fn execute_chat_completions_stream_with_conductor(
Some(elapsed_ms_since(started_at)),
prompt,
completion,
0.0,
cost,
attempts,
true,
)
@@ -1192,7 +1329,7 @@ async fn execute_chat_completions_stream_with_conductor(
&billing_model,
prompt,
completion,
0.0,
cost,
)
.await;
Ok(chunks)
+15 -13
View File
@@ -49,13 +49,13 @@ pub fn routes() -> Router<AppState> {
put(update_announcement).delete(delete_announcement),
)
.route("/api/site-announcements/{id}/read", post(mark_read_admin))
.route("/api/site-announcements/read-all", post(mark_all_read_admin))
.route(
"/api/site-announcements/read-all",
post(mark_all_read_admin),
)
.route("/api/user/announcements", get(list_user_announcements))
.route("/api/user/announcements/{id}/read", post(mark_read_user))
.route(
"/api/user/announcements/read-all",
post(mark_all_read_user),
)
.route("/api/user/announcements/read-all", post(mark_all_read_user))
}
async fn list_admin_announcements(
@@ -95,11 +95,7 @@ async fn list_admin_announcements(
if let Some(status) = status {
query = query.bind(status);
}
let rows = query
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?;
let rows = query.bind(limit).bind(offset).fetch_all(&*state.db).await?;
Ok(Json(Value::Array(
rows.into_iter().map(|row| row.into_admin_json()).collect(),
@@ -135,7 +131,9 @@ async fn list_user_announcements(
sql.push_str(" AND r.read_at IS NULL");
}
sql.push_str(" ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC LIMIT ? OFFSET ?");
sql.push_str(
" ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC LIMIT ? OFFSET ?",
);
let rows: Vec<UserAnnouncementRow> = sqlx::query_as(&sql)
.bind(user_id)
@@ -412,10 +410,14 @@ fn normalize_payload(payload: AnnouncementPayload) -> AppResult<NormalizedAnnoun
let title = payload.title.trim().to_string();
let content = payload.content.trim().to_string();
if title.is_empty() {
return Err(crate::error::AppError::BadRequest("title is required".into()));
return Err(crate::error::AppError::BadRequest(
"title is required".into(),
));
}
if content.is_empty() {
return Err(crate::error::AppError::BadRequest("content is required".into()));
return Err(crate::error::AppError::BadRequest(
"content is required".into(),
));
}
let level = payload
+371 -93
View File
@@ -1,5 +1,6 @@
use crate::{
auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask,
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
AppState,
@@ -12,6 +13,7 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::Row;
pub fn routes() -> Router<AppState> {
Router::new()
@@ -20,6 +22,305 @@ pub fn routes() -> Router<AppState> {
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
.route("/api/user/logs", get(get_user_logs))
.route("/api/user/summary", get(get_user_summary))
.route(
"/api/user/models/marketplace",
get(get_user_models_marketplace),
)
}
async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT *
FROM background_tasks
WHERE task_type = ?
AND json_extract(result_json, '$.userId') = ?
ORDER BY created_at DESC, id DESC",
)
.bind("picture-agent-generation")
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(tasks
.into_iter()
.map(|task| {
let result = task
.result_json
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.unwrap_or_else(|| json!({}));
let status = match task.status.as_str() {
"succeeded" => "success",
"failed" => "failed",
"cancelled" => "cancelled",
"pending" => "pending",
_ => "running",
};
json!({
"id": task.id,
"route_id": Value::Null,
"channel_id": Value::Null,
"account_id": Value::Null,
"downstream_api_key_id": Value::Null,
"site_id": Value::Null,
"username": Value::Null,
"site_name": Value::Null,
"site_url": Value::Null,
"downstream_key_name": result.get("keyName").cloned().unwrap_or(Value::Null),
"downstream_key_group_name": Value::Null,
"downstream_key_tags": Value::Null,
"model_requested": result.get("model").cloned().unwrap_or(Value::Null),
"model_actual": result.get("model").cloned().unwrap_or(Value::Null),
"status": status,
"http_status": if status == "success" { 200 } else if status == "running" || status == "pending" { 202 } else { 500 },
"latency_ms": Value::Null,
"first_byte_latency_ms": Value::Null,
"prompt_tokens": Value::Null,
"completion_tokens": Value::Null,
"total_tokens": Value::Null,
"estimated_cost": 0.0,
"error_message": task.error.clone(),
"retry_count": 0,
"is_stream": false,
"client_family": "picture-agent",
"client_app_id": "picture-agent",
"client_app_name": "Picture Agent",
"client_confidence": "high",
"created_at": task.created_at,
"source": "picture-agent",
"task_type": task.task_type,
"title": task.title,
"message": task.message,
"error": task.error,
"progress": task.progress,
"progress_total": task.progress_total,
"status_label": status,
"result": result,
})
})
.collect())
}
async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
let rows = sqlx::query(
r#"
SELECT
l.id,
l.model,
l.cost,
l.prompt_tokens,
l.completion_tokens,
l.total_tokens,
l.created_at,
k.name AS downstream_key_name
FROM usage_logs l
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
WHERE k.user_id = ?
ORDER BY l.created_at DESC, l.id DESC
LIMIT 5000
"#,
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(rows
.into_iter()
.map(|row| {
let model = row
.try_get::<Option<String>, _>("model")
.ok()
.flatten()
.unwrap_or_default();
let model_trimmed = model.trim().to_string();
let source_model = model_trimmed
.split(':')
.next_back()
.unwrap_or(&model_trimmed)
.trim()
.to_string();
json!({
"id": format!("usage-{}", row.try_get::<i64, _>("id").unwrap_or_default()),
"route_id": Value::Null,
"channel_id": Value::Null,
"account_id": Value::Null,
"downstream_api_key_id": Value::Null,
"site_id": Value::Null,
"username": Value::Null,
"site_name": Value::Null,
"site_url": Value::Null,
"downstream_key_name": row.try_get::<Option<String>, _>("downstream_key_name").ok().flatten(),
"downstream_key_group_name": Value::Null,
"downstream_key_tags": Value::Null,
"model_requested": source_model,
"model_actual": source_model,
"status": "success",
"http_status": 200,
"latency_ms": Value::Null,
"first_byte_latency_ms": Value::Null,
"prompt_tokens": row.try_get::<Option<i64>, _>("prompt_tokens").ok().flatten(),
"completion_tokens": row.try_get::<Option<i64>, _>("completion_tokens").ok().flatten(),
"total_tokens": row.try_get::<Option<i64>, _>("total_tokens").ok().flatten(),
"estimated_cost": row.try_get::<Option<f64>, _>("cost").ok().flatten().unwrap_or(0.0),
"error_message": Value::Null,
"retry_count": 0,
"is_stream": false,
"client_family": "picture-agent",
"client_app_id": "picture-agent",
"client_app_name": "Picture Agent",
"client_confidence": "high",
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
"source": "usage-logs",
"task_type": "usage-log",
"title": "生图助手",
"message": model_trimmed,
"error": Value::Null,
"progress": Value::Null,
"progress_total": Value::Null,
})
})
.collect())
}
fn user_log_text(item: &Value) -> String {
[
item.get("model_requested").and_then(Value::as_str),
item.get("model_actual").and_then(Value::as_str),
item.get("downstream_key_name").and_then(Value::as_str),
item.get("site_name").and_then(Value::as_str),
item.get("site_url").and_then(Value::as_str),
item.get("error_message").and_then(Value::as_str),
item.get("message").and_then(Value::as_str),
item.get("title").and_then(Value::as_str),
item.get("source").and_then(Value::as_str),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn user_log_status(item: &Value) -> String {
item.get("status")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase()
}
fn user_log_created_at(item: &Value) -> String {
item.get("created_at")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn user_log_cost(item: &Value) -> f64 {
item.get("estimated_cost")
.and_then(Value::as_f64)
.unwrap_or(0.0)
}
fn user_log_tokens(item: &Value) -> i64 {
item.get("total_tokens")
.and_then(Value::as_i64)
.unwrap_or(0)
}
fn matches_user_log_filters(
item: &Value,
q: Option<&str>,
status: Option<&str>,
date_from: Option<&str>,
date_to: Option<&str>,
) -> bool {
if let Some(q) = q {
let q = q.to_ascii_lowercase();
if !user_log_text(item).contains(&q) {
return false;
}
}
if let Some(status) = status {
let item_status = user_log_status(item);
let normalized = status.to_ascii_lowercase();
if normalized == "success" {
if item_status != "success" {
return false;
}
} else if normalized == "failed" {
if !matches!(item_status.as_str(), "failed" | "cancelled") {
return false;
}
} else if item_status != normalized {
return false;
}
}
let created_at = user_log_created_at(item);
if let Some(date_from) = date_from {
if !created_at.is_empty() && created_at.as_str() < date_from {
return false;
}
}
if let Some(date_to) = date_to {
if !created_at.is_empty() && created_at.as_str() > date_to {
return false;
}
}
true
}
fn sort_user_logs_desc(items: &mut [Value]) {
items.sort_by(|a, b| {
let created_cmp = user_log_created_at(b).cmp(&user_log_created_at(a));
if created_cmp != std::cmp::Ordering::Equal {
return created_cmp;
}
let id_a = a.get("id").map(Value::to_string).unwrap_or_default();
let id_b = b.get("id").map(Value::to_string).unwrap_or_default();
id_b.cmp(&id_a)
});
}
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
k.name AS downstream_key_name,
k.group_name AS downstream_key_group_name,
k.tags AS downstream_key_tags
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE k.user_id = ?
ORDER BY l.created_at DESC
LIMIT 5000
"#,
)
.bind(user_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
.collect();
items.extend(user_usage_log_items(state, user_id).await?);
items.extend(user_picture_log_items(state, user_id).await?);
sort_user_logs_desc(&mut items);
Ok(items)
}
#[derive(Debug, Deserialize)]
@@ -190,98 +491,40 @@ async fn get_user_logs(
let offset = params.offset.unwrap_or((page - 1) * page_size).max(0);
let q = params.q.as_deref().map(str::trim).filter(|s| !s.is_empty());
let q_like = q.map(|s| format!("%{s}%"));
let status = params
.status
.as_deref()
.filter(|s| !s.is_empty() && *s != "all");
let date_from = params.date_from.as_deref().filter(|s| !s.is_empty());
let date_to = params.date_to.as_deref().filter(|s| !s.is_empty());
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
k.name AS downstream_key_name,
k.group_name AS downstream_key_group_name,
k.tags AS downstream_key_tags
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE k.user_id = ?
AND (? IS NULL OR l.model_requested LIKE ?)
AND (? IS NULL OR l.status = ?)
AND (? IS NULL OR l.created_at >= ?)
AND (? IS NULL OR l.created_at <= ?)
ORDER BY l.created_at DESC
LIMIT ? OFFSET ?
"#,
)
.bind(user_id)
.bind(q_like.as_deref())
.bind(q_like.as_deref())
.bind(status)
.bind(status)
.bind(date_from)
.bind(date_from)
.bind(date_to)
.bind(date_to)
.bind(page_size)
.bind(offset)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
.collect();
let (total, success_count, total_cost, total_tokens): (i64, i64, f64, i64) = sqlx::query_as(
r#"
SELECT
COUNT(*),
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
COALESCE(SUM(l.estimated_cost), 0.0),
COALESCE(SUM(l.total_tokens), 0)
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
WHERE k.user_id = ?
AND (? IS NULL OR l.model_requested LIKE ?)
AND (? IS NULL OR l.status = ?)
AND (? IS NULL OR l.created_at >= ?)
AND (? IS NULL OR l.created_at <= ?)
"#,
)
.bind(user_id)
.bind(q_like.as_deref())
.bind(q_like.as_deref())
.bind(status)
.bind(status)
.bind(date_from)
.bind(date_from)
.bind(date_to)
.bind(date_to)
.fetch_one(&*state.db)
.await?;
let items = load_user_log_items(&state, user_id).await?;
let filtered_items: Vec<Value> = items
.into_iter()
.filter(|item| matches_user_log_filters(item, q, status, date_from, date_to))
.collect();
let total = filtered_items.len() as i64;
let success_count = filtered_items
.iter()
.filter(|item| user_log_status(item).as_str() == "success")
.count() as i64;
let failed_count = total - success_count;
let total_cost = filtered_items.iter().map(user_log_cost).sum::<f64>();
let total_tokens = filtered_items.iter().map(user_log_tokens).sum::<i64>();
let paged_items = filtered_items
.into_iter()
.skip(offset as usize)
.take(page_size as usize)
.collect::<Vec<_>>();
Ok(Json(json!({
"items": items,
"items": paged_items,
"total": total,
"page": page,
"page_size": page_size,
"summary": {
"total": total,
"success": success_count,
"failed": total - success_count,
"failed": failed_count,
"total_cost": total_cost,
"total_tokens": total_tokens,
}
@@ -293,22 +536,57 @@ async fn get_user_summary(
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let stats: (i64, i64, f64) = sqlx::query_as(
"SELECT COUNT(*),
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
COALESCE(SUM(l.estimated_cost), 0.0)
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
WHERE k.user_id = ?",
)
.bind(user_id)
.fetch_one(&*state.db)
.await?;
let items = load_user_log_items(&state, user_id).await?;
let total = items.len() as i64;
let success_count = items
.iter()
.filter(|item| user_log_status(item).as_str() == "success")
.count() as i64;
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
Ok(Json(json!({
"totalRequests": stats.0,
"successRequests": stats.1,
"totalCost": stats.2,
"totalRequests": total,
"successRequests": success_count,
"totalCost": total_cost,
})))
}
async fn get_user_models_marketplace(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<crate::routes::admin::models_marketplace::MarketplaceQuery>,
) -> AppResult<Json<Value>> {
let _user_id = current_user_id(&headers, &state)?;
let value = crate::routes::admin::models_marketplace::marketplace_inner(&state.db).await?;
let models = value
.get("models")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|model| {
let brand = model.get("name").and_then(Value::as_str).unwrap_or_default();
json!({
"name": brand,
"accountCount": 0,
"tokenCount": 0,
"avgLatency": model.get("avgLatency").cloned().unwrap_or(Value::Null),
"successRate": model.get("successRate").cloned().unwrap_or(Value::Null),
"description": model.get("description").cloned().unwrap_or(Value::Null),
"tags": model.get("tags").cloned().unwrap_or_else(|| json!([])),
"supportedEndpointTypes": model.get("supportedEndpointTypes").cloned().unwrap_or_else(|| json!([])),
"pricingSources": [],
"accounts": [],
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(Json(json!({
"models": models,
"meta": value.get("meta").cloned().unwrap_or_else(|| json!({})),
"userView": true,
"includePricing": query.include_pricing.is_some(),
})))
}
-785
View File
@@ -1,785 +0,0 @@
//! `/api/video-agent/*` — per-user multi-tenant refactor.
//!
//! Every handler resolves the caller from the JWT in the `Authorization`
//! header (no anonymous access) and, where it makes sense, lazily starts
//! that user's ComfyUI container via [`ComfyUIPool`]. The LLM/image/TTS
//! helpers continue to call BoosAPI's own `/v1/*` loopback — they are
//! independent of ComfyUI and stay shared.
//!
//! Status responses preserve every existing JSON key the frontend reads
//! and add `containerState` so the new full-screen loading overlay can
//! poll until the container is ready.
use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, Response, StatusCode},
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::{get, post},
Json, Router,
};
use futures::stream;
use serde_json::{json, Value};
use std::convert::Infallible;
use std::time::{Duration, Instant};
use tracing::info;
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
services::comfyui_pool::{ContainerState, PoolError},
AppState,
};
const COLD_START_HOLD: Duration = Duration::from_secs(30);
const COLD_START_POLL: Duration = Duration::from_millis(500);
const ADMIN_WORKSPACE_USER_ID: i64 = 0;
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
{
"title": "视频标题",
"characters": [
{
"name": "角色名",
"gender": "男/女",
"age": "年龄描述",
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
"personality": "性格特征"
}
],
"scenes": [
{
"id": 1,
"description": "场景描述",
"environment": "环境详细描述(时间、地点、天气、光线等)",
"characters": ["出现的角色名"],
"action": "场景中发生的动作",
"dialogue": [
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
]
}
],
"style": "整体视觉风格(动画/写实/像素等)"
}
只返回 JSON,不要其他文字。"#;
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
每组提示词应包含:人物姿态、服装、光照、背景。
返回 JSON 格式:
{
"front": "正面提示词",
"back": "背面提示词",
"left": "左侧面提示词",
"right": "右侧面提示词"
}
只返回 JSON。"#;
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
包含:环境、光照、色调、氛围。
返回 JSON:
{
"prompt": "场景生成提示词",
"negative_prompt": "负面提示词"
}
只返回 JSON。"#;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/video-agent/status", get(status))
.route("/api/video-agent/start", post(start_comfyui))
.route("/api/video-agent/stop", post(stop_comfyui))
.route("/api/video-agent/restart", post(restart_comfyui))
.route("/api/video-agent/chat", post(chat))
.route("/api/video-agent/analyze", post(analyze))
.route(
"/api/video-agent/character-prompts",
post(character_prompts),
)
.route(
"/api/video-agent/generate-character",
post(generate_character),
)
.route("/api/video-agent/scene-prompt", post(scene_prompt))
.route("/api/video-agent/generate-scene", post(generate_scene))
.route("/api/video-agent/generate-tts", post(generate_tts))
.route("/api/video-agent/full-workflow", post(full_workflow))
}
// ── Auth ───────────────────────────────────────────────────────────────
/// Resolve the caller workspace from `Authorization: Bearer <token>`.
/// User JWTs map to their real user id; admin/global proxy tokens map to
/// workspace 0 so the admin console can use the same Docker-backed ComfyUI.
fn resolve_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
if auth == state.config.auth.auth_token || auth == state.config.auth.proxy_token {
return Ok(ADMIN_WORKSPACE_USER_ID);
}
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
// ── Pool helpers ───────────────────────────────────────────────────────
/// Map a [`PoolError`] to the appropriate [`AppError`].
fn pool_err_to_app(e: PoolError) -> AppError {
match e {
PoolError::AtCapacity(_) => AppError::ServiceUnavailable("comfyui pool at capacity".into()),
PoolError::StartTimeout(_) => {
AppError::GatewayTimeout("comfyui container start timeout".into())
}
other => AppError::Internal(anyhow::anyhow!(other)),
}
}
/// Lazily start the caller's container and wait for readiness up to 30 s.
/// Returns the host port for the loopback HTTP API.
async fn ensure_container_ready(state: &AppState, user_id: i64) -> AppResult<u16> {
state
.comfyui_pool
.get_or_start(user_id)
.await
.map_err(pool_err_to_app)?;
let deadline = Instant::now() + COLD_START_HOLD;
loop {
match state.comfyui_pool.state(user_id).await {
ContainerState::Ready => break,
ContainerState::Failed | ContainerState::Stopped => {
return Err(AppError::UpstreamError(
"comfyui container failed to start".into(),
));
}
ContainerState::Starting => {
if Instant::now() >= deadline {
return Err(AppError::GatewayTimeout(
"comfyui cold-start timeout".into(),
));
}
tokio::time::sleep(COLD_START_POLL).await;
}
}
}
state.comfyui_pool.touch(user_id).await;
state
.comfyui_pool
.entry(user_id)
.await
.map(|e| e.host_port)
.ok_or_else(|| AppError::UpstreamError("container vanished after ready".into()))
}
/// JSON status response. Preserves the keys the frontend already reads
/// (`status`, `pid`, `uptime_secs`, `mode`, `model_backend`, `base_url`,
/// `version`, `last_error`) and adds `containerState` (task 5.2).
async fn pool_status_json(state: &AppState, user_id: i64) -> Value {
let cs = state.comfyui_pool.state(user_id).await;
let entry = state.comfyui_pool.entry(user_id).await;
let port = entry.as_ref().map(|e| e.host_port);
let last_error = entry.as_ref().and_then(|e| e.last_error.clone());
let status = match cs {
ContainerState::Starting => "starting",
ContainerState::Ready => "running",
ContainerState::Stopped => "stopped",
ContainerState::Failed => "unhealthy",
};
let base_url = port
.map(|p| format!("http://127.0.0.1:{}", p))
.unwrap_or_default();
let model_backend = format!(
"{}/v1",
state
.comfyui_pool
.config()
.boosapi_base
.trim_end_matches("/v1")
.trim_end_matches('/')
);
json!({
"status": status,
"pid": Value::Null,
"uptime_secs": 0,
"mode": "container",
"model_backend": model_backend,
"base_url": base_url,
"version": Value::Null,
"last_error": last_error,
"containerState": cs.as_str(),
})
}
// ── Lifecycle handlers ─────────────────────────────────────────────────
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
Ok(Json(pool_status_json(&state, user_id).await))
}
async fn start_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
// Idempotent: get_or_start returns the existing entry if already up.
state
.comfyui_pool
.get_or_start(user_id)
.await
.map_err(pool_err_to_app)?;
Ok(Json(json!({
"success": true,
"comfyui": pool_status_json(&state, user_id).await,
})))
}
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
state
.comfyui_pool
.stop(user_id)
.await
.map_err(pool_err_to_app)?;
Ok(Json(json!({
"success": true,
"comfyui": pool_status_json(&state, user_id).await,
})))
}
async fn restart_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
// Stop is best-effort: if the container is already gone, that's fine.
let _ = state.comfyui_pool.stop(user_id).await;
state
.comfyui_pool
.get_or_start(user_id)
.await
.map_err(pool_err_to_app)?;
Ok(Json(json!({
"success": true,
"comfyui": pool_status_json(&state, user_id).await,
})))
}
// ── BoosAPI loopback helpers ───────────────────────────────────────────
//
// LLM, image, and TTS calls go to BoosAPI's own `/v1/*` endpoints — they
// are not ComfyUI traffic. We pull the base URL from the same config the
// pool uses so there's exactly one source of truth.
fn boosapi_v1_base(state: &AppState) -> String {
let raw = state
.comfyui_pool
.config()
.boosapi_base
.trim_end_matches('/');
if raw.ends_with("/v1") {
raw.to_string()
} else {
format!("{}/v1", raw)
}
}
async fn chat(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
let user_id = resolve_user_id(&headers, &state)?;
// Lazily warm the container so a subsequent generate-* call doesn't
// pay the full cold start.
let _ = state.comfyui_pool.get_or_start(user_id).await;
state.comfyui_pool.touch(user_id).await;
let prompt = body
.get("message")
.and_then(Value::as_str)
.or_else(|| {
body.get("messages")
.and_then(Value::as_array)
.and_then(|messages| messages.last())
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
})
.unwrap_or("生成一个短视频工作流");
let workflow = build_basic_workflow(prompt);
let events = vec![
json!({
"type": "text",
"content": "已收到需求,正在生成 ComfyUI workflow..."
}),
json!({
"type": "workflow_generated",
"workflow": workflow
}),
json!({
"type": "done"
}),
];
Ok(Sse::new(stream::iter(events.into_iter().map(|event| {
Ok(Event::default().data(event.to_string()))
}))))
}
fn build_basic_workflow(prompt: &str) -> Value {
json!({
"boosapi": {
"kind": "video-agent-workflow",
"version": 1,
"prompt": prompt,
},
"nodes": {},
})
}
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
body.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
body.get(key)
.filter(|v| !v.is_null())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn strip_json_markers(s: &str) -> String {
let mut text = s.trim().to_string();
if text.starts_with("```json") {
text = text.trim_start_matches("```json").to_string();
} else if text.starts_with("```") {
text = text.trim_start_matches("```").to_string();
}
if text.ends_with("```") {
text = text.trim_end_matches("```").to_string();
}
text.trim().to_string()
}
fn parse_llm_json(raw: &str) -> AppResult<Value> {
let cleaned = strip_json_markers(raw);
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"failed to parse LLM JSON response: {}; response: {}",
e,
cleaned
))
})
}
async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", boosapi_v1_base(state));
info!("video-agent calling BoosAPI LLM endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": &state.config.comfyui.agent_model,
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": prompt }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"LLM upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/images/generations", boosapi_v1_base(state));
info!("video-agent calling BoosAPI image endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
"n": 1,
"size": "1024x1024"
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"image upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("data")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("url"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8>> {
let client = reqwest::Client::new();
let url = format!("{}/audio/speech", boosapi_v1_base(state));
info!("video-agent calling BoosAPI TTS endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "tts-1",
"input": text,
"voice": voice
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::Internal(anyhow::anyhow!(
"TTS upstream returned {}: {}",
status,
body
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
Ok(bytes.to_vec())
}
// ── Per-request handlers (5.5) ─────────────────────────────────────────
async fn analyze(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let script = required_str(&body, "script")?;
let result = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
let parsed = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": parsed
})))
}
async fn character_prompts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let character = required_value(&body, "character")?;
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let result = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompts
})))
}
async fn generate_character(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
// Image generation can take a while; ensure the container is warm so
// downstream ComfyUI workflow reuse doesn't pay another cold start.
let _ = ensure_container_ready(&state, user_id).await?;
let prompts = required_value(&body, "prompts")?;
let prompt_obj = prompts
.as_object()
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
let mut results = serde_json::Map::new();
for (angle, prompt_value) in prompt_obj {
let prompt = prompt_value.as_str().ok_or_else(|| {
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
})?;
let url = call_image_gen(&state, prompt).await?;
results.insert(angle.clone(), Value::String(url));
}
state.comfyui_pool.touch(user_id).await;
Ok(Json(json!({
"success": true,
"data": Value::Object(results)
})))
}
async fn scene_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let scene = required_value(&body, "scene")?;
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let result = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
let prompt = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompt
})))
}
async fn generate_scene(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
let _ = ensure_container_ready(&state, user_id).await?;
let prompt = required_str(&body, "prompt")?;
let url = call_image_gen(&state, prompt).await?;
state.comfyui_pool.touch(user_id).await;
Ok(Json(json!({
"success": true,
"data": {
"url": url
}
})))
}
async fn generate_tts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let text = required_str(&body, "text")?;
let voice = body
.get("voice")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or("alloy");
let audio = call_tts(&state, text, voice).await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "audio/mpeg")],
audio,
)
.into_response())
}
async fn full_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let user_id = resolve_user_id(&headers, &state)?;
let _ = ensure_container_ready(&state, user_id).await?;
let script = required_str(&body, "script")?;
let requested_style = body.get("style").and_then(Value::as_str);
let analysis_raw = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
let analysis = parse_llm_json(&analysis_raw)?;
let mut characters = Vec::new();
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
for character in chars {
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let prompts_raw = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&prompts_raw)?;
let mut item = character.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompts".to_string(), prompts);
}
characters.push(item);
}
}
let mut scenes = Vec::new();
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
for scene in scene_items {
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let scene_prompt_raw = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
let mut item = scene.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompt".to_string(), image_prompt);
}
scenes.push(item);
}
}
let style = analysis
.get("style")
.cloned()
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
.unwrap_or(Value::Null);
state.comfyui_pool.touch(user_id).await;
Ok(Json(json!({
"success": true,
"data": {
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
"style": style,
"characters": characters,
"scenes": scenes
}
})))
}
+34 -22
View File
@@ -93,10 +93,7 @@ pub fn routes() -> Router<AppState> {
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
Ok(Json(
serde_json::to_value(state.comfyui.status_response().await)
.unwrap_or_else(|_| json!({ "status": "unknown" })),
))
Ok(Json(external_comfyui_status(&state).await))
}
async fn start_comfyui(
@@ -104,27 +101,19 @@ async fn start_comfyui(
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.start()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
"message": "内置 Docker ComfyUI 工作台已移除;请配置外部 ComfyUI 或使用 /workflows 工作流画布。",
"comfyui": external_comfyui_status_value(&state).await
})))
}
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.stop()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
"message": "内置 Docker ComfyUI 工作台已移除;BoosAPI 不再管理 ComfyUI 进程生命周期。",
"comfyui": external_comfyui_status_value(&state).await
})))
}
@@ -133,14 +122,10 @@ async fn restart_comfyui(
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.restart()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
"message": "内置 Docker ComfyUI 工作台已移除;请在外部 ComfyUI 服务侧重启。",
"comfyui": external_comfyui_status_value(&state).await
})))
}
@@ -187,6 +172,33 @@ fn local_api_base(state: &AppState) -> String {
state.comfyui.model_backend_url()
}
async fn external_comfyui_status(state: &AppState) -> Value {
json!({
"success": true,
"comfyui": external_comfyui_status_value(state).await,
})
}
async fn external_comfyui_status_value(state: &AppState) -> Value {
let status = state.comfyui.status_response().await;
let reachable = state.comfyui.health_check().await;
json!({
"status": if reachable { "running" } else { "unavailable" },
"pid": null,
"uptime_secs": 0,
"mode": "external",
"model_backend": status.model_backend,
"base_url": status.base_url,
"version": status.version,
"last_error": if reachable {
Value::Null
} else {
json!("外部 ComfyUI 不可达;内置 Docker ComfyUI 工作台已移除,请配置 comfyui.base_url 或使用 /workflows 工作流画布。")
},
"dockerManaged": false,
})
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token);
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -36,11 +36,23 @@ struct AssetQuery {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/workspace/assets", get(list_admin_assets))
.route("/api/workspace/assets/{public_id}", get(get_admin_asset).delete(delete_admin_asset))
.route("/api/workspace/assets/{public_id}/file", get(get_admin_asset_file))
.route(
"/api/workspace/assets/{public_id}",
get(get_admin_asset).delete(delete_admin_asset),
)
.route(
"/api/workspace/assets/{public_id}/file",
get(get_admin_asset_file),
)
.route("/api/user/workspace/assets", get(list_user_assets))
.route("/api/user/workspace/assets/{public_id}", get(get_user_asset).delete(delete_user_asset))
.route("/api/user/workspace/assets/{public_id}/file", get(get_user_asset_file))
.route(
"/api/user/workspace/assets/{public_id}",
get(get_user_asset).delete(delete_user_asset),
)
.route(
"/api/user/workspace/assets/{public_id}/file",
get(get_user_asset_file),
)
}
fn bearer(headers: &HeaderMap) -> Option<&str> {
@@ -56,7 +68,11 @@ fn bearer(headers: &HeaderMap) -> Option<&str> {
.filter(|value| !value.is_empty())
}
fn resolve_caller(headers: &HeaderMap, state: &AppState, admin_required: bool) -> AppResult<WorkspaceCaller> {
fn resolve_caller(
headers: &HeaderMap,
state: &AppState,
admin_required: bool,
) -> AppResult<WorkspaceCaller> {
let token = bearer(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
@@ -79,7 +95,16 @@ fn resolve_caller(headers: &HeaderMap, state: &AppState, admin_required: bool) -
Err(AppError::Forbidden("Workspace access denied".into()))
}
fn normalize_query(query: &AssetQuery) -> (i64, i64, Option<String>, Option<String>, Option<String>, Option<i64>) {
fn normalize_query(
query: &AssetQuery,
) -> (
i64,
i64,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
) {
let page_size = query.page_size.unwrap_or(36).clamp(1, 120);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * page_size;
-672
View File
@@ -1,672 +0,0 @@
//! Per-user ComfyUI container pool.
//!
//! Owns the in-memory map `user_id -> PoolEntry`, allocates ports from the
//! 8200..=8299 range, enforces the 30-container ceiling, runs the readiness
//! probe, and reaps idle containers.
//!
//! All Docker/K3S specifics are encapsulated by the [`ContainerRuntime`]
//! trait; this file MUST stay backend-agnostic.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use tokio::sync::{Mutex as AsyncMutex, RwLock};
use tracing::{debug, error, info, warn};
use super::container_runtime::{
BindMount, ContainerRuntime, ContainerSpec, ResourceLimits, RuntimeError, TmpfsMount,
};
/// Maximum concurrent containers (§design Decision 2: 30 × 2 GB ≈ 60 GB).
pub const DEFAULT_MAX_CONCURRENT: usize = 30;
/// Idle timeout before the reaper stops a container.
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 60);
/// Host port range for per-user containers.
pub const PORT_RANGE_START: u16 = 8200;
pub const PORT_RANGE_END: u16 = 8299;
/// Time we wait for a freshly started container to answer `/system_stats`.
pub const READINESS_TIMEOUT: Duration = Duration::from_secs(30);
/// How often the reaper task wakes up.
pub const REAPER_TICK: Duration = Duration::from_secs(60);
/// Image tag we run for per-user tenants.
pub const DEFAULT_IMAGE: &str = "boosapi-comfyui:multi-tenant";
/// Host directory under which per-user volumes live.
pub const DEFAULT_DATA_ROOT: &str = "/data/comfyui-users";
/// Lifecycle state visible to callers (frontend renders loading vs ready).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerState {
Starting,
Ready,
Stopped,
Failed,
}
impl ContainerState {
pub fn as_str(self) -> &'static str {
match self {
ContainerState::Starting => "starting",
ContainerState::Ready => "ready",
ContainerState::Stopped => "stopped",
ContainerState::Failed => "failed",
}
}
}
/// One running (or starting) per-user container.
#[derive(Debug, Clone)]
pub struct PoolEntry {
pub user_id: i64,
pub container_name: String,
pub host_port: u16,
pub state: ContainerState,
pub last_used_at: Instant,
pub active_ws: u32,
pub last_error: Option<String>,
}
#[derive(thiserror::Error, Debug)]
pub enum PoolError {
#[error("pool at capacity ({0} containers running)")]
AtCapacity(usize),
#[error("no free port in pool range")]
OutOfPorts,
#[error("container failed to become ready in {0:?}")]
StartTimeout(Duration),
#[error("runtime error: {0}")]
Runtime(#[from] RuntimeError),
#[error("internal: {0}")]
Internal(String),
}
pub type PoolResult<T> = Result<T, PoolError>;
/// Tunable knobs the binary wires up at boot.
#[derive(Debug, Clone)]
pub struct PoolConfig {
pub image: String,
pub max_concurrent: usize,
pub idle_timeout: Duration,
pub data_root: PathBuf,
pub boosapi_base: String,
pub builtin_key: String,
}
impl PoolConfig {
pub fn from_env(boosapi_base: String, builtin_key: String) -> Self {
let image = std::env::var("BOOSAPI_COMFY_IMAGE").unwrap_or_else(|_| DEFAULT_IMAGE.into());
let max_concurrent: usize = std::env::var("BOOSAPI_COMFY_MAX")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_CONCURRENT);
let idle_secs: u64 = std::env::var("BOOSAPI_COMFY_IDLE_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_IDLE_TIMEOUT.as_secs());
let data_root = std::env::var("BOOSAPI_COMFY_DATA_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_DATA_ROOT));
Self {
image,
max_concurrent,
idle_timeout: Duration::from_secs(idle_secs),
data_root,
boosapi_base,
builtin_key,
}
}
}
/// Thread-safe allocator over `PORT_RANGE_START..=PORT_RANGE_END`.
struct PortAllocator {
in_use: Mutex<HashSet<u16>>,
}
impl PortAllocator {
fn new() -> Self {
Self {
in_use: Mutex::new(HashSet::new()),
}
}
fn claim(&self, port: u16) {
self.in_use.lock().insert(port);
}
fn allocate(&self) -> Option<u16> {
let mut g = self.in_use.lock();
for p in PORT_RANGE_START..=PORT_RANGE_END {
if !g.contains(&p) {
g.insert(p);
return Some(p);
}
}
None
}
fn release(&self, port: u16) {
self.in_use.lock().remove(&port);
}
}
/// Per-user start lock. Prevents two concurrent calls to
/// `get_or_start(7)` from spawning two containers for user 7.
type StartLock = Arc<AsyncMutex<()>>;
/// The pool itself.
pub struct ComfyUIPool {
runtime: Arc<dyn ContainerRuntime>,
config: PoolConfig,
containers: RwLock<HashMap<i64, PoolEntry>>,
start_locks: Mutex<HashMap<i64, StartLock>>,
ports: PortAllocator,
http: reqwest::Client,
}
impl ComfyUIPool {
pub fn new(runtime: Arc<dyn ContainerRuntime>, config: PoolConfig) -> Arc<Self> {
Arc::new(Self {
runtime,
config,
containers: RwLock::new(HashMap::new()),
start_locks: Mutex::new(HashMap::new()),
ports: PortAllocator::new(),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client"),
})
}
pub fn config(&self) -> &PoolConfig {
&self.config
}
/// Start the user's container if it isn't already running and return a
/// handle. Idempotent.
pub async fn get_or_start(&self, user_id: i64) -> PoolResult<PoolEntry> {
// Fast path: already ready.
let existing = {
let map = self.containers.read().await;
map.get(&user_id).cloned()
};
if let Some(entry) = existing {
if entry.state == ContainerState::Ready {
self.touch_inner(user_id).await;
return Ok(entry);
}
}
// Per-user start lock — only one task gets to spawn per user.
let lock = self
.start_locks
.lock()
.entry(user_id)
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone();
let _g = lock.lock().await;
// Re-check after acquiring the lock; another task may have done the work.
let existing = {
let map = self.containers.read().await;
map.get(&user_id).cloned()
};
if let Some(entry) = existing {
if entry.state == ContainerState::Ready {
self.touch_inner(user_id).await;
return Ok(entry);
}
if entry.state == ContainerState::Starting {
// Probe ourselves — the previous winner may have died mid-start.
return self.probe_until_ready(user_id, entry).await;
}
}
// Capacity check.
{
let map = self.containers.read().await;
let live = map
.values()
.filter(|e| matches!(e.state, ContainerState::Starting | ContainerState::Ready))
.count();
if live >= self.config.max_concurrent {
return Err(PoolError::AtCapacity(live));
}
}
// Allocate port.
let port = self.ports.allocate().ok_or(PoolError::OutOfPorts)?;
let name = format!("comfyui-user-{}", user_id);
// Insert a Starting marker so concurrent callers see it.
{
let mut map = self.containers.write().await;
map.insert(
user_id,
PoolEntry {
user_id,
container_name: name.clone(),
host_port: port,
state: ContainerState::Starting,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
}
let spec = self.build_spec(user_id, &name, port);
match self.runtime.start(spec).await {
Ok(handle) => {
debug!(user_id, container = %handle.name, port = handle.host_port, "container started");
}
Err(e) => {
self.ports.release(port);
let mut map = self.containers.write().await;
if let Some(entry) = map.get_mut(&user_id) {
entry.state = ContainerState::Failed;
entry.last_error = Some(e.to_string());
}
return Err(PoolError::Runtime(e));
}
}
let entry_now = self
.containers
.read()
.await
.get(&user_id)
.cloned()
.ok_or_else(|| PoolError::Internal("entry vanished mid-start".into()))?;
self.probe_until_ready(user_id, entry_now).await
}
/// Wait for the container to answer `/system_stats`, then mark Ready.
async fn probe_until_ready(&self, user_id: i64, entry: PoolEntry) -> PoolResult<PoolEntry> {
let url = format!("http://127.0.0.1:{}/system_stats", entry.host_port);
let deadline = Instant::now() + READINESS_TIMEOUT;
while Instant::now() < deadline {
match self.http.get(&url).send().await {
Ok(r) if r.status().is_success() => {
let mut map = self.containers.write().await;
if let Some(e) = map.get_mut(&user_id) {
e.state = ContainerState::Ready;
e.last_used_at = Instant::now();
e.last_error = None;
return Ok(e.clone());
}
return Err(PoolError::Internal("entry removed during probe".into()));
}
_ => {
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
// Timeout. Mark Failed and let the next call retry.
let mut map = self.containers.write().await;
if let Some(e) = map.get_mut(&user_id) {
e.state = ContainerState::Failed;
e.last_error = Some("readiness probe timed out".into());
}
// Best-effort stop so the slot frees on the next reaper tick.
let name = entry.container_name.clone();
let runtime = self.runtime.clone();
tokio::spawn(async move {
let _ = runtime.stop(&name).await;
});
Err(PoolError::StartTimeout(READINESS_TIMEOUT))
}
/// Stop the user's container, release its port.
pub async fn stop(&self, user_id: i64) -> PoolResult<()> {
let entry = {
let mut map = self.containers.write().await;
map.remove(&user_id)
};
let Some(entry) = entry else {
return Ok(());
};
self.ports.release(entry.host_port);
self.runtime.stop(&entry.container_name).await?;
Ok(())
}
/// Current state for a user (or `Stopped` if no entry).
pub async fn state(&self, user_id: i64) -> ContainerState {
self.containers
.read()
.await
.get(&user_id)
.map(|e| e.state)
.unwrap_or(ContainerState::Stopped)
}
/// Snapshot of the user's pool entry, if any.
pub async fn entry(&self, user_id: i64) -> Option<PoolEntry> {
self.containers.read().await.get(&user_id).cloned()
}
/// Bump `last_used_at`. Cheap; called on every proxied frame.
pub async fn touch(&self, user_id: i64) {
self.touch_inner(user_id).await
}
async fn touch_inner(&self, user_id: i64) {
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
e.last_used_at = Instant::now();
}
}
/// Increment / decrement the active-WS counter for a user.
pub async fn ws_open(&self, user_id: i64) {
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
e.active_ws = e.active_ws.saturating_add(1);
e.last_used_at = Instant::now();
}
}
pub async fn ws_close(&self, user_id: i64) {
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
e.active_ws = e.active_ws.saturating_sub(1);
e.last_used_at = Instant::now();
}
}
/// Reconcile against the runtime at startup. Adopts existing
/// `comfyui-user-*` containers so a Rust restart doesn't orphan them.
pub async fn reconcile_on_startup(&self) {
let listings = match self.runtime.list_user_containers().await {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "could not reconcile existing containers; assuming none");
return;
}
};
let mut map = self.containers.write().await;
for listing in listings {
// Names look like `comfyui-user-7`.
let Some(suffix) = listing.name.strip_prefix("comfyui-user-") else {
continue;
};
let Ok(user_id) = suffix.parse::<i64>() else {
continue;
};
self.ports.claim(listing.host_port);
map.insert(
user_id,
PoolEntry {
user_id,
container_name: listing.name.clone(),
host_port: listing.host_port,
state: ContainerState::Ready, // assume ready; first request re-probes
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
info!(user_id, container = %listing.name, port = listing.host_port, "adopted existing container");
}
}
/// Stop every container we manage (graceful-shutdown hook).
pub async fn shutdown_all(&self) {
let names: Vec<String> = {
let map = self.containers.read().await;
map.values().map(|e| e.container_name.clone()).collect()
};
for name in names {
if let Err(e) = self.runtime.stop(&name).await {
warn!(container = %name, error = %e, "shutdown stop failed");
}
}
self.containers.write().await.clear();
}
/// Build the [`ContainerSpec`] for a given user.
fn build_spec(&self, user_id: i64, name: &str, host_port: u16) -> ContainerSpec {
let user_root = self.config.data_root.join(user_id.to_string());
// Subdirs we mount in. Created on demand by the host operator (or
// the runtime can `mkdir -p` them — Docker `bind` will fail if the
// path is missing).
let mk = |sub: &str| BindMount {
host_path: user_root.join(sub),
container_path: format!("/workspace/ComfyUI/{}", sub),
read_only: false,
};
// Best-effort mkdir so a fresh user just works. Failures here surface
// through the Docker error message later.
for sub in ["input", "output", "temp", "user", "custom_nodes_user"] {
let _ = std::fs::create_dir_all(user_root.join(sub));
}
let mut env = std::collections::HashMap::new();
env.insert(
"BOOSAPI_BUILTIN_KEY".into(),
self.config.builtin_key.clone(),
);
env.insert(
"BOOSAPI_BASE".into(),
container_reachable_boosapi_base(&self.config.boosapi_base),
);
env.insert("BOOSAPI_KEY".into(), self.config.builtin_key.clone());
ContainerSpec {
image: self.config.image.clone(),
name: name.to_string(),
host_port,
env,
binds: vec![
mk("input"),
mk("output"),
mk("temp"),
mk("user"),
mk("custom_nodes_user"),
],
tmpfs: vec![TmpfsMount {
container_path: "/tmp".into(),
size_bytes: 512 * 1024 * 1024,
}],
limits: ResourceLimits::default(),
}
}
}
fn container_reachable_boosapi_base(base: &str) -> String {
if let Ok(override_base) = std::env::var("BOOSAPI_COMFY_CONTAINER_BASE") {
let trimmed = override_base.trim();
if !trimmed.is_empty() {
return trimmed.trim_end_matches('/').to_string();
}
}
let trimmed = base.trim().trim_end_matches('/');
for local_host in ["127.0.0.1", "localhost"] {
let needle = format!("//{}:", local_host);
if let Some((prefix, rest)) = trimmed.split_once(&needle) {
return format!("{}//host.docker.internal:{}", prefix, rest);
}
}
trimmed.to_string()
}
/// Spawn the idle-reaper task. Lives for the life of the process.
pub fn spawn_reaper(pool: Arc<ComfyUIPool>) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(REAPER_TICK);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tick.tick().await;
reap_once(&pool).await;
}
});
}
async fn reap_once(pool: &Arc<ComfyUIPool>) {
let now = Instant::now();
let idle = pool.config.idle_timeout;
let candidates: Vec<i64> = {
let map = pool.containers.read().await;
map.values()
.filter(|e| {
e.active_ws == 0
&& now.saturating_duration_since(e.last_used_at) > idle
&& matches!(e.state, ContainerState::Ready | ContainerState::Failed)
})
.map(|e| e.user_id)
.collect()
};
for user_id in candidates {
info!(user_id, "reaping idle comfyui container");
if let Err(e) = pool.stop(user_id).await {
error!(user_id, error = %e, "reap failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::services::container_runtime::{ContainerHandle, ContainerListing, RuntimeResult};
/// In-memory runtime that succeeds and pretends `/system_stats` is up.
/// Real readiness probe is tested separately via the live Docker setup.
struct MockRuntime {
starts: AtomicUsize,
stops: AtomicUsize,
}
impl MockRuntime {
fn new() -> Self {
Self {
starts: AtomicUsize::new(0),
stops: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl ContainerRuntime for MockRuntime {
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle> {
self.starts.fetch_add(1, Ordering::SeqCst);
Ok(ContainerHandle {
id: format!("mock-{}", spec.name),
name: spec.name,
host_port: spec.host_port,
})
}
async fn stop(&self, _name: &str) -> RuntimeResult<()> {
self.stops.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn is_alive(&self, _name: &str) -> RuntimeResult<bool> {
Ok(true)
}
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>> {
Ok(vec![])
}
}
fn cfg() -> PoolConfig {
PoolConfig {
image: "test".into(),
max_concurrent: 2,
idle_timeout: Duration::from_secs(60),
data_root: std::env::temp_dir().join("boosapi-pool-test"),
boosapi_base: "http://127.0.0.1:9999".into(),
builtin_key: "sk-test".into(),
}
}
#[tokio::test]
async fn capacity_cap_enforced() {
let runtime = Arc::new(MockRuntime::new());
let pool = ComfyUIPool::new(runtime.clone(), cfg());
// Inject 2 containers manually so we don't have to satisfy the
// /system_stats probe (this test isolates the cap check).
{
let mut map = pool.containers.write().await;
map.insert(
1,
PoolEntry {
user_id: 1,
container_name: "comfyui-user-1".into(),
host_port: 8200,
state: ContainerState::Ready,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
map.insert(
2,
PoolEntry {
user_id: 2,
container_name: "comfyui-user-2".into(),
host_port: 8201,
state: ContainerState::Ready,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
}
let err = pool.get_or_start(3).await.unwrap_err();
assert!(matches!(err, PoolError::AtCapacity(2)), "got: {:?}", err);
}
#[tokio::test]
async fn stop_releases_port() {
let runtime = Arc::new(MockRuntime::new());
let pool = ComfyUIPool::new(runtime.clone(), cfg());
{
let mut map = pool.containers.write().await;
map.insert(
7,
PoolEntry {
user_id: 7,
container_name: "comfyui-user-7".into(),
host_port: 8205,
state: ContainerState::Ready,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
}
pool.ports.claim(8205);
pool.stop(7).await.unwrap();
assert_eq!(runtime.stops.load(Ordering::SeqCst), 1);
// Port is back in the free pool.
let next = pool.ports.allocate().unwrap();
assert_eq!(next, PORT_RANGE_START);
}
#[test]
fn port_allocator_assigns_in_order_and_releases() {
let p = PortAllocator::new();
let a = p.allocate().unwrap();
let b = p.allocate().unwrap();
assert_eq!(a, PORT_RANGE_START);
assert_eq!(b, PORT_RANGE_START + 1);
p.release(a);
let c = p.allocate().unwrap();
assert_eq!(c, PORT_RANGE_START);
}
}
-120
View File
@@ -1,120 +0,0 @@
//! Backend-agnostic container lifecycle abstraction.
//!
//! v1 ships [`crate::services::docker_runtime::DockerRuntime`] against the
//! local Docker daemon. v2 will add a K3S implementation behind the same
//! trait so the pool, proxy, and route handlers do NOT have to change.
//!
//! Callers (the pool, in particular) MUST go through this trait — never
//! import `bollard`, the `docker` CLI, or `kube` types directly.
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::PathBuf;
/// Resource and security caps for a container.
///
/// Mirrors §design Decision 5 verbatim: 2 CPU, 2 GB RAM, 2 GB swap,
/// `pids_limit=512`, read-only rootfs, drop all caps, `no-new-privileges`.
#[derive(Debug, Clone)]
pub struct ResourceLimits {
pub cpus: f64,
pub memory_bytes: u64,
pub memory_swap_bytes: u64,
pub pids_limit: u32,
pub read_only_rootfs: bool,
pub cap_drop_all: bool,
pub no_new_privileges: bool,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
cpus: 2.0,
memory_bytes: 2 * 1024 * 1024 * 1024,
memory_swap_bytes: 2 * 1024 * 1024 * 1024,
pids_limit: 512,
read_only_rootfs: true,
cap_drop_all: true,
no_new_privileges: true,
}
}
}
/// One bind mount: `host_path -> container_path`.
#[derive(Debug, Clone)]
pub struct BindMount {
pub host_path: PathBuf,
pub container_path: String,
pub read_only: bool,
}
/// Tmpfs mount (used to satisfy a few writable paths under read-only rootfs).
#[derive(Debug, Clone)]
pub struct TmpfsMount {
pub container_path: String,
pub size_bytes: u64,
}
/// A complete container start request.
#[derive(Debug, Clone)]
pub struct ContainerSpec {
pub image: String,
pub name: String,
/// Host port to bind to. The container's internal port is fixed at 8188.
pub host_port: u16,
pub env: HashMap<String, String>,
pub binds: Vec<BindMount>,
pub tmpfs: Vec<TmpfsMount>,
pub limits: ResourceLimits,
}
/// Result of a successful start: identifies the container and tells the
/// caller which loopback port to forward traffic to.
#[derive(Debug, Clone)]
pub struct ContainerHandle {
/// Backend-specific id (Docker container id, k8s pod name, ...).
pub id: String,
/// Container name we asked for, e.g. `comfyui-user-7`.
pub name: String,
/// Loopback host port the runtime mapped for us.
pub host_port: u16,
}
/// Listing entry returned by [`ContainerRuntime::list_user_containers`].
#[derive(Debug, Clone)]
pub struct ContainerListing {
pub id: String,
pub name: String,
pub host_port: u16,
}
#[derive(thiserror::Error, Debug)]
pub enum RuntimeError {
#[error("backend error: {0}")]
Backend(String),
#[error("container not found: {0}")]
NotFound(String),
}
pub type RuntimeResult<T> = Result<T, RuntimeError>;
/// Backend-agnostic container lifecycle.
#[async_trait]
pub trait ContainerRuntime: Send + Sync + 'static {
/// Start a container and return a handle once it is running.
///
/// The runtime is NOT required to wait for the application inside the
/// container to be ready — that probe lives in the pool.
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle>;
/// Stop and remove a container by name. Idempotent: removing a
/// nonexistent container returns `Ok`.
async fn stop(&self, name: &str) -> RuntimeResult<()>;
/// Check whether a container is running.
async fn is_alive(&self, name: &str) -> RuntimeResult<bool>;
/// List every running container whose name starts with `comfyui-user-`.
/// Used at API startup to reconstruct the pool's in-memory map.
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>>;
}
-291
View File
@@ -1,291 +0,0 @@
//! Docker implementation of [`ContainerRuntime`].
//!
//! We shell out to the `docker` CLI rather than pull in a Rust Docker SDK,
//! to keep the dependency footprint small. The CLI is universally
//! available on the hosts we ship to, and the surface area we need
//! (`run / stop / rm / inspect / ps`) is tiny.
use async_trait::async_trait;
use std::process::Stdio;
use tokio::process::Command;
use tracing::{debug, warn};
use super::container_runtime::{
ContainerHandle, ContainerListing, ContainerRuntime, ContainerSpec, RuntimeError, RuntimeResult,
};
/// Internal port the ComfyUI process listens on inside the container.
/// Hard-coded to match the image's ENTRYPOINT.
const CONTAINER_INTERNAL_PORT: u16 = 8188;
/// Label we stamp onto every container we manage. Used by
/// `list_user_containers` and as a sanity-check on `stop`.
pub const MANAGED_LABEL: &str = "boosapi.comfy.tenant=1";
/// Default Docker runtime. Stateless — every method shells out fresh.
pub struct DockerRuntime {
docker_bin: String,
}
impl DockerRuntime {
pub fn new() -> Self {
Self {
docker_bin: std::env::var("BOOSAPI_DOCKER_BIN").unwrap_or_else(|_| "docker".into()),
}
}
fn cmd(&self) -> Command {
let mut c = Command::new(&self.docker_bin);
c.stdin(Stdio::null());
c
}
}
impl Default for DockerRuntime {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ContainerRuntime for DockerRuntime {
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle> {
// Best-effort: kill any stale container with the same name.
// `docker rm -f <name>` is a no-op if the name is free.
let _ = self
.cmd()
.args(["rm", "-f", &spec.name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
let mut cmd = self.cmd();
cmd.arg("run")
.arg("-d")
.arg("--rm")
.arg("--name")
.arg(&spec.name)
.arg("--label")
.arg(MANAGED_LABEL)
.arg("-p")
.arg(format!(
"127.0.0.1:{}:{}",
spec.host_port, CONTAINER_INTERNAL_PORT
))
.arg("--add-host")
.arg("host.docker.internal:host-gateway")
// Resource limits (§design Decision 5)
.arg(format!("--cpus={}", spec.limits.cpus))
.arg(format!("--memory={}", spec.limits.memory_bytes))
.arg(format!("--memory-swap={}", spec.limits.memory_swap_bytes))
.arg(format!("--pids-limit={}", spec.limits.pids_limit));
if spec.limits.read_only_rootfs {
cmd.arg("--read-only");
}
if spec.limits.cap_drop_all {
cmd.args(["--cap-drop", "ALL"]);
}
if spec.limits.no_new_privileges {
cmd.args(["--security-opt", "no-new-privileges"]);
}
for (k, v) in &spec.env {
cmd.arg("-e").arg(format!("{}={}", k, v));
}
for bind in &spec.binds {
// Using `--mount` over `-v` so the syntax is unambiguous and
// we get a clear error if the host path is wrong.
let mut s = format!(
"type=bind,source={},target={}",
bind.host_path.display(),
bind.container_path
);
if bind.read_only {
s.push_str(",readonly");
}
cmd.arg("--mount").arg(s);
}
for tmp in &spec.tmpfs {
cmd.arg("--tmpfs")
.arg(format!("{}:size={}", tmp.container_path, tmp.size_bytes));
}
cmd.arg(&spec.image);
debug!(?cmd, "starting comfyui container");
let output = cmd.output().await.map_err(io_to_runtime)?;
if !output.status.success() {
return Err(RuntimeError::Backend(format!(
"docker run failed (exit {}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if id.is_empty() {
return Err(RuntimeError::Backend(
"docker run returned empty container id".into(),
));
}
Ok(ContainerHandle {
id,
name: spec.name,
host_port: spec.host_port,
})
}
async fn stop(&self, name: &str) -> RuntimeResult<()> {
// `docker stop` flushes nicely; `--rm` from start removes it after.
// If it's already gone, treat as success.
let output = self
.cmd()
.args(["stop", "-t", "10", name])
.output()
.await
.map_err(io_to_runtime)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No such container") {
return Ok(());
}
warn!(name, stderr = stderr.trim(), "docker stop failed");
// Fall through to rm -f as a last resort.
}
let _ = self
.cmd()
.args(["rm", "-f", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
Ok(())
}
async fn is_alive(&self, name: &str) -> RuntimeResult<bool> {
let output = self
.cmd()
.args(["inspect", "-f", "{{.State.Running}}", name])
.output()
.await
.map_err(io_to_runtime)?;
if !output.status.success() {
// `docker inspect` returns 1 if the container is unknown.
return Ok(false);
}
Ok(String::from_utf8_lossy(&output.stdout).trim() == "true")
}
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>> {
// `--format {{json .}}` gives one JSON object per line. We pull the
// fields we need without depending on a JSON-deserialize crate
// schema for Docker's somewhat-fluid output.
let output = self
.cmd()
.args([
"ps",
"--filter",
"name=comfyui-user-",
"--filter",
&format!("label={}", MANAGED_LABEL),
"--format",
"{{.Names}}\t{{.ID}}\t{{.Ports}}",
])
.output()
.await
.map_err(io_to_runtime)?;
if !output.status.success() {
return Err(RuntimeError::Backend(format!(
"docker ps failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut out = Vec::new();
for line in stdout.lines() {
let mut parts = line.splitn(3, '\t');
let name = parts.next().unwrap_or("").trim().to_string();
let id = parts.next().unwrap_or("").trim().to_string();
let ports = parts.next().unwrap_or("").trim();
if name.is_empty() || id.is_empty() {
continue;
}
let host_port = match parse_host_port(ports) {
Some(p) => p,
None => {
warn!(
name,
ports, "could not parse host port from docker ps; skipping"
);
continue;
}
};
out.push(ContainerListing {
id,
name,
host_port,
});
}
Ok(out)
}
}
/// Pull the host-side port out of a `docker ps` Ports column.
///
/// Examples it must handle:
/// "127.0.0.1:8200->8188/tcp"
/// "127.0.0.1:8200->8188/tcp, 127.0.0.1:8200->8188/udp"
fn parse_host_port(ports: &str) -> Option<u16> {
for spec in ports.split(',') {
let spec = spec.trim();
// Accept `<host>:<port>->...`
let arrow = spec.find("->")?;
let left = &spec[..arrow];
let port_str = left.rsplit(':').next()?;
if let Ok(p) = port_str.parse::<u16>() {
return Some(p);
}
}
None
}
fn io_to_runtime(e: std::io::Error) -> RuntimeError {
RuntimeError::Backend(format!("docker cli io error: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_port_mapping() {
assert_eq!(parse_host_port("127.0.0.1:8200->8188/tcp"), Some(8200));
}
#[test]
fn parses_port_mapping_with_extra_entries() {
assert_eq!(
parse_host_port("127.0.0.1:8201->8188/tcp, 127.0.0.1:8201->8188/udp"),
Some(8201)
);
}
#[test]
fn returns_none_for_garbage() {
assert_eq!(parse_host_port(""), None);
assert_eq!(parse_host_port("nope"), None);
}
}
+1 -3
View File
@@ -1,13 +1,11 @@
pub mod announcement;
pub mod billing;
pub mod builtin_keys;
pub mod comfyui_pool;
pub mod comfyui_process;
pub mod container_runtime;
pub mod cron;
pub mod docker_runtime;
pub mod notification;
pub mod oauth;
pub mod registration_limiter;
pub mod route_rebuild;
pub mod shared;
pub mod update_center;
+221
View File
@@ -0,0 +1,221 @@
use std::{
collections::{HashMap, VecDeque},
net::IpAddr,
time::{Duration, Instant},
};
use parking_lot::Mutex;
const IP_WINDOW: Duration = Duration::from_secs(15 * 60);
const EMAIL_WINDOW: Duration = Duration::from_secs(60 * 60);
const GLOBAL_WINDOW: Duration = Duration::from_secs(5 * 60);
const SUCCESS_LOCK_WINDOW: Duration = Duration::from_secs(10 * 60);
const MAX_ATTEMPTS_PER_IP: usize = 8;
const MAX_ATTEMPTS_PER_EMAIL: usize = 3;
const MAX_GLOBAL_ATTEMPTS: usize = 120;
const MAX_SUCCESS_PER_IP: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistrationLimitExceeded {
pub retry_after_secs: u64,
pub message: &'static str,
}
#[derive(Default)]
struct LimiterState {
attempts_by_ip: HashMap<String, VecDeque<Instant>>,
attempts_by_email: HashMap<String, VecDeque<Instant>>,
successful_by_ip: HashMap<String, VecDeque<Instant>>,
global_attempts: VecDeque<Instant>,
}
#[derive(Default)]
pub struct RegistrationLimiter {
inner: Mutex<LimiterState>,
}
impl RegistrationLimiter {
pub fn new() -> Self {
Self::default()
}
pub fn check_and_record_attempt(
&self,
ip: Option<IpAddr>,
email: &str,
) -> Result<(), RegistrationLimitExceeded> {
let now = Instant::now();
let ip_key = ip
.map(|addr| addr.to_string())
.unwrap_or_else(|| "unknown".to_string());
let email_key = normalize_email(email);
let mut guard = self.inner.lock();
prune_deque(&mut guard.global_attempts, now, GLOBAL_WINDOW);
if guard.global_attempts.len() >= MAX_GLOBAL_ATTEMPTS {
return Err(limit_error(
&guard.global_attempts,
now,
GLOBAL_WINDOW,
"注册请求过于频繁,请稍后再试。",
));
}
{
let ip_attempts = guard.attempts_by_ip.entry(ip_key.clone()).or_default();
prune_deque(ip_attempts, now, IP_WINDOW);
if ip_attempts.len() >= MAX_ATTEMPTS_PER_IP {
return Err(limit_error(
ip_attempts,
now,
IP_WINDOW,
"当前网络注册过于频繁,请稍后再试。",
));
}
}
{
let ip_successes = guard.successful_by_ip.entry(ip_key.clone()).or_default();
prune_deque(ip_successes, now, SUCCESS_LOCK_WINDOW);
if ip_successes.len() >= MAX_SUCCESS_PER_IP {
return Err(limit_error(
ip_successes,
now,
SUCCESS_LOCK_WINDOW,
"当前网络短时间内已注册多个账号,请稍后再试。",
));
}
}
{
let email_attempts = guard
.attempts_by_email
.entry(email_key.clone())
.or_default();
prune_deque(email_attempts, now, EMAIL_WINDOW);
if email_attempts.len() >= MAX_ATTEMPTS_PER_EMAIL {
return Err(limit_error(
email_attempts,
now,
EMAIL_WINDOW,
"该邮箱注册尝试过于频繁,请稍后再试。",
));
}
}
guard.global_attempts.push_back(now);
guard
.attempts_by_ip
.entry(ip_key)
.or_default()
.push_back(now);
guard
.attempts_by_email
.entry(email_key)
.or_default()
.push_back(now);
Ok(())
}
pub fn record_success(&self, ip: Option<IpAddr>) {
let now = Instant::now();
let ip_key = ip
.map(|addr| addr.to_string())
.unwrap_or_else(|| "unknown".to_string());
let mut guard = self.inner.lock();
let entries = guard.successful_by_ip.entry(ip_key).or_default();
prune_deque(entries, now, SUCCESS_LOCK_WINDOW);
entries.push_back(now);
}
}
fn normalize_email(email: &str) -> String {
email.trim().to_ascii_lowercase()
}
fn prune_deque(entries: &mut VecDeque<Instant>, now: Instant, window: Duration) {
while entries
.front()
.is_some_and(|timestamp| now.duration_since(*timestamp) >= window)
{
entries.pop_front();
}
}
fn limit_error(
entries: &VecDeque<Instant>,
now: Instant,
window: Duration,
message: &'static str,
) -> RegistrationLimitExceeded {
let retry_after_secs = entries
.front()
.map(|first| {
window
.saturating_sub(now.duration_since(*first))
.as_secs()
.max(1)
})
.unwrap_or_else(|| window.as_secs().max(1));
RegistrationLimitExceeded {
retry_after_secs,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn ip() -> Option<IpAddr> {
Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))
}
#[test]
fn limits_repeated_attempts_from_same_ip() {
let limiter = RegistrationLimiter::new();
for index in 0..MAX_ATTEMPTS_PER_IP {
limiter
.check_and_record_attempt(ip(), &format!("u{index}@example.com"))
.unwrap();
}
let err = limiter
.check_and_record_attempt(ip(), "blocked@example.com")
.unwrap_err();
assert_eq!(err.message, "当前网络注册过于频繁,请稍后再试。");
}
#[test]
fn limits_repeated_attempts_for_same_email_case_insensitive() {
let limiter = RegistrationLimiter::new();
for _ in 0..MAX_ATTEMPTS_PER_EMAIL {
limiter
.check_and_record_attempt(None, "User@Example.com")
.unwrap();
}
let err = limiter
.check_and_record_attempt(None, "user@example.com")
.unwrap_err();
assert_eq!(err.message, "该邮箱注册尝试过于频繁,请稍后再试。");
}
#[test]
fn limits_successful_registrations_from_same_ip() {
let limiter = RegistrationLimiter::new();
for index in 0..MAX_SUCCESS_PER_IP {
limiter
.check_and_record_attempt(ip(), &format!("u{index}@example.com"))
.unwrap();
limiter.record_success(ip());
}
let err = limiter
.check_and_record_attempt(ip(), "extra@example.com")
.unwrap_err();
assert_eq!(err.message, "当前网络短时间内已注册多个账号,请稍后再试。");
}
}
+30 -14
View File
@@ -22,6 +22,7 @@ pub struct WorkspaceAssetInput {
#[derive(Debug, Clone)]
pub struct StoredWorkspaceAsset {
pub public_id: String,
pub file_name: Option<String>,
pub file_path: Option<String>,
pub remote_url: Option<String>,
pub status: String,
@@ -56,7 +57,12 @@ fn safe_segment(value: &str) -> String {
}
fn extension_for_mime(mime: &str) -> &'static str {
let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
let mime = mime
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
match mime.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
@@ -156,6 +162,7 @@ async fn insert_asset(
Ok(StoredWorkspaceAsset {
public_id: public_id.to_string(),
file_name,
file_path: file_path_string,
remote_url: input.remote_url.clone(),
status: status.to_string(),
@@ -193,12 +200,13 @@ pub async fn record_remote_asset(
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(format!("download workspace asset: {err}")))?;
let ext = extension_from_url(url).unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
let path = write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
let bytes = response.bytes().await.map_err(|err| {
AppError::UpstreamError(format!("download workspace asset: {err}"))
})?;
let ext = extension_from_url(url)
.unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
let path =
write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
insert_asset(
state,
&input,
@@ -283,17 +291,22 @@ pub async fn record_image_assets_from_response(
prompt: Option<String>,
model: Option<String>,
response: &Value,
) {
) -> Vec<StoredWorkspaceAsset> {
let Some(items) = response.get("data").and_then(Value::as_array) else {
return;
return Vec::new();
};
let mut stored_assets = Vec::new();
for (index, item) in items.iter().enumerate() {
let title = prompt
.as_deref()
.map(|value| value.chars().take(36).collect::<String>())
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(format!("图片产出 #{}", index + 1)));
if let Some(url) = item.get("url").and_then(Value::as_str).filter(|value| !value.trim().is_empty()) {
if let Some(url) = item
.get("url")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
let input = WorkspaceAssetInput {
user_id,
source: source.to_string(),
@@ -304,8 +317,9 @@ pub async fn record_image_assets_from_response(
remote_url: Some(url.trim().to_string()),
metadata: json!({ "responseItem": item }),
};
if let Err(err) = record_remote_asset(state, input).await {
tracing::warn!(%err, "failed to archive remote image asset");
match record_remote_asset(state, input).await {
Ok(asset) => stored_assets.push(asset),
Err(err) => tracing::warn!(%err, "failed to archive remote image asset"),
}
} else if let Some(b64) = item
.get("b64_json")
@@ -322,9 +336,11 @@ pub async fn record_image_assets_from_response(
remote_url: None,
metadata: json!({ "responseItem": item }),
};
if let Err(err) = record_base64_asset(state, input, b64).await {
tracing::warn!(%err, "failed to archive base64 image asset");
match record_base64_asset(state, input, b64).await {
Ok(asset) => stored_assets.push(asset),
Err(err) => tracing::warn!(%err, "failed to archive base64 image asset"),
}
}
}
stored_assets
}
+25 -9
View File
@@ -294,7 +294,13 @@ fn parse_message_content(
return Ok((Vec::new(), None, None));
};
if let Some(text) = content.as_str() {
return Ok((vec![ContentPart::Text { text: text.to_string() }], None, None));
return Ok((
vec![ContentPart::Text {
text: text.to_string(),
}],
None,
None,
));
}
let mut parts = Vec::new();
@@ -307,7 +313,9 @@ fn parse_message_content(
match item["type"].as_str() {
Some("text") => {
if let Some(text) = item["text"].as_str() {
parts.push(ContentPart::Text { text: text.to_string() });
parts.push(ContentPart::Text {
text: text.to_string(),
});
}
}
Some("image") => {
@@ -317,9 +325,7 @@ fn parse_message_content(
detail: None,
});
} else if item["source"]["type"].as_str() == Some("base64") {
let media_type = item["source"]["media_type"]
.as_str()
.unwrap_or("image/png");
let media_type = item["source"]["media_type"].as_str().unwrap_or("image/png");
if let Some(data) = item["source"]["data"].as_str() {
parts.push(ContentPart::Image {
url: format!("data:{media_type};base64,{data}"),
@@ -350,14 +356,20 @@ fn parse_message_content(
}
Ok((
parts,
if tool_calls.is_empty() { None } else { Some(tool_calls) },
if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id,
))
}
fn parse_anthropic_content(value: &Value) -> Result<Vec<ContentPart>, String> {
if let Some(text) = value.as_str() {
return Ok(vec![ContentPart::Text { text: text.to_string() }]);
return Ok(vec![ContentPart::Text {
text: text.to_string(),
}]);
}
let Some(items) = value.as_array() else {
return Ok(Vec::new());
@@ -366,7 +378,9 @@ fn parse_anthropic_content(value: &Value) -> Result<Vec<ContentPart>, String> {
for item in items {
if item["type"].as_str() == Some("text") {
if let Some(text) = item["text"].as_str() {
parts.push(ContentPart::Text { text: text.to_string() });
parts.push(ContentPart::Text {
text: text.to_string(),
});
}
}
}
@@ -387,7 +401,9 @@ fn parse_anthropic_tool_choice(value: Option<&Value>) -> Option<ToolChoice> {
match value["type"].as_str()? {
"auto" => Some(ToolChoice::Auto),
"any" => Some(ToolChoice::Required),
"tool" => value["name"].as_str().map(|name| ToolChoice::Specific(name.to_string())),
"tool" => value["name"]
.as_str()
.map(|name| ToolChoice::Specific(name.to_string())),
"none" => Some(ToolChoice::None),
_ => None,
}
+3 -1
View File
@@ -5,6 +5,8 @@ pub mod outbound;
pub mod stream;
pub use compatibility::AnthropicMessagesCompatibility;
pub use inbound::{format_messages_request, format_messages_request_with_compat, parse_messages_request};
pub use inbound::{
format_messages_request, format_messages_request_with_compat, parse_messages_request,
};
pub use outbound::parse_messages_response;
pub use stream::parse_stream_event;
-23
View File
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
@@ -12,14 +11,6 @@ function collectText(node: ReactTestInstance): string {
}
describe('Login surface', () => {
it('uses the site root as the documentation URL', () => {
expect(SITE_DOCS_URL).toBe('https://boosapi.cita777.me');
});
it('uses the author github profile for the login github shortcut', () => {
expect(SITE_GITHUB_URL).toBe('https://github.com/cita-777');
});
it('renders a poster-style hero with a floating admin login panel', () => {
const root = create(
<MemoryRouter>
@@ -52,15 +43,9 @@ describe('Login surface', () => {
expect(pageText).toContain('统一代理网关');
expect(pageText).toContain('智能路由引擎');
expect(pageText).toContain('自动模型发现');
expect(pageText).toContain('部署文档');
expect(lightBrandPanel).toBeTruthy();
expect(authStage).toBeTruthy();
expect(brandMarkCanvas).toBeTruthy();
const docsLink = root.root.find((node) => (
node.type === 'a'
&& node.props.href === SITE_DOCS_URL
));
const tokenInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '管理员令牌'
@@ -70,14 +55,6 @@ describe('Login surface', () => {
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-mode-tab')
));
const githubLink = root.root.find((node) => (
node.type === 'a'
&& node.props.href === SITE_GITHUB_URL
));
expect(docsLink.props.target).toBe('_blank');
expect(githubLink.props['aria-label']).toBe('GitHub');
expect(githubLink.props.target).toBe('_blank');
expect(tokenInput.props.type).toBe('password');
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
} finally {
+17 -15
View File
@@ -7,13 +7,11 @@ import TooltipLayer from './components/TooltipLayer.js';
import { api, userApi } from './api.js';
import { clearSession, getSessionToken } from './authSession.js';
import {
FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY,
THEME_MODE_STORAGE_KEY,
USER_PROFILE_STORAGE_KEY,
} from './appLocalState.js';
import { I18nProvider, useI18n } from './i18n.js';
import { SITE_DOCS_URL } from './docsLink.js';
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
import { useIsMobile } from './components/useIsMobile.js';
import { MobileDrawer } from './components/MobileDrawer.js';
@@ -36,10 +34,11 @@ const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
const Workspace = lazy(() => import('./pages/Workspace.js'));
const Workflows = lazy(() => import('./pages/Workflows.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
@@ -348,7 +347,8 @@ export const sidebarGroups = [
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
],
@@ -369,6 +369,7 @@ export const userSidebarGroups = [
label: '概览',
items: [
{ to: '/user/dashboard', label: '个人中心', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> },
{ to: '/user/models', label: '模型广场', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 6h16M4 10h10M4 14h16M4 18h8" /></svg> },
{ to: '/user/usage', label: '使用记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/user/announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/about', label: '关于', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M13 16h-1v-4h-1m1-4h.01M12 3a9 9 0 110 18 9 9 0 010-18z" /></svg> },
@@ -378,7 +379,8 @@ export const userSidebarGroups = [
label: '创作应用',
items: [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
],
@@ -388,7 +390,7 @@ const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' },
{ label: '视频工作台', to: '/video-agent' },
{ label: '工作流', to: '/workflows' },
{ label: '视频助手', to: '/comfyui-agent' },
{ label: '工作空间', to: '/workspace' },
{ label: '关于', to: '/about' },
@@ -535,12 +537,6 @@ function AppShell() {
const timer = setInterval(check, 60_000);
return () => clearInterval(timer);
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
}, [authed, t, toast]);
useEffect(() => {
if (!userAuthed || !location.pathname.startsWith('/user/') || location.pathname === '/user/login') {
setAnnouncementPopupRows([]);
@@ -677,8 +673,9 @@ function AppShell() {
const currentSidebarGroups = isUserPage ? userSidebarGroups : sidebarGroups;
const currentTopNavItems = isUserPage ? [
{ label: '个人中心', to: '/user/dashboard' },
{ label: '模型广场', to: '/user/models' },
{ label: '用户公告', to: '/user/announcements' },
{ label: '视频工作台', to: '/user/video-agent' },
{ label: '工作流', to: '/user/workflows' },
{ label: '视频助手', to: '/user/comfyui-agent' },
{ label: '工作空间', to: '/user/workspace' },
{ label: '关于', to: '/user/about' },
@@ -917,7 +914,9 @@ function AppShell() {
<Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} />
<Route path="/picture-agent" element={<PictureAgent />} />
<Route path="/video-agent" element={<ComfyUI />} />
<Route path="/digital-human" element={<DigitalHuman />} />
<Route path="/workflows" element={<Workflows />} />
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/workspace" element={<Workspace />} />
<Route path="/about" element={<About />} />
@@ -928,12 +927,15 @@ function AppShell() {
path="/user/dashboard"
element={<UserDashboard onLogout={() => setUserAuthed(false)} />}
/>
<Route path="/user/models" element={<Models />} />
<Route path="/user/dashboard/keys" element={<UserKeys />} />
<Route path="/user/dashboard/profile" element={<UserProfile />} />
<Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/announcements" element={<UserAnnouncements />} />
<Route path="/user/picture-agent" element={<PictureAgent />} />
<Route path="/user/video-agent" element={<ComfyUI />} />
<Route path="/user/digital-human" element={<DigitalHuman />} />
<Route path="/user/workflows" element={<Workflows />} />
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/user/workspace" element={<Workspace />} />
<Route path="/user/about" element={<About />} />
+115 -2
View File
@@ -55,6 +55,17 @@ async function extractResponseErrorMessage(res: Response): Promise<string> {
return message;
}
async function parseJsonResponse<T = any>(res: Response): Promise<T> {
if (res.status === 204 || res.status === 205) {
return { success: true } as T;
}
const text = await res.text();
if (!text.trim()) {
return { success: true } as T;
}
return JSON.parse(text) as T;
}
function parseContentDispositionFilename(
headerValue: string | null,
): string | null {
@@ -245,7 +256,7 @@ async function userRequest<T = any>(
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
}
return res.json() as Promise<T>;
return parseJsonResponse<T>(res);
}
async function request<T = any>(
@@ -256,7 +267,7 @@ async function request<T = any>(
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
}
return res.json() as Promise<T>;
return parseJsonResponse<T>(res);
}
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
@@ -1081,6 +1092,56 @@ export type WorkspaceAssetQuery = {
pageSize?: number;
};
export type WorkflowSummary = {
id: number;
publicId: string;
userId?: number | null;
name: string;
description?: string | null;
graph: any;
isTemplate?: boolean;
visibility: string;
createdAt: string;
updatedAt: string;
};
export type WorkflowNodeRun = {
id: number;
nodeId: string;
nodeType: string;
status: string;
inputs: any;
outputs?: any;
errorMessage?: string | null;
startedAt?: string | null;
completedAt?: string | null;
createdAt: string;
};
export type WorkflowRunSummary = {
id: number;
publicId: string;
workflowId: number;
workflowPublicId?: string | null;
userId?: number | null;
status: string;
inputOverrides: any;
finalOutputs?: any;
errorMessage?: string | null;
startedAt?: string | null;
completedAt?: string | null;
createdAt: string;
nodeRuns: WorkflowNodeRun[];
};
export type WorkflowSavePayload = {
name?: string;
description?: string | null;
graph?: any;
visibility?: string;
isTemplate?: boolean;
};
export const api = {
// Sites
getSites: async () => {
@@ -1939,6 +2000,26 @@ export const api = {
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) =>
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
getWorkflowNodeTypes: () =>
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
getWorkflows: (params?: { include_public?: boolean }) =>
request<{ success: boolean; items: WorkflowSummary[] }>(`/api/workflows${buildQueryString(params)}`),
createWorkflow: (data: WorkflowSavePayload) =>
request<{ success: boolean; workflow: WorkflowSummary }>("/api/workflows", { method: "POST", body: JSON.stringify(data) }),
updateWorkflow: (publicId: string, data: WorkflowSavePayload) =>
request<{ success: boolean; workflow: WorkflowSummary }>(`/api/workflows/${encodeURIComponent(publicId)}`, { method: "PUT", body: JSON.stringify(data) }),
deleteWorkflow: (publicId: string) =>
request(`/api/workflows/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
runWorkflow: (publicId: string, inputs?: any) =>
request<{ success: boolean; runId: string; run: WorkflowRunSummary }>(`/api/workflows/${encodeURIComponent(publicId)}/run`, {
method: "POST",
body: JSON.stringify({ inputs: inputs || {} }),
timeoutMs: 180_000,
}),
getWorkflowRuns: (params?: { limit?: number }) =>
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) =>
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
};
export const userApi = {
register: (data: { username: string; email: string; password: string }) =>
@@ -1960,6 +2041,18 @@ export const userApi = {
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"),
getModelsMarketplace: (options?: {
refresh?: boolean;
includePricing?: boolean;
}) => {
const params = new URLSearchParams();
if (options?.refresh) params.set("refresh", "1");
if (options?.includePricing) params.set("includePricing", "1");
const query = params.toString();
return userRequest(`/api/user/models/marketplace${query ? `?${query}` : ""}`, {
timeoutMs: options?.refresh ? 45_000 : 15_000,
});
},
getAnnouncements: (params?: string) =>
userRequest(`/api/user/announcements${params ? "?" + params : ""}`),
markAnnouncementRead: (id: number) =>
@@ -1970,4 +2063,24 @@ export const userApi = {
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) =>
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
getWorkflowNodeTypes: () =>
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
getWorkflows: (params?: { include_public?: boolean }) =>
userRequest<{ success: boolean; items: WorkflowSummary[] }>(`/api/user/workflows${buildQueryString(params)}`),
createWorkflow: (data: WorkflowSavePayload) =>
userRequest<{ success: boolean; workflow: WorkflowSummary }>("/api/user/workflows", { method: "POST", body: JSON.stringify(data) }),
updateWorkflow: (publicId: string, data: WorkflowSavePayload) =>
userRequest<{ success: boolean; workflow: WorkflowSummary }>(`/api/user/workflows/${encodeURIComponent(publicId)}`, { method: "PUT", body: JSON.stringify(data) }),
deleteWorkflow: (publicId: string) =>
userRequest(`/api/user/workflows/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
runWorkflow: (publicId: string, inputs?: any) =>
userRequest<{ success: boolean; runId: string; run: WorkflowRunSummary }>(`/api/user/workflows/${encodeURIComponent(publicId)}/run`, {
method: "POST",
body: JSON.stringify({ inputs: inputs || {} }),
timeoutMs: 180_000,
}),
getWorkflowRuns: (params?: { limit?: number }) =>
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) =>
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
};
+13
View File
@@ -43,6 +43,10 @@ const zhToEn: Record<string, string> = {
'仪表盘': 'Dashboard',
'站点': 'Sites',
'账号': 'Accounts',
'连接管理': 'Connection Management',
'用户公告': 'User Announcements',
'OAuth 管理': 'OAuth Management',
'下游密钥': 'Downstream Keys',
'未关联站点': 'Unlinked Site',
'余额': 'Balance',
'令牌管理': 'Account Tokens',
@@ -57,11 +61,20 @@ const zhToEn: Record<string, string> = {
'使用日志': 'Usage Logs',
'暂无使用日志': 'No Usage Logs',
'可用性监控': 'Availability Monitor',
'生图助手': 'Picture Agent',
'数字人': 'Digital Human',
'工作流': 'Workflow',
'视频助手': 'Video Assistant',
'工作空间': 'Workspace',
'系统': 'System',
'设置': 'Settings',
'程序日志': 'System Logs',
'导入/导出': 'Import/Export',
'通知设置': 'Notification Settings',
'概览': 'Overview',
'个人中心': 'Profile',
'使用记录': 'Usage Records',
'创作应用': 'Creative Apps',
'暂无通知': 'No Notifications',
'模型广场': 'Model Marketplace',
'模型广场刷新进行中': 'Marketplace refresh in progress',
+524 -66
View File
@@ -2887,6 +2887,8 @@ textarea {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
overflow: hidden;
}
.picture-agent-panel-head {
@@ -3115,6 +3117,354 @@ textarea {
margin-top: auto;
}
.picture-agent-job-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 56%, var(--color-bg-card));
min-width: 0;
}
.picture-agent-job-summary div {
display: grid;
gap: 2px;
min-width: 0;
}
.picture-agent-job-summary strong {
font-size: 13px;
}
.picture-agent-job-summary span {
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-job-summary .btn {
flex-shrink: 0;
min-height: 30px;
padding: 0 10px;
font-size: 12px;
}
.picture-agent-job-panel {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 56%, var(--color-bg-card));
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.picture-agent-job-head,
.picture-agent-job-row,
.picture-agent-job-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.picture-agent-job-head strong {
font-size: 13px;
}
.picture-agent-job-head-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.picture-agent-job-head-copy span {
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.4;
}
.picture-agent-job-head .btn {
min-height: 28px;
padding: 0 8px;
font-size: 12px;
}
.picture-agent-job-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.picture-agent-job-empty {
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.55;
}
.picture-agent-job-list {
display: grid;
gap: 6px;
min-width: 0;
}
.picture-agent-job {
display: grid;
gap: 5px;
padding: 8px 9px;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-card);
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.picture-agent-job-row-top {
align-items: flex-start;
}
.picture-agent-job-title-wrap {
display: grid;
gap: 2px;
min-width: 0;
}
.picture-agent-job-row strong {
min-width: 0;
overflow: hidden;
display: block;
font-size: 12px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.picture-agent-job-title-wrap span {
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.picture-agent-job-row span {
flex-shrink: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.picture-agent-job-status {
flex-shrink: 0;
font-size: 11px;
line-height: 1.4;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-border-light);
background: color-mix(in srgb, var(--color-bg) 72%, transparent);
}
.picture-agent-job-status-running,
.picture-agent-job-status-pending {
color: var(--color-primary);
}
.picture-agent-job-status-succeeded {
color: var(--color-success);
}
.picture-agent-job-status-failed {
color: var(--color-danger);
}
.picture-agent-job-status-cancelled {
color: var(--color-text-muted);
}
.picture-agent-job-progress-line {
display: grid;
gap: 4px;
}
.picture-agent-job-progress-labels {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.35;
}
.picture-agent-job-progress-labels span,
.picture-agent-job-progress-labels em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-job-progress-labels em {
font-style: normal;
}
.picture-agent-job-progress {
height: 6px;
width: 100%;
min-width: 0;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 52%, transparent);
}
.picture-agent-job-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-primary), var(--color-info));
transition: width 0.22s ease;
}
.picture-agent-job-succeeded .picture-agent-job-progress span {
background: var(--color-success);
}
.picture-agent-job-failed .picture-agent-job-progress span {
background: var(--color-danger);
}
.picture-agent-job-cancelled .picture-agent-job-progress span {
background: var(--color-text-muted);
}
.picture-agent-job-meta {
align-items: flex-start;
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.45;
}
.picture-agent-job-meta span,
.picture-agent-job-meta em {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-job-meta em {
color: var(--color-danger);
font-style: normal;
}
.picture-agent-job-hint {
color: var(--color-warning);
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.picture-agent-job-terminal {
color: var(--color-text-muted);
font-size: 11px;
line-height: 1.45;
}
.picture-agent-job-cancel {
justify-self: end;
border: 1px solid var(--color-border-light);
border-radius: 999px;
padding: 3px 9px;
background: transparent;
color: var(--color-text-muted);
font-size: 11px;
cursor: pointer;
}
.picture-agent-job-cancel:hover:not(:disabled) {
color: var(--color-danger);
border-color: color-mix(in srgb, var(--color-danger) 46%, var(--color-border-light));
}
.picture-agent-job-cancel:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.picture-agent-job-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(2px);
}
.picture-agent-job-dialog {
width: min(820px, calc(100vw - 40px));
max-height: min(760px, calc(100vh - 56px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.picture-agent-job-dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border-light);
}
.picture-agent-job-dialog-head > div:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.picture-agent-job-dialog-head strong {
font-size: 16px;
}
.picture-agent-job-dialog-head span {
color: var(--color-text-muted);
font-size: 12px;
}
.picture-agent-job-list-dialog {
max-height: 100%;
overflow-y: auto;
padding-right: 4px;
}
.picture-agent-job-dialog .picture-agent-job {
padding: 12px;
}
.picture-agent-job-dialog .picture-agent-job-row strong {
max-height: 5.6em;
overflow-y: auto;
}
.picture-agent-canvas {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -3146,9 +3496,10 @@ textarea {
.picture-agent-template-list {
display: flex;
gap: 10px;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
padding: 2px 2px 6px;
scrollbar-width: thin;
}
.picture-agent-template-empty {
@@ -3166,13 +3517,13 @@ textarea {
}
.picture-agent-template-card {
width: 156px;
min-height: 168px;
width: 142px;
min-height: 154px;
flex: 0 0 auto;
display: grid;
grid-template-rows: 112px auto;
gap: 8px;
padding: 8px;
grid-template-rows: 102px auto;
gap: 7px;
padding: 7px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
@@ -3188,7 +3539,7 @@ textarea {
.picture-agent-template-card img {
width: 100%;
height: 112px;
height: 102px;
display: block;
object-fit: cover;
border-radius: 6px;
@@ -3209,12 +3560,12 @@ textarea {
}
.picture-agent-template-card strong {
font-size: 13px;
font-size: 12px;
}
.picture-agent-template-card em {
color: var(--color-text-muted);
font-size: 12px;
font-size: 11px;
font-style: normal;
}
@@ -4287,11 +4638,9 @@ textarea {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
padding: 30px;
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 34%),
radial-gradient(circle at right 18%, rgba(34, 211, 238, 0.16), transparent 30%),
linear-gradient(180deg, #eef4fb 0%, #e9eef7 100%);
linear-gradient(135deg, rgba(219, 234, 254, 0.92) 0%, rgba(248, 250, 252, 0.98) 44%, rgba(224, 242, 254, 0.88) 100%);
}
[data-theme="dark"] .login-shell {
@@ -4302,17 +4651,18 @@ textarea {
}
.login-surface {
width: min(1240px, 100%);
width: min(1280px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.16fr) minmax(360px, 440px);
gap: 24px;
padding: 20px;
grid-template-columns: minmax(620px, 1fr) minmax(360px, 420px);
gap: 18px;
padding: 18px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(244, 248, 253, 0.88));
border: 1px solid rgba(255, 255, 255, 0.58);
border-radius: 36px;
linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(244, 248, 253, 0.94));
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 30px;
box-shadow:
0 24px 60px rgba(148, 163, 184, 0.24),
0 30px 72px rgba(59, 130, 246, 0.16),
0 18px 44px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
@@ -4327,21 +4677,19 @@ textarea {
.login-brand-panel {
position: relative;
min-height: 760px;
padding: 56px 56px 44px;
min-height: 720px;
padding: 54px 58px 42px;
display: flex;
flex-direction: column;
gap: 28px;
gap: 30px;
color: var(--color-text-primary);
border-radius: 30px;
border-radius: 24px;
overflow: hidden;
}
.login-brand-panel-light {
background:
radial-gradient(circle at top left, rgba(96, 165, 250, 0.22), transparent 26%),
radial-gradient(circle at right 20%, rgba(34, 211, 238, 0.16), transparent 22%),
linear-gradient(160deg, #f8fbff 0%, #edf4ff 52%, #e9f0fa 100%);
linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(239, 246, 255, 0.94) 46%, rgba(226, 239, 255, 0.9) 100%);
}
.login-brand-panel::before {
@@ -4369,7 +4717,8 @@ textarea {
.login-brand-header {
display: flex;
align-items: center;
gap: 20px;
gap: 22px;
z-index: 1;
}
.login-brand-summary {
@@ -4402,9 +4751,9 @@ textarea {
}
.brand-mark-frame-hero {
width: 118px;
height: 118px;
border-radius: 36px;
width: 124px;
height: 124px;
border-radius: 32px;
}
.brand-mark-canvas {
@@ -4429,9 +4778,10 @@ textarea {
.login-brand-name {
font-family: 'Avenir Next', 'Segoe UI Variable Display', 'PingFang SC', 'Noto Sans SC', sans-serif;
font-size: clamp(34px, 3.2vw, 46px);
font-size: clamp(38px, 3.6vw, 54px);
font-weight: 800;
letter-spacing: -0.05em;
line-height: 1;
letter-spacing: 0;
}
.login-brand-kicker {
@@ -4444,47 +4794,57 @@ textarea {
.login-brand-copy-block {
display: grid;
max-width: 580px;
max-width: 620px;
z-index: 1;
}
.login-brand-copy {
max-width: 42ch;
max-width: 44ch;
margin: 0;
font-size: 18px;
line-height: 1.92;
font-size: 20px;
line-height: 1.82;
color: var(--color-text-secondary);
}
.login-compat-line {
max-width: 42ch;
max-width: 52ch;
font-size: 13px;
line-height: 1.8;
color: #475569;
z-index: 1;
}
.login-capability-list {
display: grid;
gap: 0;
max-width: 44rem;
max-width: 46rem;
margin-top: auto;
border-top: 1px solid rgba(148, 163, 184, 0.18);
z-index: 1;
}
.login-capability-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 16px;
grid-template-columns: 48px minmax(0, 1fr);
gap: 18px;
align-items: start;
padding: 18px 0;
padding: 20px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
}
.login-capability-index {
padding-top: 3px;
font-size: 11px;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.18em;
color: #64748b;
letter-spacing: 0;
color: #2563eb;
border: 1px solid rgba(37, 99, 235, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.68);
}
.login-capability-content {
@@ -4494,9 +4854,9 @@ textarea {
.login-capability-title {
font-family: 'Avenir Next', 'Segoe UI Variable Display', 'PingFang SC', 'Noto Sans SC', sans-serif;
font-size: 18px;
font-size: 19px;
font-weight: 700;
letter-spacing: -0.03em;
letter-spacing: 0;
color: #0f172a;
}
@@ -4512,6 +4872,7 @@ textarea {
display: flex;
align-items: center;
gap: 12px;
z-index: 1;
}
.login-icon-link {
@@ -4563,21 +4924,21 @@ textarea {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
border-radius: 30px;
padding: 22px;
border-radius: 24px;
background:
radial-gradient(circle at top, rgba(125, 211, 252, 0.16), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.44), rgba(241, 245, 249, 0.58));
linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(241, 245, 249, 0.7));
}
.login-auth-panel {
width: min(100%, 392px);
padding: 40px 34px 28px;
border-radius: 28px;
padding: 38px 34px 28px;
border-radius: 22px;
border: 1px solid rgba(226, 232, 240, 0.92);
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 28px 50px rgba(148, 163, 184, 0.22),
0 24px 44px rgba(37, 99, 235, 0.1),
0 10px 22px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
}
@@ -4594,10 +4955,10 @@ textarea {
.login-auth-title {
margin: 0 0 10px;
font-family: 'Avenir Next', 'Segoe UI Variable Display', 'PingFang SC', 'Noto Sans SC', sans-serif;
font-size: 42px;
font-size: 38px;
font-weight: 800;
line-height: 0.96;
letter-spacing: -0.05em;
line-height: 1.08;
letter-spacing: 0;
}
.login-auth-copy {
@@ -4610,18 +4971,18 @@ textarea {
.login-mode-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
gap: 6px;
padding: 4px;
margin-bottom: 18px;
border: 1px solid color-mix(in srgb, var(--color-primary) 12%, var(--color-border));
border-radius: 16px;
border-radius: 14px;
background: color-mix(in srgb, var(--color-bg) 78%, transparent);
}
.login-mode-tab {
min-height: 38px;
min-height: 40px;
border: 0;
border-radius: 12px;
border-radius: 10px;
background: transparent;
color: var(--color-text-muted);
font-size: 13px;
@@ -4648,7 +5009,7 @@ textarea {
width: 100%;
padding: 14px 16px;
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border));
border-radius: 16px;
border-radius: 12px;
font-size: 14px;
outline: none;
background: rgba(248, 250, 252, 0.92);
@@ -4668,7 +5029,7 @@ textarea {
padding: 13px 0;
font-size: 14px;
font-weight: 700;
border-radius: 16px;
border-radius: 12px;
box-shadow: 0 16px 24px rgba(99, 102, 241, 0.2);
}
@@ -4687,6 +5048,18 @@ textarea {
color: var(--color-text-muted);
}
.login-register-form .login-auth-label {
margin-bottom: 6px;
}
.login-register-form .login-auth-input {
margin-bottom: 12px;
}
.login-register-form .login-auth-submit {
margin-top: 2px;
}
.login-shell[data-variant="user"] .login-surface {
width: min(560px, 100%);
grid-template-columns: minmax(0, 1fr);
@@ -4751,6 +5124,35 @@ textarea {
max-width: 100%;
}
@media (max-height: 900px) and (min-width: 641px) {
.login-shell[data-variant="user"] {
align-items: flex-start;
overflow-y: auto;
}
.login-shell[data-variant="user"] .login-surface {
margin: auto 0;
}
.login-shell[data-variant="user"] .login-brand-panel {
padding-block: 20px 14px;
}
.login-shell[data-variant="user"] .brand-mark-frame-hero {
width: 64px;
height: 64px;
border-radius: 20px;
}
.login-shell[data-variant="user"] .login-brand-name {
font-size: 30px;
}
.login-shell[data-variant="user"] .login-auth-panel {
padding: 30px 30px 24px;
}
}
[data-theme="dark"] .login-surface,
[data-theme="dark"] .brand-mark-frame,
[data-theme="dark"] .brand-mark-canvas,
@@ -7788,3 +8190,59 @@ textarea {
grid-template-columns: 1fr;
}
}
.comfyui-agent-page article p {
margin: 0 0 0.72em;
}
.comfyui-agent-page article p:last-child {
margin-bottom: 0;
}
.comfyui-agent-page article ul,
.comfyui-agent-page article ol {
margin: 0.4em 0 0.8em;
padding-left: 1.35em;
}
.comfyui-agent-page article blockquote {
margin: 0.6em 0;
padding: 0.2em 0 0.2em 0.9em;
border-left: 3px solid color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
color: var(--color-text-secondary);
}
.comfyui-agent-page article pre {
max-width: 100%;
overflow: auto;
margin: 0.75em 0;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-bg) 82%, #111827);
border: 1px solid var(--color-border-light);
}
.comfyui-agent-page article code {
font-family: var(--font-mono);
font-size: 0.92em;
}
.comfyui-agent-page article :not(pre) > code {
padding: 0.12em 0.35em;
border-radius: 5px;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.comfyui-agent-page article table {
display: block;
max-width: 100%;
overflow: auto;
border-collapse: collapse;
margin: 0.8em 0;
}
.comfyui-agent-page article th,
.comfyui-agent-page article td {
border: 1px solid var(--color-border-light);
padding: 6px 8px;
}
+8 -8
View File
@@ -7,8 +7,8 @@ const FEATURES = [
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
{ icon: 'CU', title: 'ComfyUI 工作台', desc: '内置多租户 ComfyUI 容器池,支持用 BoosAPI 模型驱动文本、图像和视频节点。' },
{ icon: 'AG', title: 'ComfyUI Agent', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
@@ -17,18 +17,18 @@ const FEATURES = [
const TECH_STACK = [
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和容器生命周期管理。' },
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' },
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
{ name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
];
const ENDPOINTS = [
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' },
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
@@ -56,10 +56,10 @@ export default function About() {
</div>
</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、ComfyUI、图像生成、视频任务和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和 ComfyUI 节点都能使用后台已经配置好的模型。')}
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI', 'Picture Agent', 'Volcengine Video'].map((tag) => (
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
<span
key={tag}
style={{
@@ -171,7 +171,7 @@ export default function About() {
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、ComfyUI 容器与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
</div>
</div>
</div>
+77
View File
@@ -493,7 +493,37 @@ export default function Accounts() {
credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
});
const createdAccountIds = result?.batch
? Array.isArray(result?.data)
? result.data
.map((item: any) => Number(item?.id))
.filter((id: number) => Number.isFinite(id) && id > 0)
: []
: Number(result?.id) > 0
? [Number(result.id)]
: [];
if (result?.batch) {
if (
activeSegment === "apikey" &&
createdAccountIds.length > 0
) {
try {
const syncResults = await Promise.allSettled(
createdAccountIds.map((accountId: number) =>
api.checkModels(accountId),
),
);
const syncedCount = syncResults.filter(
(item) => item.status === "fulfilled",
).length;
if (syncedCount > 0) {
await api.rebuildRoutes(false, false);
toast.success("批量 API Key 已自动同步模型并重建路由");
}
} catch (syncErr: any) {
toast.error(syncErr?.message || "批量连接已添加,但自动建路由失败");
}
}
closeAddPanel();
const createdCount = Number(result.createdCount) || 0;
const failedCount = Number(result.failedCount) || 0;
@@ -527,6 +557,7 @@ export default function Accounts() {
createdAccountId,
recommendedModels,
);
await api.rebuildRoutes(false, false);
seededRecommendedModels = true;
} catch (seedErr: any) {
toast.error(seedErr?.message || "连接已添加,但推荐模型补录失败");
@@ -549,6 +580,52 @@ export default function Accounts() {
`已补入 ${recommendedModels.length} 个推荐模型并重建路由`,
);
}
const shouldAutoSyncModels =
credentialMode === "apikey" &&
!tokenForm.skipModelFetch &&
createdAccountIds.length > 0;
if (shouldAutoSyncModels) {
if (
!result?.batch &&
verifyResult?.success &&
Array.isArray(verifyResult.models) &&
verifyResult.models.length > 0
) {
try {
await api.addAccountAvailableModels(
createdAccountIds[0],
verifyResult.models,
);
await api.rebuildRoutes(false, false);
toast.success("已自动同步模型并重建路由");
} catch (syncErr: any) {
toast.error(syncErr?.message || "连接已添加,但模型同步失败");
}
} else {
const syncResults = await Promise.allSettled(
createdAccountIds.map((accountId: number) =>
api.checkModels(accountId),
),
);
const syncedCount = syncResults.filter(
(item) => item.status === "fulfilled",
).length;
const failedCount = syncResults.length - syncedCount;
if (syncedCount > 0) {
try {
await api.rebuildRoutes(false, false);
toast.success(
`已自动同步 ${syncedCount} 个连接的模型并重建路由`,
);
} catch (rebuildErr: any) {
toast.error(rebuildErr?.message || "模型已同步,但路由重建失败");
}
}
if (failedCount > 0) {
toast.error(`有 ${failedCount} 个连接同步模型失败`);
}
}
}
load(true);
} catch (e: any) {
toast.error(e.message || "添加失败");
+93 -135
View File
@@ -1,16 +1,20 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const [iframeSrc, setIframeSrc] = React.useState('');
const [starting, setStarting] = React.useState(true);
const [message, setMessage] = React.useState('正在启动 ComfyUI...');
const [error, setError] = React.useState('');
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const [available, setAvailable] = React.useState(false);
const [message, setMessage] = React.useState('正在检查外部 ComfyUI...');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const openFullscreen = () => {
window.open('/api/comfyui/proxy/', '_blank', 'noopener,noreferrer');
};
const reloadFrame = () => {
if (iframeRef.current) {
iframeRef.current.src = '/api/comfyui/proxy/';
}
};
React.useEffect(() => {
const el = document.documentElement;
@@ -23,121 +27,25 @@ export default function ComfyUI() {
React.useEffect(() => {
let cancelled = false;
const encodedToken = token ? encodeURIComponent(token) : '';
const requestTimeoutMs = 35_000;
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), requestTimeoutMs);
try {
return await fetch(input, { ...init, signal: controller.signal });
} finally {
window.clearTimeout(timer);
}
};
const resolveIframeFromPayload = (payload: any) => {
const comfyui = payload?.comfyui || payload || {};
const mode = String(comfyui?.mode || '').toLowerCase();
const status = String(comfyui?.status || '').toLowerCase();
const lastError = String(comfyui?.lastError || comfyui?.last_error || '').trim();
if (mode === 'container') {
if (status === 'running') return `/comfy/?token=${encodedToken}`;
if (status === 'unhealthy' || status === 'failed') {
throw new Error(lastError || 'ComfyUI 容器启动失败');
}
return '';
}
if (status && status !== 'disabled') {
return '/api/comfyui/proxy/';
}
if (lastError) {
throw new Error(lastError);
}
return '';
};
if (!token) {
setIframeSrc('');
setStarting(false);
setMessage('登录状态已过期,请重新登录。');
setError('');
return () => {
cancelled = true;
};
}
setStarting(true);
setIframeSrc('');
setError('');
setMessage('正在启动 ComfyUI...');
const start = async () => {
try {
const response = await fetchWithTimeout('/api/video-agent/start', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!cancelled) {
if (!response.ok) {
throw new Error(`启动接口返回 ${response.status}`);
}
const payload = await response.json().catch(() => null);
const nextSrc = resolveIframeFromPayload(payload);
if (nextSrc) {
setIframeSrc(nextSrc);
return;
}
}
} catch (e: any) {
if (!cancelled) {
setMessage('ComfyUI 启动较慢,正在检查状态...');
if (e?.name !== 'AbortError') setError(e?.message || '启动请求失败');
}
}
const deadline = Date.now() + 120_000;
while (!cancelled && Date.now() < deadline) {
try {
const response = await fetchWithTimeout('/api/video-agent/status', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const payload = await response.json().catch(() => null);
const nextSrc = resolveIframeFromPayload(payload);
if (nextSrc) {
setIframeSrc(nextSrc);
return;
}
setMessage('ComfyUI 仍在启动,等待容器就绪...');
} else {
setError(`状态接口返回 ${response.status}`);
}
} catch (e: any) {
setError(e?.name === 'AbortError' ? '状态检查超时' : (e?.message || '状态检查失败'));
}
await new Promise((resolve) => window.setTimeout(resolve, 2000));
}
if (!cancelled) {
setStarting(false);
setMessage('ComfyUI 启动超时');
setError((current) => current || '请检查 /api/video-agent/status、Docker 容器日志和 4000 后端进程。');
}
};
void start().finally(() => {
if (!cancelled) setStarting(false);
});
const controller = new AbortController();
void fetch('/api/comfyui/proxy/', { method: 'GET', signal: controller.signal })
.then((response) => {
if (cancelled) return;
setAvailable(response.ok);
setMessage(response.ok
? ''
: '外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
})
.catch((err) => {
if (cancelled || err?.name === 'AbortError') return;
setAvailable(false);
setMessage('外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
});
return () => {
cancelled = true;
controller.abort();
};
}, [token]);
}, []);
return (
<div
@@ -149,15 +57,23 @@ export default function ComfyUI() {
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
{iframeSrc ? (
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>外部 ComfyUI</div>
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!available} style={toolbarButtonStyle}>
刷新
</button>
<button type="button" onClick={openFullscreen} disabled={!available} style={primaryToolbarButtonStyle}>
新窗口打开
</button>
</div>
</div>
{available ? (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
ref={iframeRef}
src="/api/comfyui/proxy/"
style={{ width: '100%', height: '100%', border: 'none' }}
title="External ComfyUI"
allow="clipboard-read; clipboard-write"
/>
) : (
@@ -168,16 +84,58 @@ export default function ComfyUI() {
color: 'var(--color-text-muted)',
fontSize: 14,
textAlign: 'center',
gap: 8,
padding: 24,
}}>
<div>{starting ? message : message}</div>
{error && (
<div style={{ color: '#ef4444', fontSize: 12, maxWidth: 520 }}>
{error}
<div>
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 8 }}>
内置 Docker ComfyUI 工作台已移除
</div>
)}
<div style={{ maxWidth: 560, lineHeight: 1.7 }}>{message}</div>
</div>
</div>
)}
</div>
);
}
const toolbarStyle: React.CSSProperties = {
minHeight: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid var(--color-border-light)',
background: 'var(--color-surface)',
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const toolbarActionsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const toolbarButtonStyle: React.CSSProperties = {
minHeight: 30,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '5px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: 12,
};
const primaryToolbarButtonStyle: React.CSSProperties = {
...toolbarButtonStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};
File diff suppressed because it is too large Load Diff
+340
View File
@@ -0,0 +1,340 @@
import React, { CSSProperties, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { fetchSessionResponse } from '../api.js';
import type { SessionAudience } from '../authSession.js';
type Mode = 'audio' | 'image' | 'text';
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
];
const MODEL_PRESETS = [
'longcat-video-avatar',
'doubao-seedance-1-0-lite-i2v-250428',
'doubao-seedance-1-0-lite-t2v-250428',
'wan2-1-14b-i2v-250225',
];
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
async function parseError(response: Response) {
const text = await response.text().catch(() => '');
if (!text) return `HTTP ${response.status}`;
try {
const value = JSON.parse(text);
return value?.error?.message || value?.message || text;
} catch {
return text;
}
}
function extractTaskId(value: any): string {
return value?.response?.id
|| value?.response?.task_id
|| value?.response?.data?.id
|| value?.response?.data?.task_id
|| value?.id
|| value?.task_id
|| '';
}
function copyText(value: string) {
if (!value) return;
void navigator.clipboard?.writeText(value);
}
export default function DigitalHuman() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [mode, setMode] = useState<Mode>('audio');
const [model, setModel] = useState(MODEL_PRESETS[0]);
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
const [imageUrl, setImageUrl] = useState('');
const [audioUrl, setAudioUrl] = useState('');
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
const [resolution, setResolution] = useState('720p');
const [ratio, setRatio] = useState('9:16');
const [duration, setDuration] = useState(8);
const [refImageIndex, setRefImageIndex] = useState(0);
const [audioCfg, setAudioCfg] = useState(4.5);
const [segments, setSegments] = useState(false);
const [multiPerson, setMultiPerson] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<any>(null);
const bodyPreview = useMemo(() => ({
model: model.trim(),
prompt: [
prompt.trim(),
script.trim() ? `台词:${script.trim()}` : '',
`风格:${voiceStyle}`,
].filter(Boolean).join('\n'),
mode,
ratio,
resolution,
duration,
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
audio_cfg: audioCfg,
ref_img_index: refImageIndex,
segmented: segments,
multi_person: multiPerson,
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
const submit = async () => {
setSubmitting(true);
setError('');
setResult(null);
try {
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', {
method: 'POST',
body: JSON.stringify(bodyPreview),
timeoutMs: 300_000,
});
if (!response.ok) throw new Error(await parseError(response));
setResult(await response.json());
} catch (err: any) {
setError(err?.message || '数字人任务创建失败');
} finally {
setSubmitting(false);
}
};
const taskId = extractTaskId(result);
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title">数字人</h1>
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
{submitting ? '创建中...' : '创建数字人任务'}
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div style={layoutStyle}>
<section style={panelStyle}>
<div style={modeGridStyle}>
{MODE_OPTIONS.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setMode(item.value)}
style={mode === item.value ? activeModeStyle : modeStyle}
>
<strong>{item.label}</strong>
<span>{item.description}</span>
</button>
))}
</div>
<div style={formGridStyle}>
<label style={fieldStyle}>
<span>模型</span>
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
<datalist id="digital-human-models">
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
</datalist>
</label>
<label style={fieldStyle}>
<span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
<option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select>
</label>
<label style={fieldStyle}>
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="480p">480p</option>
</select>
</label>
<label style={fieldStyle}>
<span>时长(秒)</span>
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
</label>
</div>
<label style={fieldStyle}>
<span>人物/场景描述</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
</label>
<label style={fieldStyle}>
<span>台词/脚本</span>
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
</label>
<div style={formGridStyle}>
{mode !== 'text' && (
<label style={fieldStyle}>
<span>人物图 URL</span>
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
)}
{mode === 'audio' && (
<label style={fieldStyle}>
<span>音频 URL</span>
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
)}
<label style={fieldStyle}>
<span>口播风格</span>
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label style={fieldStyle}>
<span>ref_img_index</span>
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
</label>
<label style={fieldStyle}>
<span>audio CFG</span>
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
</label>
</div>
<div style={toggleRowStyle}>
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
</div>
</section>
<aside style={panelStyle}>
<h2 style={sideTitleStyle}>任务请求</h2>
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
{result ? (
<>
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
</>
) : (
<div className="empty-state" style={{ minHeight: 160 }}>
<div className="empty-state-title">暂无任务</div>
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
</div>
)}
</aside>
</div>
</div>
);
}
const layoutStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.25fr) minmax(320px, 0.75fr)',
gap: 16,
};
const panelStyle: CSSProperties = {
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
borderRadius: 8,
padding: 16,
};
const modeGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 10,
marginBottom: 16,
};
const modeStyle: CSSProperties = {
textAlign: 'left',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
borderRadius: 8,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 6,
color: 'var(--color-text-primary)',
};
const activeModeStyle: CSSProperties = {
...modeStyle,
borderColor: 'var(--color-primary)',
boxShadow: '0 0 0 2px color-mix(in srgb, var(--color-primary) 15%, transparent)',
};
const formGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
marginBottom: 12,
};
const fieldStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
fontSize: 12,
color: 'var(--color-text-secondary)',
};
const inputStyle: CSSProperties = {
width: '100%',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
borderRadius: 6,
padding: '10px 12px',
fontSize: 13,
};
const textareaStyle: CSSProperties = {
...inputStyle,
minHeight: 110,
resize: 'vertical',
};
const toggleRowStyle: CSSProperties = {
display: 'flex',
gap: 16,
flexWrap: 'wrap',
fontSize: 13,
color: 'var(--color-text-secondary)',
};
const sideTitleStyle: CSSProperties = {
margin: '0 0 10px',
fontSize: 14,
};
const codeStyle: CSSProperties = {
maxHeight: 280,
overflow: 'auto',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: 12,
fontSize: 12,
whiteSpace: 'pre-wrap',
};
const taskIdStyle: CSSProperties = {
marginBottom: 8,
padding: '8px 10px',
borderRadius: 6,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-text-primary)',
fontSize: 12,
};
+151 -86
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../api.js';
import { api, userApi } from '../api.js';
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { useToast } from '../components/Toast.js';
@@ -125,6 +125,24 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
return `${pricing.perCallTotal ?? 0} USD / call`;
}
async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
const PAGE_SIZES = [10, 20, 50];
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
@@ -153,6 +171,9 @@ function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'a
/* ---- component ---- */
export default function Models() {
const toast = useToast();
const location = useLocation();
const isUserView = location.pathname.startsWith('/user/');
const marketplaceApi = isUserView ? userApi : api;
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -172,7 +193,6 @@ export default function Models() {
const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220);
const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0);
const location = useLocation();
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const model of data.models) {
@@ -192,7 +212,7 @@ export default function Models() {
setMetadataHydrating(false);
setLoading(true);
try {
const res = await api.getModelsMarketplace({
const res = await marketplaceApi.getModelsMarketplace({
refresh,
includePricing: false,
});
@@ -216,7 +236,7 @@ export default function Models() {
setLoading(false);
}
}
}, [toast]);
}, [marketplaceApi, toast]);
const hydrateMarketplaceMetadata = useCallback(async (baseModels: ModelRow[]) => {
if (!shouldHydrateMarketplaceMetadata(baseModels)) return;
@@ -225,7 +245,7 @@ export default function Models() {
const baseRequestId = latestPrimaryRequestRef.current;
setMetadataHydrating(true);
try {
const res = await api.getModelsMarketplace({
const res = await marketplaceApi.getModelsMarketplace({
includePricing: true,
});
if (metadataRequestId !== latestMetadataRequestRef.current) return;
@@ -244,7 +264,7 @@ export default function Models() {
setMetadataHydrating(false);
}
}
}, []);
}, [marketplaceApi]);
useEffect(() => {
if (!isMobile) return;
@@ -311,6 +331,7 @@ export default function Models() {
/* ---- derived: site list ---- */
const siteMap = useMemo(() => {
if (isUserView) return [];
const m = new Map<string, number>();
for (const model of data.models) {
for (const a of model.accounts) {
@@ -318,7 +339,7 @@ export default function Models() {
}
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
}, [data.models]);
}, [data.models, isUserView]);
/* ---- filtered ---- */
const filteredModels = useMemo(() => {
@@ -366,8 +387,8 @@ export default function Models() {
};
}) : filteredModels;
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
}, [filteredModels, activeSite, sortBy, sortDir]);
return [...scopedModels].sort((a, b) => compareModels(a, b, isUserView ? 'name' : sortBy, isUserView ? 'asc' : sortDir));
}, [filteredModels, activeSite, sortBy, sortDir, isUserView]);
/* ---- pagination ---- */
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
@@ -395,10 +416,15 @@ export default function Models() {
: null;
/* ---- copy ---- */
const copyName = (name: string) => {
navigator.clipboard.writeText(name).catch(() => { });
setCopied(name);
setTimeout(() => setCopied(null), 1500);
const copyName = async (name: string) => {
try {
await copyTextToClipboard(name);
setCopied(name);
toast.success(tr('已复制模型名'));
setTimeout(() => setCopied(null), 1500);
} catch (error: any) {
toast.error(error?.message || tr('复制模型名失败'));
}
};
const filterControls = (
@@ -441,27 +467,29 @@ export default function Models() {
)}
</div>
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
{!isUserView && (
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
))}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
)}
<div className="filter-panel-section">
{!isUserView && <div className="filter-panel-section">
<div className="filter-panel-title">{tr('排序方式')}</div>
{[
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
@@ -490,7 +518,7 @@ export default function Models() {
)}
</div>
))}
</div>
</div>}
</>
);
@@ -626,15 +654,17 @@ export default function Models() {
/>
</div>
{/* Quick stats */}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span>
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
</div>
{!isUserView && (
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span>
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
</div>
)}
</div>
{/* Empty */}
@@ -660,14 +690,14 @@ export default function Models() {
<div className="model-card-info">
<div className="model-card-name">{m.name}</div>
<div className="model-card-meta">
<span>
{!isUserView && <span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
{m.accountCount} {tr('个账号')}
</span>
<span>
</span>}
{!isUserView && <span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
{m.tokenCount} {tr('令牌')}
</span>
</span>}
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontVariantNumeric: 'tabular-nums' }}
@@ -685,7 +715,16 @@ export default function Models() {
</div>
</div>
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" data-tooltip={tr('复制模型名')} aria-label={tr('复制模型名')} onClick={() => copyName(m.name)}>
<button
type="button"
className="model-card-action-btn"
data-tooltip={tr('复制模型名')}
aria-label={tr('复制模型名')}
onClick={(event) => {
event?.stopPropagation?.();
void copyName(m.name);
}}
>
{copied === m.name ? (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
@@ -709,7 +748,7 @@ export default function Models() {
{getBrand(m.name) && (
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
)}
{m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
{!isUserView && m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
<span key={site} className="model-tag model-tag-blue">{site}</span>
))}
{m.successRate != null && m.successRate >= 90 && (
@@ -750,35 +789,48 @@ export default function Models() {
</div>
</div>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
{m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => (
<div
key={`${source.siteId}-${source.accountId}`}
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
{!isUserView && (
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
{m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => (
<div
key={`${source.siteId}-${source.accountId}`}
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
{isUserView ? tr('隐藏账号详情') : <><SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}</>}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
<span key={group} className="badge badge-info">
{group}: {renderGroupPricingValue(pricing)}
</span>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
<span key={group} className="badge badge-info">
{group}: {renderGroupPricingValue(pricing)}
</span>
))}
</div>
</div>
))}
</div>
) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
)}
</div>
))}
</div>
) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
)}
</div>
)}
</div>
{isMobile ? (
{isUserView ? (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('品牌')}</div>
<div className="card" style={{ padding: 10 }}>
{getBrand(m.name) ? (
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
) : (
<span className="badge badge-muted">{tr('未识别品牌')}</span>
)}
</div>
</div>
) : isMobile ? (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('账号明细')}</div>
{m.accounts.map((a) => (
@@ -828,8 +880,8 @@ export default function Models() {
<tbody>
{m.accounts.map(a => (
<tr key={a.id}>
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td>{isUserView ? <span className="badge badge-muted">{tr('隐藏')}</span> : <SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} />}</td>
<td style={{ fontSize: 12 }}>{isUserView ? tr('隐藏') : (a.username || `ID:${a.id}`)}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
@@ -864,12 +916,16 @@ export default function Models() {
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
{!isUserView && (
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
)}
{!isUserView && (
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
)}
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
@@ -893,8 +949,8 @@ export default function Models() {
{m.name}
</code>
</td>
<td><span className="badge badge-info">{m.accountCount}</span></td>
<td><span className="badge badge-muted">{m.tokenCount}</span></td>
{!isUserView && <td><span className="badge badge-info">{m.accountCount}</span></td>}
{!isUserView && <td><span className="badge badge-muted">{m.tokenCount}</span></td>}
<td>
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
@@ -912,7 +968,16 @@ export default function Models() {
</span>
</td>
<td onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}>
<button
type="button"
className="model-card-action-btn"
data-tooltip={tr('复制')}
aria-label={tr('复制')}
onClick={(event) => {
event?.stopPropagation?.();
void copyName(m.name);
}}
>
{copied === m.name ? (
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
@@ -923,7 +988,7 @@ export default function Models() {
</tr>
{isExpanded ? (
<tr className="log-detail-row">
<td colSpan={7} style={{ padding: 0 }}>
<td colSpan={isUserView ? 5 : 7} style={{ padding: 0 }}>
<div className="anim-collapse is-open">
<div className="anim-collapse-inner">
<div style={{ padding: '12px 16px 12px 54px' }}>
+390 -13
View File
@@ -13,6 +13,36 @@ type AgentImage = {
};
type PictureMode = 'generate' | 'edit';
type PictureJobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
type PictureJob = {
id: string;
status: PictureJobStatus | string;
model?: string | null;
message?: string | null;
error?: string | null;
progress?: number;
progressTotal?: number;
result?: {
prompt?: string;
model?: string;
mode?: PictureMode | string;
response?: any;
images?: any[];
};
createdAt?: string;
updatedAt?: string;
finishedAt?: string | null;
};
function staleRunningHint(job: PictureJob) {
if (job.status !== 'pending' && job.status !== 'running') return '';
const updatedAt = parseServerTime(job.updatedAt || job.createdAt);
if (!Number.isFinite(updatedAt)) return '';
const ageMs = Date.now() - updatedAt;
if (ageMs < 180_000) return '';
return '图片模型响应时间较长,任务仍在后台等待。大型提示词或高峰期通常需要 3-5 分钟。';
}
type PictureAgentStatus = {
success?: boolean;
@@ -47,6 +77,7 @@ type GalleryItem = {
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
const TEMPLATE_LIMIT = 144;
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
const ASPECT_OPTIONS = [
{ value: '1:1', label: '1:1 方图', width: 1024, height: 1024 },
@@ -84,7 +115,13 @@ function makeId() {
}
function extractImageUrls(payload: any): string[] {
const data = Array.isArray(payload?.data) ? payload.data : [];
const data = Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.response?.data)
? payload.response.data
: Array.isArray(payload?.images)
? payload.images
: [];
return data
.map((item: any) => {
if (typeof item?.url === 'string' && item.url.trim()) return item.url.trim();
@@ -112,6 +149,28 @@ function getFilename(image: AgentImage) {
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
}
function parseServerTime(value?: string | null): number {
if (!value) return Number.NaN;
const trimmed = value.trim();
if (!trimmed) return Number.NaN;
const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
const withTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
? normalized
: `${normalized}Z`;
return Date.parse(withTimezone);
}
function formatLocalTime(value: number | string | null | undefined): string {
const timestamp = typeof value === 'number' ? value : parseServerTime(value);
if (!Number.isFinite(timestamp)) return '-';
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function clampDimension(value: number) {
if (!Number.isFinite(value)) return 1024;
return Math.min(4096, Math.max(256, Math.round(value)));
@@ -168,7 +227,62 @@ function templateSize(prompt: string) {
return 'auto';
}
function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate[] {
function isPictureModel(model: string) {
const normalized = model.trim().toLowerCase();
if (!normalized) return false;
const imageKeywords = [
'gpt-image',
'dall-e',
'dalle',
'midjourney',
'recraft',
'kolors',
'cogview',
'seedream',
'sdxl',
'stable-diffusion',
'stablediffusion',
'pixart',
'playground',
'firefly',
'imagen',
'flux',
'image',
'img',
'canvas',
'painting',
'draw',
];
if (imageKeywords.some((keyword) => normalized.includes(keyword))) return true;
const chatKeywords = [
'claude',
'chatgpt',
'gpt-4',
'gpt-3',
'gpt-5',
'o1',
'o3',
'o4',
'gemini',
'deepseek',
'qwen',
'llama',
'mistral',
'command',
'kimi',
'doubao',
'glm',
'hunyuan',
'spark',
'baichuan',
'ernie',
'wenxin',
'grok',
];
return !chatKeywords.some((keyword) => normalized.includes(keyword));
}
function normalizeGalleryItems(items: GalleryItem[], limit = TEMPLATE_LIMIT): PromptTemplate[] {
return items
.map<PromptTemplate | null>((item) => {
const prompt = (item.prompt || '').trim();
@@ -194,7 +308,7 @@ function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate
.slice(0, limit);
}
async function loadGalleryTemplatesDirectly(limit = 48): Promise<PromptTemplate[]> {
async function loadGalleryTemplatesDirectly(limit = TEMPLATE_LIMIT): Promise<PromptTemplate[]> {
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
const items = await response.json();
@@ -221,20 +335,68 @@ export default function PictureAgent() {
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState('');
const [images, setImages] = useState<AgentImage[]>([]);
const [jobs, setJobs] = useState<PictureJob[]>([]);
const [selectedId, setSelectedId] = useState('');
const [loading, setLoading] = useState(false);
const [cancellingJobId, setCancellingJobId] = useState('');
const [clearingJobs, setClearingJobs] = useState(false);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
const [bootError, setBootError] = useState('');
const [error, setError] = useState('');
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
const resolvedSize = buildSize(aspect, customWidth, customHeight);
const latestJob = jobs[0] ?? null;
const latestJobLabel = latestJob?.status === 'running'
? '正在处理最新任务'
: latestJob?.status === 'pending'
? '最新任务已排队'
: latestJob?.status === 'failed'
? '最新任务失败'
: latestJob?.status === 'cancelled'
? '最新任务已取消'
: latestJob?.status === 'succeeded'
? '最新任务已完成'
: '暂无最新任务';
const terminalJobs = useMemo(
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
[jobs],
);
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
return fetchSessionResponse(audience, url, options);
}, [audience]);
const hydrateImagesFromJobs = useCallback((items: PictureJob[]) => {
const completed = items.filter((job) => job.status === 'succeeded');
if (!completed.length) return;
setImages((current) => {
const seen = new Set(current.map((image) => image.url));
const next: AgentImage[] = [];
for (const job of completed) {
const urls = extractImageUrls(job.result || {});
for (const url of urls) {
if (seen.has(url)) continue;
seen.add(url);
next.push({
id: makeId(),
url,
prompt: job.result?.prompt || '',
model: job.result?.model || model || 'image',
mode: job.result?.mode === 'edit' ? 'edit' : 'generate',
createdAt: parseServerTime(job.finishedAt) || Date.now(),
});
}
}
if (!next.length) return current;
setSelectedId((selected) => selected || next[0]?.id || '');
return [...next, ...current].slice(0, 48);
});
}, [model]);
useEffect(() => {
let ignore = false;
async function load() {
@@ -247,10 +409,11 @@ export default function PictureAgent() {
setTemplatesLoading(true);
setTemplatesError('');
try {
const [statusRes, modelsRes, templatesRes] = await Promise.all([
const [statusRes, modelsRes, templatesRes, jobsRes] = await Promise.all([
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
authedFetch('/api/picture-agent/templates?limit=48', { timeoutMs: 30_000 }),
authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 30_000 }),
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 }),
]);
if (!statusRes.ok) throw new Error(await parseError(statusRes));
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
@@ -262,17 +425,32 @@ export default function PictureAgent() {
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
} else {
try {
nextTemplates = await loadGalleryTemplatesDirectly(48);
nextTemplates = await loadGalleryTemplatesDirectly(TEMPLATE_LIMIT);
} catch {
setTemplatesError(await parseError(templatesRes));
}
}
let nextJobs: PictureJob[] = [];
if (jobsRes.ok) {
const jobsPayload = await jobsRes.json();
nextJobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : [];
}
if (ignore) return;
const availableModels = Array.isArray(nextModels?.models) ? nextModels.models : [];
const availableModels = Array.isArray(nextModels?.models)
? nextModels.models.filter((item: string) => isPictureModel(item))
: [];
setStatus(nextStatus);
setModels(availableModels);
setTemplates(nextTemplates);
setModel((current) => current || nextModels?.defaultModel || availableModels[0] || 'gpt-image-2');
setJobs(nextJobs);
hydrateImagesFromJobs(nextJobs);
setModel((current) => {
if (current && isPictureModel(current)) return current;
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
? nextModels.defaultModel
: availableModels[0];
return fallback || 'gpt-image-2';
});
} catch (err: any) {
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
} finally {
@@ -283,7 +461,36 @@ export default function PictureAgent() {
return () => {
ignore = true;
};
}, [audience, authedFetch, token]);
}, [audience, authedFetch, hydrateImagesFromJobs, token]);
useEffect(() => {
if (!activeJobs.length || !token) return;
let stopped = false;
const timer = window.setInterval(async () => {
try {
const updated = await Promise.all(activeJobs.map(async (job) => {
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(job.id)}`, { timeoutMs: 15_000 });
if (!response.ok) return job;
const payload = await response.json();
return payload?.job || job;
}));
if (stopped) return;
setJobs((current) => {
const map = new Map(current.map((job) => [job.id, job]));
for (const job of updated) map.set(job.id, job);
const next = Array.from(map.values()).sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
hydrateImagesFromJobs(next);
return next;
});
} catch {
// Keep polling quietly; task status is also visible after refresh.
}
}, 1800);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [activeJobs, authedFetch, hydrateImagesFromJobs, token]);
useEffect(() => {
if (!sourceFile) {
@@ -322,7 +529,7 @@ export default function PictureAgent() {
timeoutMs: 180_000,
});
} else {
response = await authedFetch('/api/picture-agent/images/generations', {
response = await authedFetch('/api/picture-agent/jobs', {
method: 'POST',
body: JSON.stringify({
model: model.trim(),
@@ -330,12 +537,17 @@ export default function PictureAgent() {
size: resolvedSize,
quality,
}),
timeoutMs: 180_000,
timeoutMs: 30_000,
});
}
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
if (mode === 'generate' && payload?.job) {
setJobs((current) => [payload.job, ...current.filter((job) => job.id !== payload.job.id)].slice(0, 20));
setError('');
return;
}
const urls = extractImageUrls(payload);
if (urls.length === 0) throw new Error('上游没有返回图片 URL 或 base64 图片。');
@@ -357,6 +569,56 @@ export default function PictureAgent() {
}
};
const refreshJobs = async () => {
const response = await authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 });
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
const nextJobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
setJobs(nextJobs);
hydrateImagesFromJobs(nextJobs);
};
const clearJobs = async () => {
if (clearingJobs) return;
setClearingJobs(true);
setError('');
try {
const response = await authedFetch('/api/picture-agent/jobs/clear', {
method: 'POST',
timeoutMs: 15_000,
});
if (!response.ok) throw new Error(await parseError(response));
await refreshJobs();
} catch (err: any) {
setError(err?.message || '清空日志失败');
} finally {
setClearingJobs(false);
}
};
const cancelJob = async (jobId: string) => {
if (!jobId || cancellingJobId) return;
setCancellingJobId(jobId);
setError('');
try {
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(jobId)}/cancel`, {
method: 'POST',
timeoutMs: 15_000,
});
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
if (payload?.job) {
setJobs((current) => current.map((job) => job.id === jobId ? payload.job : job));
} else {
await refreshJobs();
}
} catch (err: any) {
setError(err?.message || '取消任务失败');
} finally {
setCancellingJobId('');
}
};
const handleDownload = (image: AgentImage | null) => {
if (!image) return;
const link = document.createElement('a');
@@ -390,6 +652,59 @@ export default function PictureAgent() {
setError('');
};
const renderJobCard = (job: PictureJob) => {
const total = Math.max(1, Number(job.progressTotal || 4));
const progress = Math.min(total, Math.max(0, Number(job.progress || 0)));
const percent = Math.round((progress / total) * 100);
const hint = staleRunningHint(job);
const isActive = job.status === 'pending' || job.status === 'running';
const isFinished = !isActive;
const statusText = job.status === 'succeeded'
? '完成'
: job.status === 'failed'
? '失败'
: job.status === 'cancelled'
? '已取消'
: `${percent}%`;
return (
<div key={job.id} className={`picture-agent-job picture-agent-job-${job.status}`}>
<div className="picture-agent-job-row picture-agent-job-row-top">
<div className="picture-agent-job-title-wrap">
<strong>{job.result?.prompt || job.message || '生图任务'}</strong>
<span>{job.model || job.result?.model || '未知模型'}</span>
</div>
<span className={`picture-agent-job-status picture-agent-job-status-${job.status}`}>{statusText}</span>
</div>
<div className="picture-agent-job-progress-line">
<div className="picture-agent-job-progress-labels">
<span>{job.message || job.id}</span>
<em>{percent}%</em>
</div>
<div className="picture-agent-job-progress">
<span style={{ width: `${percent}%` }} />
</div>
</div>
<div className="picture-agent-job-meta">
<span>{formatLocalTime(job.createdAt)}</span>
{job.error ? <em>{job.error}</em> : <span>{isFinished ? '已结束' : '进行中'}</span>}
</div>
{hint ? <div className="picture-agent-job-hint">{hint}</div> : null}
{isActive ? (
<button
type="button"
className="picture-agent-job-cancel"
disabled={cancellingJobId === job.id}
onClick={() => void cancelJob(job.id)}
>
{cancellingJobId === job.id ? '取消中...' : '取消任务'}
</button>
) : (
<div className="picture-agent-job-terminal">{job.result?.images?.length ? `结果 ${job.result.images.length} 张` : '可在历史结果中查看'}</div>
)}
</div>
);
};
if (bootError) {
return (
<div className="empty-state" style={{ minHeight: 320 }}>
@@ -539,8 +854,18 @@ export default function PictureAgent() {
{error && <div className="alert alert-error">{error}</div>}
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
{loading ? '处理中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
</button>
<div className="picture-agent-job-summary">
<div>
<strong>生图进度</strong>
<span>{latestJobLabel} · {jobs.length} 条记录</span>
</div>
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(true)}>
查看进度
</button>
</div>
</aside>
<section className="picture-agent-canvas">
@@ -581,7 +906,7 @@ export default function PictureAgent() {
<div className="picture-agent-board-meta">
<div>
<strong>{resultLabel}</strong>
<span>{selectedImage.model} · {new Date(selectedImage.createdAt).toLocaleTimeString()}</span>
<span>{selectedImage.model} · {formatLocalTime(selectedImage.createdAt)}</span>
</div>
<button type="button" className="btn btn-ghost" onClick={() => handleDownload(selectedImage)}>下载</button>
</div>
@@ -618,6 +943,58 @@ export default function PictureAgent() {
</div>
</section>
</div>
{jobDialogOpen && (
<div
className="picture-agent-job-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setJobDialogOpen(false);
}}
>
<section
className="picture-agent-job-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="picture-agent-job-dialog-title"
>
<div className="picture-agent-job-dialog-head">
<div>
<strong id="picture-agent-job-dialog-title">生图进度</strong>
<span>{latestJobLabel} · {jobs.length} 条记录</span>
</div>
<div className="picture-agent-job-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => {
refreshJobs().catch((err: any) => setError(err?.message || '刷新任务失败'));
}}
>
刷新
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void clearJobs()}
disabled={clearingJobs || terminalJobs.length === 0}
>
{clearingJobs ? '清理中...' : '清空日志'}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(false)}>
关闭
</button>
</div>
</div>
{jobs.length === 0 ? (
<div className="picture-agent-job-empty">提交后会先进入队列,完成后会自动出现在这里和使用记录里。</div>
) : (
<div className="picture-agent-job-list picture-agent-job-list-dialog">
{jobs.map(renderJobCard)}
</div>
)}
</section>
</div>
)}
</div>
);
}
+23 -2
View File
@@ -9,6 +9,7 @@ interface LogItem {
status: string | null;
http_status: number | null;
latency_ms: number | null;
first_byte_latency_ms?: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
total_tokens: number | null;
@@ -42,6 +43,23 @@ function latencyColor(ms: number | null): string {
return 'var(--color-danger)';
}
function displayLatency(log: LogItem): string {
const firstByte = log.first_byte_latency_ms;
const total = log.latency_ms;
if (firstByte != null && Number.isFinite(firstByte)) {
return `${firstByte}ms / ${total != null ? `${total}ms` : '-'}`;
}
return total != null ? `${total}ms` : '-';
}
function latencyTextColor(log: LogItem): string {
const firstByte = log.first_byte_latency_ms;
if (firstByte != null && Number.isFinite(firstByte)) {
return latencyColor(firstByte);
}
return latencyColor(log.latency_ms);
}
export default function UsageRecords() {
const [logs, setLogs] = useState<LogItem[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
@@ -181,6 +199,9 @@ export default function UsageRecords() {
</button>
)}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
延迟优先显示首字延迟,Tokens 与费用依赖上游返回的 usage 数据。
</div>
</div>
{/* Table */}
@@ -236,8 +257,8 @@ export default function UsageRecords() {
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
)}
</td>
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
<td style={{ fontSize: 13, color: latencyTextColor(log), fontFamily: 'var(--font-mono)' }}>
{displayLatency(log)}
</td>
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
+3 -3
View File
@@ -157,9 +157,9 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
<section className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<Link to="/user/video-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
<span>视频工作台</span>
<Link to="/user/workflows" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg>
<span>工作流</span>
</Link>
<Link to="/user/comfyui-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+85 -89
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { commitSession } from '../authSession.js';
import LoginShell from './auth/LoginShell.js';
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate();
@@ -46,95 +47,90 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户注册</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">创建账号</h2>
<p className="login-auth-copy">注册后即可使用 API 代理服务。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
<input
id="reg-username-input"
type="text"
placeholder="你的昵称"
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="username"
required
<LoginShell
t={(text) => text}
variant="user"
eyebrow="用户注册"
title="创建账号"
description="注册后即可使用 API 代理服务。"
error={error}
footer={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
<span>
已有账号?
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}>
登录
</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
)}
>
<form onSubmit={handleSubmit} className="login-register-form">
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
<input
id="reg-username-input"
type="text"
placeholder="你的昵称"
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="username"
required
/>
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
<input
id="reg-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
<input
id="reg-password-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
<input
id="reg-confirm-password-input"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
className="btn btn-primary login-auth-submit"
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
<label className="login-auth-label" htmlFor="reg-email-input" style={{ marginTop: 12 }}>邮箱</label>
<input
id="reg-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="reg-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="reg-password-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-confirm-password-input" style={{ marginTop: 12 }}>确认密码</label>
<input
id="reg-confirm-password-input"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 注册中...</> : '注册'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
已有账号?<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>登录</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
注册中...
</>
) : (
'注册'
)}
</button>
</form>
</LoginShell>
);
}
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
export default function Workflows() {
const [ready, setReady] = React.useState(false);
const [message, setMessage] = React.useState('正在加载工作流前端...');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const openFullscreen = () => {
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
};
const reloadFrame = () => {
if (iframeRef.current) {
iframeRef.current.src = '/workflow-ui/';
}
};
React.useEffect(() => {
let cancelled = false;
const controller = new AbortController();
void fetch('/workflow-ui/', { signal: controller.signal })
.then((response) => {
if (cancelled) return;
setReady(response.ok);
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
})
.catch((error) => {
if (cancelled || error?.name === 'AbortError') return;
setReady(false);
setMessage('Workflow Studio 前端未构建,请先执行 workflow-ui:install');
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
return (
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>工作流</div>
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
刷新
</button>
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
新窗口打开
</button>
</div>
</div>
{ready ? (
<iframe
ref={iframeRef}
src="/workflow-ui/"
title="Workflow Studio"
allow="clipboard-read; clipboard-write"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
/>
) : (
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
</div>
</div>
)}
</div>
);
}
const toolbarStyle: React.CSSProperties = {
minHeight: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid var(--color-border-light)',
background: 'var(--color-surface)',
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const toolbarActionsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const toolbarButtonStyle: React.CSSProperties = {
minHeight: 30,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '5px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: 12,
};
const primaryToolbarButtonStyle: React.CSSProperties = {
...toolbarButtonStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};
+12 -6
View File
@@ -2,13 +2,14 @@ import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from
import { useLocation } from 'react-router-dom';
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
import type { SessionAudience } from '../authSession.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
const SOURCE_OPTIONS = [
{ value: 'all', label: '全部来源' },
{ value: 'picture-agent', label: '生图助手' },
{ value: 'comfyui-agent', label: '视频助手' },
{ value: 'video-agent', label: '视频工作台' },
{ value: 'comfyui', label: 'ComfyUI' },
{ value: 'workflow-studio', label: '工作流' },
{ value: 'comfyui', label: '外部 ComfyUI' },
];
const KIND_OPTIONS = [
@@ -46,6 +47,10 @@ function assetTitle(asset: WorkspaceAsset) {
return asset.title || asset.prompt || asset.fileName || asset.publicId;
}
function assetCreatedAtLabel(asset: WorkspaceAsset) {
return formatDateTimeLocal(asset.createdAt);
}
export default function Workspace() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
@@ -167,7 +172,7 @@ export default function Workspace() {
<div>
<h1 className="page-title">工作空间</h1>
<p className="page-subtitle">
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手和 ComfyUI 产出
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
</p>
</div>
<div className="page-actions">
@@ -235,7 +240,7 @@ export default function Workspace() {
{items.length === 0 ? (
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
<div className="empty-state-desc">生图助手、视频助手和 ComfyUI 的产出会归档到这里。</div>
<div className="empty-state-desc">生图助手、视频助手、工作流和外部 ComfyUI 的产出会归档到这里。</div>
</div>
) : items.map((asset) => {
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
@@ -267,8 +272,8 @@ export default function Workspace() {
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
<div style={cardFooterStyle}>
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
<span style={metaStyle}>{asset.createdAt || ''}</span>
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
</div>
</div>
</button>
@@ -303,6 +308,7 @@ export default function Workspace() {
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
</div>
{selected.prompt && (
<div style={promptPanelStyle}>
+36 -13
View File
@@ -5,16 +5,18 @@ import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
addAccount: vi.fn(),
addAccountAvailableModels: vi.fn(),
},
}));
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
addAccount: vi.fn(),
addAccountAvailableModels: vi.fn(),
checkModels: vi.fn(),
rebuildRoutes: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
@@ -44,6 +46,15 @@ describe('Accounts CodingPlan initialization', () => {
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
apiMock.checkModels.mockResolvedValue({
message: '模型刷新成功',
refresh: {
status: 'success',
modelCount: 3,
modelsPreview: ['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5'],
},
});
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
apiMock.addAccount.mockResolvedValue({
id: 18,
siteId: 10,
@@ -73,7 +84,7 @@ describe('Accounts CodingPlan initialization', () => {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 API Key 连接');
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
expect(rendered).toContain('阿里云 Coding Plan (OpenAI)');
expect(rendered).toContain('qwen3-coder-plus');
const checkedBoxes = root.root.findAll((node) => (
@@ -110,8 +121,10 @@ describe('Accounts CodingPlan initialization', () => {
}));
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
18,
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
['qwen3-coder-plus', 'qwen3.5-plus'],
);
expect(apiMock.checkModels).not.toHaveBeenCalled();
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
} finally {
root?.unmount();
}
@@ -127,6 +140,14 @@ describe('Accounts CodingPlan initialization', () => {
tokenType: 'apikey',
queued: false,
});
apiMock.checkModels.mockResolvedValue({
message: '模型刷新成功',
refresh: {
status: 'success',
modelCount: 2,
modelsPreview: ['deepseek-chat', 'deepseek-reasoner'],
},
});
let root!: ReactTestRenderer;
try {
@@ -142,7 +163,7 @@ describe('Accounts CodingPlan initialization', () => {
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('DeepSeek / OpenAI');
expect(rendered).toContain('DeepSeek (OpenAI)');
expect(rendered).toContain('deepseek-chat');
expect(rendered).toContain('deepseek-reasoner');
@@ -175,6 +196,8 @@ describe('Accounts CodingPlan initialization', () => {
19,
['deepseek-chat', 'deepseek-reasoner'],
);
expect(apiMock.checkModels).not.toHaveBeenCalled();
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
} finally {
root?.unmount();
}
+189 -43
View File
@@ -31,14 +31,20 @@ async function readErrorReason(res: Response): Promise<string> {
}
}
type AdminLoginMode = 'token' | 'password';
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
const [mode, setMode] = useState<AdminLoginMode>('token');
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loadingMode, setLoadingMode] = useState<AdminLoginMode | null>(null);
const [error, setError] = useState('');
const handleTokenLogin = async () => {
const handleTokenLogin = async (event?: React.FormEvent) => {
event?.preventDefault();
if (!token.trim()) return;
setLoading(true);
setLoadingMode('token');
setError('');
try {
const res = await fetch('/api/settings/auth/info', {
@@ -54,58 +60,198 @@ export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
setLoadingMode(null);
}
};
const canSubmit = !!token.trim();
const handlePasswordLogin = async (event?: React.FormEvent) => {
event?.preventDefault();
if (!email.trim() || !password) return;
setLoadingMode('password');
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
const text = await res.text();
let parsed: {
token?: unknown;
user?: { role?: unknown };
data?: { token?: unknown; user?: { role?: unknown } };
message?: unknown;
error?: unknown;
} = {};
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = { message: text };
}
}
if (!res.ok) {
const reason =
typeof parsed.message === 'string'
? parsed.message
: typeof parsed.error === 'string'
? parsed.error
: text;
setError(t(resolveLoginErrorMessage(res.status, reason)));
return;
}
const jwt = typeof parsed.token === 'string' ? parsed.token : parsed.data?.token;
const role =
typeof parsed.user?.role === 'string' ? parsed.user.role : parsed.data?.user?.role;
if (typeof jwt !== 'string' || !jwt) {
setError(t('登录响应缺少访问令牌。'));
return;
}
if (role !== 'admin') {
setError(t('该账号不是管理员账号。'));
return;
}
commitSession('admin', jwt);
onLogin(jwt);
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoadingMode(null);
}
};
const isLoading = loadingMode !== null;
const canTokenSubmit = !!token.trim();
const canPasswordSubmit = !!email.trim() && !!password;
return (
<LoginShell
t={t}
variant="admin"
eyebrow={t('管理员入口')}
title={t('登录')}
description={t('请输入管理员令牌后继续。')}
title={t('管理员登录')}
description={t('使用管理员令牌,或管理员账号密码进入控制台。')}
error={error}
footer={<span>{t('管理员登录后继续。')}</span>}
footer={<span>{t('管理员身份用于管理站点、模型路由、用户与计费。')}</span>}
>
<label className="login-auth-label" htmlFor="admin-token-input">
{t('管理员令牌')}
</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
className="login-auth-input"
autoComplete="current-password"
/>
<button
onClick={handleTokenLogin}
disabled={loading || !canSubmit}
className="btn btn-primary login-auth-submit"
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('登录')
)}
</button>
<div className="login-auth-note">
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
<div className="login-mode-tabs" role="tablist" aria-label={t('管理员登录方式')}>
<button
type="button"
className={`login-mode-tab${mode === 'token' ? ' active' : ''}`}
onClick={() => {
setMode('token');
setError('');
}}
aria-selected={mode === 'token'}
>
Auth Token
</button>
<button
type="button"
className={`login-mode-tab${mode === 'password' ? ' active' : ''}`}
onClick={() => {
setMode('password');
setError('');
}}
aria-selected={mode === 'password'}
>
{t('账号密码')}
</button>
</div>
{mode === 'token' ? (
<form onSubmit={handleTokenLogin}>
<label className="login-auth-label" htmlFor="admin-token-input">
{t('管理员令牌')}
</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="current-password"
/>
<button
type="submit"
disabled={isLoading || !canTokenSubmit}
className="btn btn-primary login-auth-submit"
>
{loadingMode === 'token' ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('进入控制台')
)}
</button>
<div className="login-auth-note">
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
</div>
</form>
) : (
<form onSubmit={handlePasswordLogin}>
<label className="login-auth-label" htmlFor="admin-email-input">
{t('管理员邮箱')}
</label>
<input
id="admin-email-input"
type="email"
placeholder={t('管理员邮箱')}
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="username"
/>
<label className="login-auth-label" htmlFor="admin-password-input">
{t('密码')}
</label>
<input
id="admin-password-input"
type="password"
placeholder={t('密码')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="current-password"
/>
<button
type="submit"
disabled={isLoading || !canPasswordSubmit}
className="btn btn-primary login-auth-submit"
>
{loadingMode === 'password' ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('进入控制台')
)}
</button>
<div className="login-auth-note">
{t('只有 role=admin 的账号可以进入管理员控制台。')}
</div>
</form>
)}
</LoginShell>
);
}
-26
View File
@@ -1,5 +1,4 @@
import React from 'react';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
export type LoginShellVariant = 'admin' | 'user';
@@ -82,31 +81,6 @@ export default function LoginShell({
</div>
))}
</div>
<div className="login-brand-footer">
<a
href={SITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="login-icon-link"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
/>
</svg>
</a>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="login-doc-link"
>
{t('部署文档')}
</a>
</div>
</>
)}
</section>
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Models from './Models.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
useToast: () => toastMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => (
typeof child === 'string' ? child : collectText(child)
)).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Models copy action', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'doubao-seedance-2-0-260128',
accountCount: 1,
tokenCount: 1,
avgLatency: null,
successRate: null,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [],
},
],
});
Object.defineProperty(globalThis.navigator, 'clipboard', {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('copies the model name from the action button', async () => {
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const copyBtn = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] === '复制模型名'
));
await act(async () => {
await copyBtn.props.onClick();
});
expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith('doubao-seedance-2-0-260128');
expect(toastMock.success).toHaveBeenCalled();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Models from './Models.js';
const { apiMock, userApiMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
},
userApiMock: {
getModelsMarketplace: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
userApi: userApiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => (
typeof child === 'string' ? child : collectText(child)
)).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Models user view', () => {
beforeEach(() => {
vi.clearAllMocks();
userApiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 1,
tokenCount: 1,
avgLatency: null,
successRate: null,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: 'Site A',
username: 'tester',
latency: null,
balance: 1,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('hides vendor and account details for user path', async () => {
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/user/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('模型广场');
expect(text).not.toContain('供应商');
expect(text).not.toContain('排序方式');
expect(text).not.toContain('账号数');
expect(text).not.toContain('令牌数');
expect(text).not.toContain('Site A');
expect(text).not.toContain('tester');
expect(userApiMock.getModelsMarketplace).toHaveBeenCalled();
expect(apiMock.getModelsMarketplace).not.toHaveBeenCalled();
});
});
+925
View File
@@ -0,0 +1,925 @@
use crate::{
error::{AppError, AppResult},
services::workspace,
workflow::{
graph,
models::{NodeExecution, NodeOutputStore, RunContext, WorkflowGraph, WorkflowNode},
storage,
},
AppState,
};
use serde_json::{json, Map, Value};
use std::{collections::HashMap, sync::Arc};
#[derive(Debug, Clone)]
pub enum WorkflowProgressEvent {
Started {
prompt_id: String,
total: usize,
},
NodeStarted {
prompt_id: String,
node_id: String,
index: usize,
total: usize,
},
NodeCompleted {
prompt_id: String,
node_id: String,
index: usize,
total: usize,
output: Value,
},
Finished {
prompt_id: String,
success: bool,
error: Option<String>,
},
}
pub type WorkflowProgressCallback = Arc<dyn Fn(WorkflowProgressEvent) + Send + Sync>;
pub async fn run_workflow(
state: &AppState,
workflow_id: i64,
graph_value: Value,
input_overrides: Value,
ctx: RunContext,
) -> AppResult<String> {
run_workflow_with_progress(state, workflow_id, graph_value, input_overrides, ctx, None).await
}
pub async fn run_workflow_with_progress(
state: &AppState,
workflow_id: i64,
graph_value: Value,
input_overrides: Value,
ctx: RunContext,
progress: Option<WorkflowProgressCallback>,
) -> AppResult<String> {
let graph: WorkflowGraph = serde_json::from_value(graph_value.clone())
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
let order = graph::validate_and_sort(&graph)?;
let total = order.len().max(1);
let (run_db_id, run_public_id) =
storage::insert_run(state, workflow_id, ctx.user_id, &input_overrides).await?;
emit_progress(
&progress,
WorkflowProgressEvent::Started {
prompt_id: run_public_id.clone(),
total,
},
);
let nodes = graph
.nodes
.iter()
.map(|node| (node.id.clone(), node.clone()))
.collect::<HashMap<_, _>>();
let mut store = NodeOutputStore::new();
for (index, node_id) in order.into_iter().enumerate() {
let Some(node) = nodes.get(&node_id) else {
continue;
};
emit_progress(
&progress,
WorkflowProgressEvent::NodeStarted {
prompt_id: run_public_id.clone(),
node_id: node.id.clone(),
index,
total,
},
);
let inputs = collect_inputs(&graph, &store, node, &input_overrides)?;
let node_run_id =
storage::insert_node_run_started(state, run_db_id, &node.id, &node.node_type, &inputs)
.await?;
match execute_node(state, &ctx, node, inputs.clone()).await {
Ok(result) => {
storage::complete_node_run(state, node_run_id, &result.outputs).await?;
emit_progress(
&progress,
WorkflowProgressEvent::NodeCompleted {
prompt_id: run_public_id.clone(),
node_id: node.id.clone(),
index: index + 1,
total,
output: result.outputs.clone(),
},
);
store.insert(node.id.clone(), result.outputs);
}
Err(err) => {
let message = err.to_string();
let _ = storage::fail_node_run(state, node_run_id, &message).await;
let _ = storage::fail_run(state, run_db_id, &message).await;
emit_progress(
&progress,
WorkflowProgressEvent::Finished {
prompt_id: run_public_id.clone(),
success: false,
error: Some(message),
},
);
return Err(err);
}
}
}
let final_outputs = json!({ "nodes": store });
storage::complete_run(state, run_db_id, &final_outputs).await?;
emit_progress(
&progress,
WorkflowProgressEvent::Finished {
prompt_id: run_public_id.clone(),
success: true,
error: None,
},
);
Ok(run_public_id)
}
fn emit_progress(progress: &Option<WorkflowProgressCallback>, event: WorkflowProgressEvent) {
if let Some(callback) = progress {
callback(event);
}
}
fn collect_inputs(
graph: &WorkflowGraph,
store: &NodeOutputStore,
node: &WorkflowNode,
input_overrides: &Value,
) -> AppResult<Value> {
let mut inputs = Map::new();
for edge in &graph.edges {
if graph::endpoint_node(&edge.to)? != node.id {
continue;
}
let from_node = graph::endpoint_node(&edge.from)?;
let from_port = graph::endpoint_port(&edge.from)?;
let to_port = graph::endpoint_port(&edge.to)?;
if let Some(value) = store
.get(from_node)
.and_then(|outputs| outputs.get(from_port))
{
inputs.insert(to_port.to_string(), value.clone());
}
}
if let Some(overrides) = input_overrides.as_object() {
for (key, value) in overrides {
if let Some((override_node, port)) = key.split_once('.') {
if override_node == node.id {
inputs.insert(port.to_string(), value.clone());
}
}
}
}
Ok(Value::Object(inputs))
}
async fn execute_node(
state: &AppState,
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
match node.node_type.as_str() {
"input.text" => {
let text = inputs
.get("value")
.or_else(|| node.params.get("value"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Ok(NodeExecution {
inputs,
outputs: json!({ "text": text }),
})
}
"boosapi.chat_completion" => chat_completion(state, ctx, node, inputs, false).await,
"boosapi.comfyui_workflow" => comfyui_workflow(ctx, node, inputs).await,
"comfyui.submit_workflow" => submit_comfyui_workflow(state, node, inputs).await,
"boosapi.prompt_rewrite" => chat_completion(state, ctx, node, inputs, true).await,
"boosapi.text_to_image" => text_to_image(state, ctx, node, inputs).await,
"boosapi.text_to_video" => text_to_video(ctx, node, inputs).await,
"boosapi.image_to_video" => image_to_video(ctx, node, inputs).await,
"boosapi.video_status" => video_status(ctx, node, inputs).await,
"CheckpointLoaderSimple" => {
let model = param_str(node, "ckpt_name", "gpt-image-2");
Ok(NodeExecution {
inputs,
outputs: json!({
"MODEL": { "model": model },
"CLIP": { "model": model },
"VAE": { "model": model }
}),
})
}
"CLIPTextEncode" => {
let text = param_str(node, "text", "");
let model = inputs
.pointer("/clip/model")
.and_then(Value::as_str)
.unwrap_or("gpt-image-2")
.to_string();
Ok(NodeExecution {
inputs,
outputs: json!({ "CONDITIONING": { "text": text, "model": model } }),
})
}
"EmptyLatentImage" => Ok(NodeExecution {
inputs,
outputs: json!({
"LATENT": {
"width": param_i64(node, "width", 1024),
"height": param_i64(node, "height", 1024),
"batch_size": param_i64(node, "batch_size", 1)
}
}),
}),
"KSampler" => {
let model = inputs
.pointer("/model/model")
.and_then(Value::as_str)
.or_else(|| inputs.pointer("/positive/model").and_then(Value::as_str))
.unwrap_or("gpt-image-2")
.to_string();
let prompt = inputs
.pointer("/positive/text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let negative_prompt = inputs
.pointer("/negative/text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let width = inputs
.pointer("/latent_image/width")
.and_then(Value::as_i64)
.unwrap_or(1024);
let height = inputs
.pointer("/latent_image/height")
.and_then(Value::as_i64)
.unwrap_or(1024);
Ok(NodeExecution {
inputs,
outputs: json!({
"LATENT": {
"model": model,
"prompt": prompt,
"negative_prompt": negative_prompt,
"width": width,
"height": height,
"steps": param_i64(node, "steps", 20),
"cfg": param_number(node, "cfg", 8.0),
"seed": param_i64(node, "seed", 0)
}
}),
})
}
"VAEDecode" => vae_decode_to_image(state, ctx, inputs).await,
"SaveImage" | "PreviewImage" => {
let image = inputs
.get("images")
.cloned()
.or_else(|| inputs.get("image").cloned())
.or_else(|| inputs.get("IMAGE").cloned())
.ok_or_else(|| {
AppError::BadRequest(format!("{} missing images input", node.id))
})?;
Ok(NodeExecution {
inputs,
outputs: json!({ "IMAGE": image, "image": image }),
})
}
"output.preview_image" => {
let image = inputs
.get("image")
.cloned()
.or_else(|| inputs.get("images").cloned())
.or_else(|| inputs.get("IMAGE").cloned())
.ok_or_else(|| AppError::BadRequest(format!("{} missing image input", node.id)))?;
Ok(NodeExecution {
inputs,
outputs: json!({ "image": image }),
})
}
"output.preview_video" => {
let video = inputs
.get("video")
.cloned()
.or_else(|| inputs.get("videos").cloned())
.or_else(|| inputs.get("VIDEO").cloned())
.ok_or_else(|| AppError::BadRequest(format!("{} missing video input", node.id)))?;
Ok(NodeExecution {
inputs,
outputs: json!({ "video": video }),
})
}
"output.save_file" => {
let file = inputs
.get("file")
.cloned()
.or_else(|| inputs.get("image").cloned())
.or_else(|| inputs.get("images").and_then(|images| images.get(0)).cloned())
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
Ok(NodeExecution {
inputs,
outputs: json!({ "file": file }),
})
}
"util.text_join" => {
let prefix = param_str(node, "prefix", "");
let suffix = param_str(node, "suffix", "");
let separator = param_str(node, "separator", "\n");
let text = value_to_string(inputs.get("text"));
let joined = [prefix, text, suffix]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(&separator);
Ok(NodeExecution {
inputs,
outputs: json!({ "text": joined }),
})
}
"util.json_extract" => {
let mut current = inputs.get("json").cloned().unwrap_or(Value::Null);
for segment in param_str(node, "path", "")
.split('.')
.filter(|v| !v.is_empty())
{
current = if let Ok(index) = segment.parse::<usize>() {
current.get(index).cloned().unwrap_or(Value::Null)
} else {
current.get(segment).cloned().unwrap_or(Value::Null)
};
}
Ok(NodeExecution {
inputs,
outputs: json!({ "value": current, "text": value_to_string(Some(&current)) }),
})
}
_ => Err(AppError::BadRequest(format!(
"unsupported node type: {}",
node.node_type
))),
}
}
async fn comfyui_workflow(
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
let text = value_to_string(inputs.get("text"));
if text.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} missing text input",
node.id
)));
}
let system = param_str(
node,
"system",
"Generate a valid ComfyUI workflow JSON object. Return JSON only.",
);
let body = json!({
"model": param_str(node, "model", "gpt-5.4"),
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": text }
],
"stream": false,
"temperature": param_number(node, "temperature", 0.3)
});
let raw = post_json(ctx, "/chat/completions", &body).await?;
let content = raw
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let workflow = parse_jsonish(&content).unwrap_or_else(|| {
json!({
"boosapi": {
"kind": "comfyui-workflow-draft",
"prompt": text,
"raw": content
},
"nodes": {}
})
});
Ok(NodeExecution {
inputs,
outputs: json!({ "workflow": workflow, "text": content, "raw": raw }),
})
}
async fn submit_comfyui_workflow(
state: &AppState,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
let workflow = inputs
.get("workflow")
.cloned()
.ok_or_else(|| AppError::BadRequest(format!("{} missing workflow input", node.id)))?;
if param_bool(node, "dry_run", true) {
return Ok(NodeExecution {
inputs,
outputs: json!({
"prompt_id": null,
"response": { "dryRun": true },
"workflow": workflow,
}),
});
}
let body = json!({
"client_id": param_str(node, "client_id", "boosapi-workflow-studio"),
"prompt": workflow,
});
let response = reqwest::Client::new()
.post(format!("{}/api/comfyui/prompt", ctx_server_base(state)))
.json(&body)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(NodeExecution {
inputs,
outputs: json!({
"prompt_id": value.get("prompt_id").and_then(Value::as_str),
"response": value,
"workflow": body.get("prompt").cloned().unwrap_or(Value::Null),
}),
})
}
async fn chat_completion(
_state: &AppState,
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
rewrite: bool,
) -> AppResult<NodeExecution> {
let text = value_to_string(inputs.get("text"));
if text.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} missing text input",
node.id
)));
}
let model = param_str(node, "model", "gpt-5.4");
let system = if rewrite {
format!(
"Rewrite the user input as a concise, high quality prompt. Style requirement: {}. Return only the rewritten prompt.",
param_str(node, "style", "cinematic English image prompt")
)
} else {
param_str(node, "system", "")
};
let mut messages = Vec::new();
if !system.trim().is_empty() {
messages.push(json!({ "role": "system", "content": system }));
}
messages.push(json!({ "role": "user", "content": text }));
let body = json!({
"model": model,
"messages": messages,
"stream": false,
"temperature": param_number(node, "temperature", 0.7)
});
let raw = post_json(ctx, "/chat/completions", &body).await?;
let content = raw
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let outputs = if rewrite {
json!({ "prompt": content, "raw": raw })
} else {
json!({ "text": content, "raw": raw })
};
Ok(NodeExecution { inputs, outputs })
}
async fn text_to_image(
state: &AppState,
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
let prompt = value_to_string(inputs.get("prompt"));
if prompt.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} missing prompt input",
node.id
)));
}
let width = param_i64(node, "width", 1024);
let height = param_i64(node, "height", 1024);
let size = param_str(node, "size", "");
let body = json!({
"model": param_str(node, "model", "gpt-image-2"),
"prompt": prompt,
"n": 1,
"size": if size.trim().is_empty() { format!("{width}x{height}") } else { size },
});
execute_image_generation(state, ctx, inputs, body).await
}
async fn text_to_video(
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
let prompt = value_to_string(inputs.get("prompt"));
if prompt.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} missing prompt input",
node.id
)));
}
let body = video_request_body(node, prompt, None);
execute_video_generation(ctx, inputs, body).await
}
async fn image_to_video(
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
let prompt = value_to_string(inputs.get("prompt"));
if prompt.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} missing prompt input",
node.id
)));
}
let image_url = param_str(node, "image_url", "");
let image_url = if image_url.trim().is_empty() {
extract_media_url(inputs.get("image"))
} else {
Some(image_url)
};
let body = video_request_body(node, prompt, image_url);
execute_video_generation(ctx, inputs, body).await
}
async fn video_status(
ctx: &RunContext,
node: &WorkflowNode,
inputs: Value,
) -> AppResult<NodeExecution> {
let task_id = value_to_string(inputs.get("task_id"));
if task_id.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"{} missing task_id input",
node.id
)));
}
let model = param_str(node, "model", "seedance-1-0-lite-t2v");
let path = format!(
"/videos/{}?model={}",
simple_urlencode(task_id.trim()),
simple_urlencode(model.trim())
);
let raw = get_json(ctx, &path).await?;
let video = video_output_from_response(&raw);
Ok(NodeExecution {
inputs,
outputs: json!({ "video": video, "raw": raw }),
})
}
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
let mut body = Map::new();
body.insert("model".to_string(), json!(param_str(node, "model", "seedance-1-0-lite-t2v")));
body.insert("prompt".to_string(), json!(prompt));
for key in ["ratio", "resolution"] {
let value = param_str(node, key, "");
if !value.trim().is_empty() {
body.insert(key.to_string(), json!(value));
}
}
for key in ["duration", "fps"] {
if let Some(value) = node.params.get(key).and_then(Value::as_i64) {
body.insert(key.to_string(), json!(value));
} else if let Some(value) = node.params.get(key).and_then(Value::as_f64) {
body.insert(key.to_string(), json!(value));
}
}
if let Some(url) = image_url.filter(|value| !value.trim().is_empty()) {
body.insert("image_url".to_string(), json!(url));
}
Value::Object(body)
}
async fn execute_video_generation(
ctx: &RunContext,
inputs: Value,
body: Value,
) -> AppResult<NodeExecution> {
let raw = post_json(ctx, "/videos", &body).await?;
let task_id = extract_task_id(&raw);
let video = video_output_from_response(&raw);
Ok(NodeExecution {
inputs,
outputs: json!({
"task_id": task_id,
"video": video,
"raw": raw
}),
})
}
async fn vae_decode_to_image(
state: &AppState,
ctx: &RunContext,
inputs: Value,
) -> AppResult<NodeExecution> {
let samples = inputs.get("samples").cloned().unwrap_or(Value::Null);
let vae = inputs.get("vae").cloned().unwrap_or(Value::Null);
let prompt = samples
.get("prompt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if prompt.trim().is_empty() {
return Err(AppError::BadRequest(
"VAEDecode missing positive prompt from KSampler".into(),
));
}
let model = samples
.get("model")
.and_then(Value::as_str)
.or_else(|| vae.get("model").and_then(Value::as_str))
.unwrap_or("gpt-image-2");
let width = samples
.get("width")
.and_then(Value::as_i64)
.unwrap_or(1024);
let height = samples
.get("height")
.and_then(Value::as_i64)
.unwrap_or(1024);
let mut prompt_text = prompt;
if let Some(negative) = samples.get("negative_prompt").and_then(Value::as_str) {
if !negative.trim().is_empty() {
prompt_text.push_str("\nNegative prompt: ");
prompt_text.push_str(negative.trim());
}
}
let body = json!({
"model": model,
"prompt": prompt_text,
"n": 1,
"size": format!("{width}x{height}"),
});
execute_image_generation(state, ctx, inputs, body).await
}
async fn execute_image_generation(
state: &AppState,
ctx: &RunContext,
inputs: Value,
body: Value,
) -> AppResult<NodeExecution> {
let raw = post_json(ctx, "/images/generations", &body).await?;
let assets = workspace::record_image_assets_from_response(
state,
ctx.user_id,
"workflow-studio",
body.get("prompt")
.and_then(Value::as_str)
.map(str::to_string),
body.get("model")
.and_then(Value::as_str)
.map(str::to_string),
&raw,
)
.await;
let images = raw.get("data").cloned().unwrap_or_else(|| json!([]));
let first = images.get(0).cloned().unwrap_or(Value::Null);
let comfy_images = assets
.iter()
.map(|asset| {
json!({
"filename": asset.file_name.as_deref().unwrap_or(&asset.public_id),
"subfolder": "",
"type": "output",
})
})
.collect::<Vec<_>>();
Ok(NodeExecution {
inputs,
outputs: json!({
"image": first,
"images": images,
"comfy_images": comfy_images,
"raw": raw
}),
})
}
async fn post_json(ctx: &RunContext, path: &str, body: &Value) -> AppResult<Value> {
let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
let response = reqwest::Client::new()
.post(url)
.bearer_auth(&ctx.bearer)
.json(body)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let json = serde_json::from_str::<Value>(&text).map_err(|err| {
AppError::UpstreamError(format!(
"invalid JSON from BoosAPI workflow node: {err}; body: {}",
text.chars().take(500).collect::<String>()
))
})?;
if !status.is_success() {
return Err(AppError::UpstreamError(json.to_string()));
}
Ok(json)
}
async fn get_json(ctx: &RunContext, path: &str) -> AppResult<Value> {
let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
let response = reqwest::Client::new()
.get(url)
.bearer_auth(&ctx.bearer)
.send()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let json = serde_json::from_str::<Value>(&text).map_err(|err| {
AppError::UpstreamError(format!(
"invalid JSON from BoosAPI workflow node: {err}; body: {}",
text.chars().take(500).collect::<String>()
))
})?;
if !status.is_success() {
return Err(AppError::UpstreamError(json.to_string()));
}
Ok(json)
}
fn extract_task_id(value: &Value) -> Value {
for pointer in ["/id", "/task_id", "/data/id", "/data/task_id", "/result/id", "/result/task_id"] {
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
if !id.trim().is_empty() {
return json!(id);
}
}
}
Value::Null
}
fn video_output_from_response(value: &Value) -> Value {
if let Some(url) = find_url_by_extension(value, &[".mp4", ".webm", ".mov"])
.or_else(|| find_url_key(value, &["video_url", "videoUrl", "url"]))
{
json!({ "url": url })
} else {
Value::Null
}
}
fn extract_media_url(value: Option<&Value>) -> Option<String> {
let value = value?;
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
}
fn find_url_key(value: &Value, keys: &[&str]) -> Option<String> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(url) = map
.get(*key)
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
{
return Some(url.to_string());
}
}
map.values().find_map(|child| find_url_key(child, keys))
}
Value::Array(items) => items.iter().find_map(|child| find_url_key(child, keys)),
_ => None,
}
}
fn find_url_by_extension(value: &Value, extensions: &[&str]) -> Option<String> {
match value {
Value::String(text)
if (text.starts_with("http://") || text.starts_with("https://"))
&& extensions.iter().any(|ext| text.to_ascii_lowercase().contains(ext)) =>
{
Some(text.clone())
}
Value::Object(map) => map
.values()
.find_map(|child| find_url_by_extension(child, extensions)),
Value::Array(items) => items
.iter()
.find_map(|child| find_url_by_extension(child, extensions)),
_ => None,
}
}
fn simple_urlencode(value: &str) -> String {
value
.replace('%', "%25")
.replace(' ', "%20")
.replace('?', "%3F")
.replace('&', "%26")
.replace('#', "%23")
.replace('/', "%2F")
}
fn param_str(node: &WorkflowNode, key: &str, default: &str) -> String {
node.params
.get(key)
.and_then(Value::as_str)
.unwrap_or(default)
.to_string()
}
fn param_i64(node: &WorkflowNode, key: &str, default: i64) -> i64 {
node.params
.get(key)
.and_then(Value::as_i64)
.unwrap_or(default)
}
fn param_number(node: &WorkflowNode, key: &str, default: f64) -> f64 {
node.params
.get(key)
.and_then(Value::as_f64)
.unwrap_or(default)
}
fn param_bool(node: &WorkflowNode, key: &str, default: bool) -> bool {
node.params
.get(key)
.and_then(Value::as_bool)
.unwrap_or(default)
}
fn parse_jsonish(text: &str) -> Option<Value> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let stripped = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim()
.strip_suffix("```")
.unwrap_or_else(|| {
trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim()
})
.trim();
serde_json::from_str(stripped).ok()
}
fn ctx_server_base(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
format!("http://{}:{}", host, state.config.server.port)
}
fn value_to_string(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(Value::Null) | None => String::new(),
Some(other) => other.to_string(),
}
}
+94
View File
@@ -0,0 +1,94 @@
use crate::{
error::{AppError, AppResult},
workflow::{models::WorkflowGraph, registry},
};
use std::collections::{HashMap, VecDeque};
pub fn validate_and_sort(graph: &WorkflowGraph) -> AppResult<Vec<String>> {
if graph.nodes.is_empty() {
return Err(AppError::BadRequest(
"workflow must contain at least one node".into(),
));
}
let mut indegree: HashMap<String, usize> = HashMap::new();
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
for node in &graph.nodes {
let node_id = node.id.trim();
if node_id.is_empty() {
return Err(AppError::BadRequest("node id is required".into()));
}
if indegree.insert(node.id.clone(), 0).is_some() {
return Err(AppError::BadRequest(format!(
"duplicate node id: {}",
node.id
)));
}
if !registry::is_known_node(&node.node_type) {
return Err(AppError::BadRequest(format!(
"unknown node type: {}",
node.node_type
)));
}
}
for edge in &graph.edges {
let from = endpoint_node(&edge.from)?;
let to = endpoint_node(&edge.to)?;
if !indegree.contains_key(from) {
return Err(AppError::BadRequest(format!(
"edge references missing node: {from}"
)));
}
if !indegree.contains_key(to) {
return Err(AppError::BadRequest(format!(
"edge references missing node: {to}"
)));
}
outgoing
.entry(from.to_string())
.or_default()
.push(to.to_string());
*indegree.entry(to.to_string()).or_default() += 1;
}
let mut queue = graph
.nodes
.iter()
.filter(|node| indegree.get(&node.id).copied().unwrap_or(0) == 0)
.map(|node| node.id.clone())
.collect::<VecDeque<_>>();
let mut order = Vec::with_capacity(graph.nodes.len());
while let Some(node_id) = queue.pop_front() {
order.push(node_id.clone());
if let Some(next) = outgoing.get(&node_id) {
for target in next {
let entry = indegree.entry(target.clone()).or_default();
*entry = entry.saturating_sub(1);
if *entry == 0 {
queue.push_back(target.clone());
}
}
}
}
if order.len() != graph.nodes.len() {
return Err(AppError::BadRequest(
"workflow graph contains a cycle".into(),
));
}
Ok(order)
}
pub fn endpoint_node(endpoint: &str) -> AppResult<&str> {
endpoint
.split_once('.')
.map(|(node, _)| node)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AppError::BadRequest(format!("invalid endpoint: {endpoint}")))
}
pub fn endpoint_port(endpoint: &str) -> AppResult<&str> {
endpoint
.split_once('.')
.map(|(_, port)| port)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AppError::BadRequest(format!("invalid endpoint: {endpoint}")))
}
+6
View File
@@ -0,0 +1,6 @@
pub mod executor;
pub mod graph;
pub mod models;
pub mod registry;
pub mod routes;
pub mod storage;
+155
View File
@@ -0,0 +1,155 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowGraph {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub nodes: Vec<WorkflowNode>,
#[serde(default)]
pub edges: Vec<WorkflowEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowNode {
pub id: String,
#[serde(rename = "type")]
pub node_type: String,
#[serde(default)]
pub position: Value,
#[serde(default)]
pub params: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowEdge {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowSummary {
pub id: i64,
pub public_id: String,
pub user_id: Option<i64>,
pub name: String,
pub description: Option<String>,
pub graph: Value,
pub is_template: bool,
pub visibility: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowRunSummary {
pub id: i64,
pub public_id: String,
pub workflow_id: i64,
pub workflow_public_id: Option<String>,
pub user_id: Option<i64>,
pub status: String,
pub input_overrides: Value,
pub final_outputs: Option<Value>,
pub error_message: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub created_at: String,
pub node_runs: Vec<WorkflowNodeRunSummary>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowNodeRunSummary {
pub id: i64,
pub node_id: String,
pub node_type: String,
pub status: String,
pub inputs: Value,
pub outputs: Option<Value>,
pub error_message: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct NodeTypeDefinition {
#[serde(rename = "type")]
pub node_type: &'static str,
pub title: &'static str,
pub category: &'static str,
pub description: &'static str,
pub inputs: Value,
pub params: Value,
pub outputs: Value,
}
#[derive(Debug, Clone)]
pub struct RunContext {
pub user_id: Option<i64>,
pub bearer: String,
pub base_url: String,
}
#[derive(Debug, Clone)]
pub struct NodeExecution {
pub inputs: Value,
pub outputs: Value,
}
pub type NodeOutputStore = HashMap<String, Value>;
pub fn default_graph(name: &str) -> Value {
json!({
"version": default_version(),
"name": name,
"nodes": [
{
"id": "input_1",
"type": "input.text",
"position": { "x": 80, "y": 120 },
"params": { "value": "生成一个电影感雨夜城市短镜头,包含一张 1024x1024 的起始画面" }
},
{
"id": "workflow_1",
"type": "boosapi.comfyui_workflow",
"position": { "x": 360, "y": 120 },
"params": {
"model": "gpt-5.4",
"system": "Generate a valid ComfyUI workflow JSON object. Return JSON only."
}
},
{
"id": "submit_1",
"type": "comfyui.submit_workflow",
"position": { "x": 660, "y": 120 },
"params": {
"client_id": "boosapi-workflow-studio",
"dry_run": true
}
},
{
"id": "save_1",
"type": "output.save_file",
"position": { "x": 960, "y": 120 },
"params": {}
}
],
"edges": [
{ "from": "input_1.text", "to": "workflow_1.text" },
{ "from": "workflow_1.workflow", "to": "submit_1.workflow" },
{ "from": "submit_1.workflow", "to": "save_1.file" }
]
})
}
fn default_version() -> String {
"1.0".to_string()
}
+291
View File
@@ -0,0 +1,291 @@
use crate::workflow::models::NodeTypeDefinition;
use serde_json::json;
fn core_node_types() -> Vec<NodeTypeDefinition> {
vec![
NodeTypeDefinition {
node_type: "CheckpointLoaderSimple",
title: "CheckpointLoaderSimple",
category: "loaders",
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
inputs: json!({}),
params: json!({
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
}),
outputs: json!({
"MODEL": { "type": "MODEL" },
"CLIP": { "type": "CLIP" },
"VAE": { "type": "VAE" }
}),
},
NodeTypeDefinition {
node_type: "CLIPTextEncode",
title: "CLIPTextEncode",
category: "conditioning",
description: "Encodes text into conditioning.",
inputs: json!({
"clip": { "type": "CLIP", "required": false }
}),
params: json!({
"text": { "ui": "textarea", "type": "STRING", "default": "" }
}),
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
},
NodeTypeDefinition {
node_type: "EmptyLatentImage",
title: "EmptyLatentImage",
category: "latent",
description: "Creates an empty latent image.",
inputs: json!({}),
params: json!({
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
}),
outputs: json!({ "LATENT": { "type": "LATENT" } }),
},
NodeTypeDefinition {
node_type: "KSampler",
title: "KSampler",
category: "sampling",
description: "Samples latent images with a diffusion model.",
inputs: json!({
"model": { "type": "MODEL", "required": true },
"positive": { "type": "CONDITIONING", "required": true },
"negative": { "type": "CONDITIONING", "required": true },
"latent_image": { "type": "LATENT", "required": true }
}),
params: json!({
"seed": { "ui": "number", "type": "INT", "default": 0 },
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
}),
outputs: json!({ "LATENT": { "type": "LATENT" } }),
},
NodeTypeDefinition {
node_type: "VAEDecode",
title: "VAEDecode",
category: "latent",
description: "Decodes latent samples to images.",
inputs: json!({
"samples": { "type": "LATENT", "required": true },
"vae": { "type": "VAE", "required": true }
}),
params: json!({}),
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
},
NodeTypeDefinition {
node_type: "SaveImage",
title: "SaveImage",
category: "image",
description: "Saves images to disk.",
inputs: json!({
"images": { "type": "IMAGE", "required": true }
}),
params: json!({
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
}),
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
},
NodeTypeDefinition {
node_type: "PreviewImage",
title: "PreviewImage",
category: "image",
description: "Previews generated images.",
inputs: json!({
"images": { "type": "IMAGE", "required": true }
}),
params: json!({}),
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
},
]
}
pub fn node_types() -> Vec<NodeTypeDefinition> {
let mut nodes = core_node_types();
nodes.extend(vec![
NodeTypeDefinition {
node_type: "input.text",
title: "文本输入",
category: "Input",
description: "输入脚本、提示词或任意文本。",
inputs: json!({}),
params: json!({
"value": { "ui": "textarea", "type": "STRING", "default": "一个电影感的雨夜城市镜头" }
}),
outputs: json!({ "text": { "type": "STRING" } }),
},
NodeTypeDefinition {
node_type: "boosapi.comfyui_workflow",
title: "生成 ComfyUI 工作流",
category: "ComfyUI",
description: "用 BoosAPI 模型把文本需求改写为 ComfyUI workflow JSON。",
inputs: json!({ "text": { "type": "STRING", "required": true } }),
params: json!({
"model": { "ui": "text", "type": "STRING", "default": "gpt-5.4" },
"system": { "ui": "textarea", "type": "STRING", "default": "You generate valid ComfyUI workflow JSON. Return JSON only." },
"temperature": { "ui": "number", "type": "NUMBER", "default": 0.3, "min": 0, "max": 2 }
}),
outputs: json!({ "workflow": { "type": "JSON" }, "text": { "type": "STRING" }, "raw": { "type": "JSON" } }),
},
NodeTypeDefinition {
node_type: "comfyui.submit_workflow",
title: "提交到外部 ComfyUI",
category: "ComfyUI",
description: "把 workflow JSON 提交到已配置的外部 ComfyUI /prompt。",
inputs: json!({ "workflow": { "type": "JSON", "required": true } }),
params: json!({
"client_id": { "ui": "text", "type": "STRING", "default": "boosapi-workflow-studio" },
"dry_run": { "ui": "boolean", "type": "BOOLEAN", "default": true }
}),
outputs: json!({ "prompt_id": { "type": "STRING" }, "response": { "type": "JSON" }, "workflow": { "type": "JSON" } }),
},
NodeTypeDefinition {
node_type: "boosapi.chat_completion",
title: "BoosAPI Chat Completion",
category: "BoosAPI/Text",
description: "调用 BoosAPI OpenAI 兼容聊天模型。",
inputs: json!({ "text": { "type": "STRING", "required": true } }),
params: json!({
"model": { "ui": "text", "type": "STRING", "default": "gpt-5.4" },
"system": { "ui": "textarea", "type": "STRING", "default": "" },
"temperature": { "ui": "number", "type": "NUMBER", "default": 0.7, "min": 0, "max": 2 }
}),
outputs: json!({ "text": { "type": "STRING" }, "raw": { "type": "JSON" } }),
},
NodeTypeDefinition {
node_type: "boosapi.text_to_image",
title: "文生图",
category: "BoosAPI/Image",
description: "通过 /v1/images/generations 生成图片,并归档到工作空间。",
inputs: json!({ "prompt": { "type": "STRING", "required": true } }),
params: json!({
"model": { "ui": "text", "type": "STRING", "default": "gpt-image-2" },
"width": { "ui": "number", "type": "NUMBER", "default": 1024, "min": 64, "max": 4096 },
"height": { "ui": "number", "type": "NUMBER", "default": 1024, "min": 64, "max": 4096 },
"size": { "ui": "text", "type": "STRING", "default": "" }
}),
outputs: json!({
"image": { "type": "IMAGE" },
"images": { "type": "IMAGE", "is_list": true },
"raw": { "type": "JSON" }
}),
},
NodeTypeDefinition {
node_type: "boosapi.text_to_video",
title: "文生视频",
category: "BoosAPI/Video",
description: "通过 /v1/videos 创建文生视频任务。",
inputs: json!({ "prompt": { "type": "STRING", "required": true } }),
params: json!({
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-t2v" },
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
}),
outputs: json!({
"task_id": { "type": "STRING" },
"video": { "type": "VIDEO" },
"raw": { "type": "JSON" }
}),
},
NodeTypeDefinition {
node_type: "boosapi.image_to_video",
title: "图生视频",
category: "BoosAPI/Video",
description: "通过 /v1/videos 创建图生视频任务。",
inputs: json!({
"prompt": { "type": "STRING", "required": true },
"image": { "type": "IMAGE", "required": false }
}),
params: json!({
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-i2v" },
"image_url": { "ui": "text", "type": "STRING", "default": "" },
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
}),
outputs: json!({
"task_id": { "type": "STRING" },
"video": { "type": "VIDEO" },
"raw": { "type": "JSON" }
}),
},
NodeTypeDefinition {
node_type: "boosapi.video_status",
title: "查询视频任务",
category: "BoosAPI/Video",
description: "查询 /v1/videos/{id} 视频任务结果。",
inputs: json!({ "task_id": { "type": "STRING", "required": true } }),
params: json!({
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-t2v" }
}),
outputs: json!({
"video": { "type": "VIDEO" },
"raw": { "type": "JSON" }
}),
},
NodeTypeDefinition {
node_type: "output.preview_image",
title: "图片预览",
category: "Output",
description: "收集图片输出,供运行结果面板展示。",
inputs: json!({ "image": { "type": "IMAGE", "required": true } }),
params: json!({}),
outputs: json!({ "image": { "type": "IMAGE" } }),
},
NodeTypeDefinition {
node_type: "output.preview_video",
title: "视频预览",
category: "Output",
description: "收集视频输出,供运行结果面板展示。",
inputs: json!({ "video": { "type": "VIDEO", "required": true } }),
params: json!({}),
outputs: json!({ "video": { "type": "VIDEO" } }),
},
NodeTypeDefinition {
node_type: "output.save_file",
title: "保存文件",
category: "Output",
description: "标记上游文件为最终输出。",
inputs: json!({ "file": { "type": "FILE", "required": true } }),
params: json!({ "name": { "ui": "text", "type": "STRING", "default": "" } }),
outputs: json!({ "file": { "type": "FILE" } }),
},
NodeTypeDefinition {
node_type: "util.text_join",
title: "文本拼接",
category: "Util",
description: "拼接输入文本和静态文本。",
inputs: json!({ "text": { "type": "STRING", "required": false } }),
params: json!({
"prefix": { "ui": "textarea", "type": "STRING", "default": "" },
"suffix": { "ui": "textarea", "type": "STRING", "default": "" },
"separator": { "ui": "text", "type": "STRING", "default": "\\n" }
}),
outputs: json!({ "text": { "type": "STRING" } }),
},
NodeTypeDefinition {
node_type: "util.json_extract",
title: "JSON 提取",
category: "Util",
description: "从 JSON 对象中按点路径提取字段。",
inputs: json!({ "json": { "type": "JSON", "required": true } }),
params: json!({ "path": { "ui": "text", "type": "STRING", "default": "data.0.url" } }),
outputs: json!({ "value": { "type": "JSON" }, "text": { "type": "STRING" } }),
},
]);
nodes
}
pub fn is_known_node(node_type: &str) -> bool {
node_types()
.iter()
.any(|definition| definition.node_type == node_type)
}
+389
View File
@@ -0,0 +1,389 @@
use crate::{
auth::jwt::validate_jwt,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
workflow::{
executor, graph,
models::{default_graph, RunContext, WorkflowGraph},
registry, storage,
},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Clone)]
enum WorkflowCaller {
Admin,
User { user_id: i64 },
}
#[derive(Debug, Deserialize)]
struct WorkflowListQuery {
#[serde(default)]
include_public: bool,
}
#[derive(Debug, Deserialize)]
struct RunListQuery {
limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SaveWorkflowPayload {
name: Option<String>,
description: Option<String>,
graph: Option<Value>,
visibility: Option<String>,
is_template: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RunWorkflowPayload {
inputs: Option<Value>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/workflow/node-types", get(node_types))
.route("/api/workflows", get(list_workflows).post(create_workflow))
.route(
"/api/workflows/{id}",
get(get_workflow)
.put(update_workflow)
.delete(delete_workflow),
)
.route("/api/workflows/{id}/run", post(run_workflow))
.route("/api/workflow-runs", get(list_runs))
.route("/api/workflow-runs/{id}", get(get_run))
.route("/api/workflow-runs/{id}/outputs", get(get_run_outputs))
.route("/api/user/workflow/node-types", get(node_types))
.route(
"/api/user/workflows",
get(list_workflows).post(create_workflow),
)
.route(
"/api/user/workflows/{id}",
get(get_workflow)
.put(update_workflow)
.delete(delete_workflow),
)
.route("/api/user/workflows/{id}/run", post(run_workflow))
.route("/api/user/workflow-runs", get(list_runs))
.route("/api/user/workflow-runs/{id}", get(get_run))
.route("/api/user/workflow-runs/{id}/outputs", get(get_run_outputs))
}
fn bearer(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn resolve_caller(headers: &HeaderMap, state: &AppState) -> AppResult<WorkflowCaller> {
let token = bearer(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Ok(WorkflowCaller::Admin);
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role == "admin" {
return Ok(WorkflowCaller::Admin);
}
if claims.role == "user" {
let user_id = claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
return Ok(WorkflowCaller::User { user_id });
}
Err(AppError::Forbidden("Workflow access denied".into()))
}
fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
match caller {
WorkflowCaller::Admin => None,
WorkflowCaller::User { user_id } => Some(*user_id),
}
}
fn caller_is_admin(caller: &WorkflowCaller) -> bool {
matches!(caller, WorkflowCaller::Admin)
}
async fn user_downstream_key(state: &AppState, user_id: i64) -> AppResult<DownstreamApiKey> {
sqlx::query_as::<_, DownstreamApiKey>(
"SELECT *
FROM downstream_api_keys
WHERE user_id = ?
AND COALESCE(enabled, 1) = 1
AND (
expires_at IS NULL
OR expires_at = ''
OR datetime(expires_at) > datetime('now')
)
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| {
AppError::Forbidden("当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".into())
})
}
async fn run_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<RunContext> {
let bearer = match caller {
WorkflowCaller::Admin => state.builtin_keys.comfyui_agent.1.clone(),
WorkflowCaller::User { user_id } => user_downstream_key(state, *user_id).await?.key,
};
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
Ok(RunContext {
user_id: caller_user_id(caller),
bearer,
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
})
}
fn normalize_graph(value: Option<Value>, name: &str) -> AppResult<Value> {
let graph = value.unwrap_or_else(|| default_graph(name));
let parsed: WorkflowGraph = serde_json::from_value(graph.clone())
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
graph::validate_and_sort(&parsed)?;
Ok(graph)
}
fn normalize_visibility(raw: Option<String>, caller: &WorkflowCaller) -> String {
let value = raw.unwrap_or_else(|| "private".into());
match value.as_str() {
"public" | "template" if caller_is_admin(caller) => value,
_ => "private".into(),
}
}
async fn node_types() -> Json<Value> {
Json(json!({ "success": true, "nodeTypes": registry::node_types() }))
}
async fn list_workflows(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<WorkflowListQuery>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let items = storage::list_workflows(
&state.db,
caller_user_id(&caller),
query.include_public || caller_is_admin(&caller),
)
.await?;
Ok(Json(json!({ "success": true, "items": items })))
}
async fn create_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SaveWorkflowPayload>,
) -> AppResult<(StatusCode, Json<Value>)> {
let caller = resolve_caller(&headers, &state)?;
let name = payload
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名工作流");
let graph = normalize_graph(payload.graph, name)?;
let visibility = normalize_visibility(payload.visibility, &caller);
let workflow = storage::insert_workflow(
&state,
caller_user_id(&caller),
name,
payload.description.as_deref(),
&graph,
&visibility,
payload.is_template.unwrap_or(false) && caller_is_admin(&caller),
)
.await?;
Ok((
StatusCode::CREATED,
Json(json!({ "success": true, "workflow": workflow })),
))
}
async fn get_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let workflow = storage::get_workflow(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(json!({ "success": true, "workflow": workflow })))
}
async fn update_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<SaveWorkflowPayload>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let current = storage::get_workflow(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
let name = payload
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&current.name);
let graph = normalize_graph(payload.graph.or(Some(current.graph)), name)?;
let visibility = normalize_visibility(payload.visibility.or(Some(current.visibility)), &caller);
let workflow = storage::update_workflow(
&state,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
name,
payload
.description
.as_deref()
.or(current.description.as_deref()),
&graph,
&visibility,
)
.await?;
Ok(Json(json!({ "success": true, "workflow": workflow })))
}
async fn delete_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
let caller = resolve_caller(&headers, &state)?;
storage::delete_workflow(
&state,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn run_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<RunWorkflowPayload>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let workflow = storage::get_workflow(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
let ctx = run_context(&state, &caller).await?;
let run_id = executor::run_workflow(
&state,
workflow.id,
workflow.graph,
payload.inputs.unwrap_or_else(|| json!({})),
ctx,
)
.await?;
let run = storage::get_run(
&state.db,
&run_id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(
json!({ "success": true, "runId": run_id, "run": run }),
))
}
async fn list_runs(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<RunListQuery>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let items = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
query.limit.unwrap_or(30),
)
.await?;
Ok(Json(json!({ "success": true, "items": items })))
}
async fn get_run(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let run = storage::get_run(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(json!({ "success": true, "run": run })))
}
async fn get_run_outputs(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state)?;
let run = storage::get_run(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(json!({
"success": true,
"runId": run.public_id,
"outputs": run.final_outputs.unwrap_or_else(|| json!({}))
})))
}
+463
View File
@@ -0,0 +1,463 @@
use crate::{
error::{AppError, AppResult},
workflow::models::{WorkflowNodeRunSummary, WorkflowRunSummary, WorkflowSummary},
AppState,
};
use serde_json::{json, Value};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
pub fn new_workflow_id() -> String {
format!("wf_{}", Uuid::new_v4().simple())
}
pub fn new_run_id() -> String {
format!("run_{}", Uuid::new_v4().simple())
}
pub fn parse_json_text(raw: Option<String>, fallback: Value) -> Value {
raw.and_then(|value| serde_json::from_str::<Value>(&value).ok())
.unwrap_or(fallback)
}
fn row_to_workflow(row: sqlx::sqlite::SqliteRow) -> WorkflowSummary {
let graph_raw: String = row.try_get("graph").unwrap_or_else(|_| "{}".to_string());
WorkflowSummary {
id: row.try_get("id").unwrap_or_default(),
public_id: row.try_get("public_id").unwrap_or_default(),
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
name: row.try_get("name").unwrap_or_default(),
description: row.try_get("description").ok(),
graph: serde_json::from_str(&graph_raw).unwrap_or_else(|_| json!({})),
is_template: row
.try_get::<i64, _>("is_template")
.map(|value| value != 0)
.unwrap_or(false),
visibility: row
.try_get("visibility")
.unwrap_or_else(|_| "private".into()),
created_at: row.try_get("created_at").unwrap_or_default(),
updated_at: row.try_get("updated_at").unwrap_or_default(),
}
}
fn row_to_node_run(row: sqlx::sqlite::SqliteRow) -> WorkflowNodeRunSummary {
WorkflowNodeRunSummary {
id: row.try_get("id").unwrap_or_default(),
node_id: row.try_get("node_id").unwrap_or_default(),
node_type: row.try_get("node_type").unwrap_or_default(),
status: row.try_get("status").unwrap_or_default(),
inputs: parse_json_text(row.try_get("inputs").ok(), json!({})),
outputs: row
.try_get::<Option<String>, _>("outputs")
.ok()
.flatten()
.and_then(|raw| serde_json::from_str(&raw).ok()),
error_message: row.try_get("error_message").ok(),
started_at: row.try_get("started_at").ok(),
completed_at: row.try_get("completed_at").ok(),
created_at: row.try_get("created_at").unwrap_or_default(),
}
}
fn row_to_run(
row: sqlx::sqlite::SqliteRow,
node_runs: Vec<WorkflowNodeRunSummary>,
) -> WorkflowRunSummary {
WorkflowRunSummary {
id: row.try_get("id").unwrap_or_default(),
public_id: row.try_get("public_id").unwrap_or_default(),
workflow_id: row.try_get("workflow_id").unwrap_or_default(),
workflow_public_id: row.try_get("workflow_public_id").ok(),
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
status: row.try_get("status").unwrap_or_default(),
input_overrides: parse_json_text(row.try_get("input_overrides").ok(), json!({})),
final_outputs: row
.try_get::<Option<String>, _>("final_outputs")
.ok()
.flatten()
.and_then(|raw| serde_json::from_str(&raw).ok()),
error_message: row.try_get("error_message").ok(),
started_at: row.try_get("started_at").ok(),
completed_at: row.try_get("completed_at").ok(),
created_at: row.try_get("created_at").unwrap_or_default(),
node_runs,
}
}
pub async fn list_workflows(
pool: &SqlitePool,
user_id: Option<i64>,
include_public: bool,
) -> AppResult<Vec<WorkflowSummary>> {
let rows = sqlx::query(
"SELECT *
FROM workflows
WHERE deleted_at IS NULL
AND (
? IS NULL
OR user_id = ?
OR (? = 1 AND visibility IN ('public', 'template'))
)
ORDER BY datetime(updated_at) DESC, id DESC",
)
.bind(user_id)
.bind(user_id)
.bind(if include_public { 1_i64 } else { 0_i64 })
.map(row_to_workflow)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get_workflow(
pool: &SqlitePool,
public_id: &str,
user_id: Option<i64>,
allow_admin: bool,
) -> AppResult<WorkflowSummary> {
let row = sqlx::query(
"SELECT *
FROM workflows
WHERE public_id = ?
AND deleted_at IS NULL
AND (? = 1 OR user_id = ? OR visibility IN ('public', 'template'))
LIMIT 1",
)
.bind(public_id)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("Workflow not found".into()))?;
Ok(row_to_workflow(row))
}
pub async fn get_workflow_by_db_id(pool: &SqlitePool, id: i64) -> AppResult<WorkflowSummary> {
let row = sqlx::query("SELECT * FROM workflows WHERE id = ? AND deleted_at IS NULL LIMIT 1")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("Workflow not found".into()))?;
Ok(row_to_workflow(row))
}
pub async fn get_run(
pool: &SqlitePool,
public_id: &str,
user_id: Option<i64>,
allow_admin: bool,
) -> AppResult<WorkflowRunSummary> {
let row = sqlx::query(
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
FROM workflow_runs
JOIN workflows ON workflows.id = workflow_runs.workflow_id
WHERE workflow_runs.public_id = ?
AND (? = 1 OR workflow_runs.user_id = ?)
LIMIT 1",
)
.bind(public_id)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("Workflow run not found".into()))?;
let run_id: i64 = row.try_get("id").unwrap_or_default();
let node_runs = sqlx::query(
"SELECT *
FROM workflow_node_runs
WHERE run_id = ?
ORDER BY id ASC",
)
.bind(run_id)
.map(row_to_node_run)
.fetch_all(pool)
.await?;
Ok(row_to_run(row, node_runs))
}
pub async fn list_runs(
pool: &SqlitePool,
user_id: Option<i64>,
allow_admin: bool,
limit: i64,
) -> AppResult<Vec<WorkflowRunSummary>> {
let rows = sqlx::query(
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
FROM workflow_runs
JOIN workflows ON workflows.id = workflow_runs.workflow_id
WHERE (? = 1 OR workflow_runs.user_id = ?)
ORDER BY datetime(workflow_runs.created_at) DESC, workflow_runs.id DESC
LIMIT ?",
)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.bind(limit.clamp(1, 100))
.fetch_all(pool)
.await?;
let mut runs = Vec::with_capacity(rows.len());
for row in rows {
let run_id: i64 = row.try_get("id").unwrap_or_default();
let node_runs = sqlx::query(
"SELECT *
FROM workflow_node_runs
WHERE run_id = ?
ORDER BY id ASC",
)
.bind(run_id)
.map(row_to_node_run)
.fetch_all(pool)
.await?;
runs.push(row_to_run(row, node_runs));
}
Ok(runs)
}
pub async fn insert_workflow(
state: &AppState,
user_id: Option<i64>,
name: &str,
description: Option<&str>,
graph: &Value,
visibility: &str,
is_template: bool,
) -> AppResult<WorkflowSummary> {
let public_id = new_workflow_id();
sqlx::query(
"INSERT INTO workflows
(public_id, user_id, name, description, graph, visibility, is_template)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&public_id)
.bind(user_id)
.bind(name)
.bind(description)
.bind(graph.to_string())
.bind(visibility)
.bind(if is_template { 1_i64 } else { 0_i64 })
.execute(&*state.db)
.await?;
get_workflow(&state.db, &public_id, user_id, true).await
}
pub async fn update_workflow(
state: &AppState,
public_id: &str,
user_id: Option<i64>,
allow_admin: bool,
name: &str,
description: Option<&str>,
graph: &Value,
visibility: &str,
) -> AppResult<WorkflowSummary> {
let result = sqlx::query(
"UPDATE workflows
SET name = ?, description = ?, graph = ?, visibility = ?, updated_at = datetime('now')
WHERE public_id = ?
AND deleted_at IS NULL
AND (? = 1 OR user_id = ?)",
)
.bind(name)
.bind(description)
.bind(graph.to_string())
.bind(visibility)
.bind(public_id)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Workflow not found".into()));
}
get_workflow(&state.db, public_id, user_id, allow_admin).await
}
pub async fn delete_workflow(
state: &AppState,
public_id: &str,
user_id: Option<i64>,
allow_admin: bool,
) -> AppResult<()> {
let result = sqlx::query(
"UPDATE workflows
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE public_id = ?
AND deleted_at IS NULL
AND (? = 1 OR user_id = ?)",
)
.bind(public_id)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Workflow not found".into()));
}
Ok(())
}
pub async fn insert_run(
state: &AppState,
workflow_id: i64,
user_id: Option<i64>,
input_overrides: &Value,
) -> AppResult<(i64, String)> {
let public_id = new_run_id();
let result = sqlx::query(
"INSERT INTO workflow_runs
(public_id, workflow_id, user_id, status, input_overrides, started_at)
VALUES (?, ?, ?, 'running', ?, datetime('now'))",
)
.bind(&public_id)
.bind(workflow_id)
.bind(user_id)
.bind(input_overrides.to_string())
.execute(&*state.db)
.await?;
let id = result.last_insert_rowid();
Ok((id, public_id))
}
pub async fn insert_node_run_started(
state: &AppState,
run_id: i64,
node_id: &str,
node_type: &str,
inputs: &Value,
) -> AppResult<i64> {
let result = sqlx::query(
"INSERT INTO workflow_node_runs
(run_id, node_id, node_type, status, inputs, started_at)
VALUES (?, ?, ?, 'running', ?, datetime('now'))",
)
.bind(run_id)
.bind(node_id)
.bind(node_type)
.bind(inputs.to_string())
.execute(&*state.db)
.await?;
let id = result.last_insert_rowid();
Ok(id)
}
pub async fn complete_node_run(
state: &AppState,
node_run_id: i64,
outputs: &Value,
) -> AppResult<()> {
sqlx::query(
"UPDATE workflow_node_runs
SET status = 'completed', outputs = ?, completed_at = datetime('now')
WHERE id = ?",
)
.bind(outputs.to_string())
.bind(node_run_id)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn fail_node_run(state: &AppState, node_run_id: i64, error: &str) -> AppResult<()> {
sqlx::query(
"UPDATE workflow_node_runs
SET status = 'failed', error_message = ?, completed_at = datetime('now')
WHERE id = ?",
)
.bind(error)
.bind(node_run_id)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn complete_run(state: &AppState, run_id: i64, outputs: &Value) -> AppResult<()> {
sqlx::query(
"UPDATE workflow_runs
SET status = 'completed', final_outputs = ?, completed_at = datetime('now')
WHERE id = ?",
)
.bind(outputs.to_string())
.bind(run_id)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn fail_run(state: &AppState, run_id: i64, error: &str) -> AppResult<()> {
sqlx::query(
"UPDATE workflow_runs
SET status = 'failed', error_message = ?, completed_at = datetime('now')
WHERE id = ?",
)
.bind(error)
.bind(run_id)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn delete_runs(
state: &AppState,
public_ids: &[String],
user_id: Option<i64>,
allow_admin: bool,
) -> AppResult<usize> {
let mut deleted = 0_usize;
for public_id in public_ids {
sqlx::query(
"DELETE FROM workflow_node_runs
WHERE run_id IN (
SELECT id
FROM workflow_runs
WHERE public_id = ?
AND (? = 1 OR user_id = ?)
)",
)
.bind(public_id)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.execute(&*state.db)
.await?;
let result = sqlx::query(
"DELETE FROM workflow_runs
WHERE public_id = ?
AND (? = 1 OR user_id = ?)",
)
.bind(public_id)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.execute(&*state.db)
.await?;
deleted += result.rows_affected() as usize;
}
Ok(deleted)
}
pub async fn clear_runs(
state: &AppState,
user_id: Option<i64>,
allow_admin: bool,
) -> AppResult<u64> {
sqlx::query(
"DELETE FROM workflow_node_runs
WHERE run_id IN (
SELECT id
FROM workflow_runs
WHERE (? = 1 OR user_id = ?)
)",
)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.execute(&*state.db)
.await?;
let result = sqlx::query(
"DELETE FROM workflow_runs
WHERE (? = 1 OR user_id = ?)",
)
.bind(if allow_admin { 1_i64 } else { 0_i64 })
.bind(user_id)
.execute(&*state.db)
.await?;
Ok(result.rows_affected())
}
+39 -2
View File
@@ -18,11 +18,48 @@ export default defineConfig(({ mode }) => {
build: {
outDir: '../../dist/web',
emptyOutDir: true,
chunkSizeWarningLimit: 1100,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('@visactor/react-vchart') || id.includes('/@visactor/')) {
return 'vchart-vendor';
if (!id.includes('/node_modules/')) {
return undefined;
}
if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/scheduler/')) {
return 'react-vendor';
}
if (id.includes('/react-router') || id.includes('/react-router-dom/')) {
return 'router-vendor';
}
if (id.includes('/@dnd-kit/')) {
return 'dnd-vendor';
}
if (id.includes('/mermaid/')) {
return 'mermaid-vendor';
}
if (id.includes('/marked/')) {
return 'markdown-vendor';
}
if (id.includes('/better-sqlite3/') || id.includes('/pg/') || id.includes('/mysql2/')) {
return 'db-vendor';
}
if (id.includes('/@visactor/react-vchart/')) {
return 'vchart-react-vendor';
}
if (id.includes('/@visactor/vchart/')) {
return 'vchart-core-vendor';
}
if (id.includes('/@visactor/vgrammar')) {
return 'vchart-grammar-vendor';
}
if (id.includes('/@visactor/vrender')) {
return 'vchart-render-vendor';
}
if (id.includes('/@visactor/vdataset/') || id.includes('/@visactor/vutils/') || id.includes('/@visactor/vscale/') || id.includes('/@visactor/vutils-extension/')) {
return 'vchart-utils-vendor';
}
if (id.includes('/@visactor/')) {
return 'vchart-extra-vendor';
}
return undefined;
},