Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f9fe4a81d | |||
| 6869504119 | |||
| 3f9f9f7665 | |||
| a1f3400af3 | |||
| da686ecda4 | |||
| 1ba293c262 |
@@ -0,0 +1,4 @@
|
||||
[submodule "vendor/ComfyUI_frontend"]
|
||||
path = vendor/ComfyUI_frontend
|
||||
url = https://github.com/Comfy-Org/ComfyUI_frontend.git
|
||||
branch = main
|
||||
@@ -13,6 +13,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
|
||||
# Async runtime
|
||||
async-stream = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = [
|
||||
@@ -60,6 +61,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Streaming
|
||||
futures = "0.3"
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
tokio-stream = "0.1"
|
||||
bytes = "1"
|
||||
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🎨 Workflow Studio 与视频助手(Rust)
|
||||
@@ -155,6 +153,29 @@
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 硬件配置建议
|
||||
|
||||
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
||||
|
||||
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
||||
|----------|-----|------|------|-----|
|
||||
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
||||
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
||||
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
|
||||
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
|
||||
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
|
||||
|
||||
推荐的 SaaS 网关部署规格:
|
||||
|
||||
```txt
|
||||
4 核 CPU
|
||||
8GB 内存
|
||||
50GB SSD
|
||||
无需 GPU
|
||||
```
|
||||
|
||||
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### Docker Compose(推荐)
|
||||
@@ -339,6 +360,19 @@ npm run build
|
||||
# 输出到 dist/web/,Rust 后端自动 serve
|
||||
```
|
||||
|
||||
可选使用 Bun 加速主前端构建:
|
||||
|
||||
```bash
|
||||
# 一次性使用 Bun 构建主前端
|
||||
./build-and-run.sh --bun
|
||||
|
||||
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
|
||||
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
|
||||
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
||||
```
|
||||
|
||||
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
|
||||
|
||||
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
||||
|
||||
|
||||
+3
-3
@@ -180,7 +180,7 @@ Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
||||
| Frontend | React 18 + Vite |
|
||||
| Language | TypeScript |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
|
||||
| Database | SQLite, with PostgreSQL migration preparation |
|
||||
| Visualization | VChart |
|
||||
| Container | Docker + Docker Compose |
|
||||
| Test | Vitest |
|
||||
@@ -191,12 +191,12 @@ Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run db:migrate
|
||||
cargo run
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run build # Build web + server
|
||||
npm run build # Build web assets
|
||||
npm test # Run tests
|
||||
```
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+33
-5
@@ -21,6 +21,7 @@ NO_RUN=0
|
||||
FORCE_FRONTEND=0
|
||||
SKIP_FRONTEND=0
|
||||
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
||||
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -35,14 +36,17 @@ usage() {
|
||||
--backend-only 只构建后端
|
||||
--force-frontend 强制重建前端,忽略缓存
|
||||
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
||||
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块仍使用 pnpm)
|
||||
--no-run 构建后不启动服务
|
||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
||||
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./build-and-run.sh --release
|
||||
./build-and-run.sh --fast-release
|
||||
./build-and-run.sh --bun
|
||||
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
||||
EOF
|
||||
}
|
||||
@@ -61,6 +65,7 @@ while [ $# -gt 0 ]; do
|
||||
--backend-only) BACKEND_ONLY=1 ;;
|
||||
--force-frontend) FORCE_FRONTEND=1 ;;
|
||||
--skip-frontend) SKIP_FRONTEND=1 ;;
|
||||
--bun) FRONTEND_PM="bun" ;;
|
||||
--no-run) NO_RUN=1 ;;
|
||||
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -167,8 +172,24 @@ build_frontend() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
fail "未找到 npx,请先安装 Node.js"
|
||||
local frontend_pm="$FRONTEND_PM"
|
||||
case "$frontend_pm" in
|
||||
auto)
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
frontend_pm="bun"
|
||||
else
|
||||
frontend_pm="npm"
|
||||
fi
|
||||
;;
|
||||
npm|bun) ;;
|
||||
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
|
||||
esac
|
||||
|
||||
if [ "$frontend_pm" = "bun" ]; then
|
||||
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
|
||||
else
|
||||
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
|
||||
command -v npx >/dev/null 2>&1 || fail "未找到 npx,请先安装 Node.js"
|
||||
fi
|
||||
|
||||
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
||||
@@ -176,10 +197,13 @@ build_frontend() {
|
||||
fingerprint="$(
|
||||
{
|
||||
printf 'build-script-v2\n'
|
||||
printf 'frontend_pm=%s\n' "$frontend_pm"
|
||||
printf 'node='
|
||||
node --version 2>/dev/null || true
|
||||
printf 'npx='
|
||||
npx --version 2>/dev/null || true
|
||||
printf 'bun='
|
||||
bun --version 2>/dev/null || true
|
||||
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
|
||||
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
||||
done | sort | xargs -r sha256sum
|
||||
@@ -194,9 +218,13 @@ build_frontend() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "构建前端 (Vite)..."
|
||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
||||
npx vite build
|
||||
log "构建前端 (Vite via ${frontend_pm})..."
|
||||
# 直接调用 vite build;桌面端构建链路已移除。
|
||||
if [ "$frontend_pm" = "bun" ]; then
|
||||
bunx --bun vite build
|
||||
else
|
||||
npx vite build
|
||||
fi
|
||||
mkdir -p "$ROOT_DIR/dist/web"
|
||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||
ok "前端构建完成 → dist/web/"
|
||||
|
||||
+11
-1
@@ -32,5 +32,15 @@ python_bin = "python3"
|
||||
port = 8188
|
||||
extra_args = []
|
||||
max_restarts = 5
|
||||
base_url = "http://127.0.0.1:8188"
|
||||
# backend modes:
|
||||
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
||||
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
|
||||
# - "external": proxy to your own full ComfyUI server.
|
||||
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
||||
backend = "cloud"
|
||||
# docker_image = "boosapi-comfyui:multi-tenant"
|
||||
# container_name = "boosapi-comfyui-workflow"
|
||||
# base_url = "https://www.runninghub.cn/proxy/94d1330ea62d4ae980ffb018ecdf82a2"
|
||||
base_url = "https://cloud.comfy.org"
|
||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||
agent_model = "gpt-5.5"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM boosapi-comfyui:multi-tenant
|
||||
|
||||
ARG COMFYUI_TAG=v0.22.2
|
||||
|
||||
WORKDIR /workspace/ComfyUI
|
||||
|
||||
COPY docker/build-comfyui-src /tmp/ComfyUI-updated
|
||||
|
||||
RUN find /workspace/ComfyUI -mindepth 1 \
|
||||
! -path '/workspace/ComfyUI/venv' \
|
||||
! -path '/workspace/ComfyUI/venv/*' \
|
||||
-exec rm -rf {} + \
|
||||
&& cp -a /tmp/ComfyUI-updated/. /workspace/ComfyUI/ \
|
||||
&& rm -rf /tmp/ComfyUI-updated \
|
||||
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
|
||||
uv pip install --python venv/bin/python -r requirements.txt \
|
||||
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
uv pip install --python venv/bin/python six \
|
||||
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
||||
|
||||
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
||||
|
||||
RUN python - <<'PY'
|
||||
from pathlib import Path
|
||||
path = Path("/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py")
|
||||
if not path.exists():
|
||||
raise SystemExit("BoosAPI custom node missing after ComfyUI update")
|
||||
PY
|
||||
@@ -1,42 +0,0 @@
|
||||
appId: me.cita777.boosapi.desktop
|
||||
productName: Metapi
|
||||
artifactName: ${name}-${version}-${os}-${arch}.${ext}
|
||||
directories:
|
||||
output: release
|
||||
electronDist: node_modules/electron/dist
|
||||
icon: build/desktop-icon.png
|
||||
asar: false
|
||||
files:
|
||||
- dist/**/*
|
||||
- drizzle/**/*
|
||||
- package.json
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
category: public.app-category.developer-tools
|
||||
hardenedRuntime: false
|
||||
gatekeeperAssess: false
|
||||
entitlementsInherit: null
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
- zip
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
maintainer: cita-777 <cita-777@users.noreply.github.com>
|
||||
publish:
|
||||
provider: github
|
||||
owner: cita-777
|
||||
repo: boosapi
|
||||
releaseType: release
|
||||
publishAutoUpdate: true
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
afterPack: scripts/desktop/afterPack.mjs
|
||||
afterSign: scripts/desktop/afterSign.mjs
|
||||
+7
-69
@@ -2,12 +2,12 @@
|
||||
"name": "boosapi",
|
||||
"version": "1.3.0",
|
||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||
"main": "dist/desktop/main.js",
|
||||
"main": "dist/web/index.html",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"api-gateway",
|
||||
"proxy",
|
||||
"fastify",
|
||||
"rust",
|
||||
"openai",
|
||||
"claude",
|
||||
"aggregator"
|
||||
@@ -22,51 +22,20 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=25.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
||||
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
|
||||
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"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",
|
||||
"build": "npm run build:web",
|
||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
||||
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
|
||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
|
||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
||||
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
|
||||
"package:desktop": "npm run dist:desktop",
|
||||
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
|
||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||
"start": "node dist/server/index.js",
|
||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
||||
"start:desktop": "electron dist/desktop/main.js",
|
||||
"comfyui:install": "node scripts/comfyui/install.mjs",
|
||||
"comfyui:start": "node scripts/comfyui/start.mjs",
|
||||
"comfyui:stop": "node scripts/comfyui/stop.mjs",
|
||||
"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",
|
||||
"schema:generate": "npm run db:generate && npm run schema:contract",
|
||||
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
|
||||
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
|
||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
||||
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
|
||||
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
|
||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
||||
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
||||
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
||||
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
|
||||
"test": "vitest run --root .",
|
||||
"test:watch": "vitest --root ."
|
||||
},
|
||||
@@ -74,46 +43,17 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@visactor/react-vchart": "^2.0.21",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fastify": "^5.8.4",
|
||||
"get-port": "^7.2.0",
|
||||
"marked": "^17.0.5",
|
||||
"minimatch": "^10.2.5",
|
||||
"minimist": "^1.2.8",
|
||||
"mysql2": "^3.20.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.16.3",
|
||||
"rate-limiter-flexible": "^11.0.0",
|
||||
"socks": "^2.8.7",
|
||||
"undici": "^6.24.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"jsdom": "^29.0.2",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -122,13 +62,11 @@
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-plugin-mermaid": "^2.0.17",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^9.0.4"
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"minimist": "^1.2.8"
|
||||
|
||||
Generated
+74
-3768
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,10 @@ 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 REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
||||
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1';
|
||||
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
||||
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
||||
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
@@ -24,11 +25,23 @@ function run(command, args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
|
||||
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR;
|
||||
if (REPO) {
|
||||
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
||||
await fs.rm(CACHE_DIR, { recursive: true, force: true });
|
||||
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
|
||||
} else if (!hasSubmodule) {
|
||||
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
||||
}
|
||||
const hasPnpmLock = await fs.access(path.join(DIR, 'pnpm-lock.yaml')).then(() => true, () => false);
|
||||
if (hasPnpmLock) {
|
||||
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR });
|
||||
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: DIR });
|
||||
} else {
|
||||
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}`);
|
||||
|
||||
@@ -52,6 +52,12 @@ pub struct ProxyConfig {
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ComfyUIConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub backend: ComfyUIBackend,
|
||||
#[serde(default = "default_comfyui_docker_image")]
|
||||
pub docker_image: String,
|
||||
#[serde(default = "default_comfyui_container_name")]
|
||||
pub container_name: String,
|
||||
pub install_dir: Option<PathBuf>,
|
||||
pub python_bin: String,
|
||||
pub port: u16,
|
||||
@@ -59,13 +65,35 @@ pub struct ComfyUIConfig {
|
||||
pub max_restarts: u32,
|
||||
pub external_url: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub agent_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComfyUIBackend {
|
||||
#[default]
|
||||
Boosapi,
|
||||
External,
|
||||
Cloud,
|
||||
Docker,
|
||||
}
|
||||
|
||||
fn default_comfyui_docker_image() -> String {
|
||||
"boosapi-comfyui:multi-tenant".to_string()
|
||||
}
|
||||
|
||||
fn default_comfyui_container_name() -> String {
|
||||
"boosapi-comfyui-workflow".to_string()
|
||||
}
|
||||
|
||||
impl Default for ComfyUIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
backend: ComfyUIBackend::Boosapi,
|
||||
docker_image: default_comfyui_docker_image(),
|
||||
container_name: default_comfyui_container_name(),
|
||||
install_dir: None,
|
||||
python_bin: "python3".to_string(),
|
||||
port: 8188,
|
||||
@@ -73,12 +101,28 @@ impl Default for ComfyUIConfig {
|
||||
max_restarts: 5,
|
||||
external_url: None,
|
||||
base_url: Some("http://127.0.0.1:8188".to_string()),
|
||||
api_key: None,
|
||||
agent_model: "gpt-4o".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ComfyUIConfig {
|
||||
pub fn uses_native_backend(&self) -> bool {
|
||||
matches!(
|
||||
self.backend,
|
||||
ComfyUIBackend::External | ComfyUIBackend::Cloud
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uses_cloud_backend(&self) -> bool {
|
||||
matches!(self.backend, ComfyUIBackend::Cloud)
|
||||
}
|
||||
|
||||
pub fn uses_docker_backend(&self) -> bool {
|
||||
matches!(self.backend, ComfyUIBackend::Docker)
|
||||
}
|
||||
|
||||
pub fn target_base_url(&self) -> String {
|
||||
self.external_url
|
||||
.as_deref()
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct AppState {
|
||||
pub db: Arc<SqlitePool>,
|
||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
||||
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
|
||||
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ impl AppState {
|
||||
db: Arc::new(db),
|
||||
comfyui,
|
||||
builtin_keys: Arc::new(builtin_keys),
|
||||
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||
registration_limiter: Arc::new(
|
||||
services::registration_limiter::RegistrationLimiter::new(),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
auth::{jwt::generate_jwt, password::verify_password},
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
services::register_captcha::CaptchaVerifyError,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
@@ -25,6 +26,10 @@ pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
#[serde(rename = "captchaId", alias = "captcha_id")]
|
||||
pub captcha_id: String,
|
||||
#[serde(rename = "captchaCode", alias = "captcha_code")]
|
||||
pub captcha_code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -59,6 +64,8 @@ pub struct UserProfile {
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/auth/register-captcha", get(register_captcha))
|
||||
.route("/api/users/register-captcha", get(register_captcha))
|
||||
.route("/api/auth/register", post(register))
|
||||
.route("/api/users/register", post(register))
|
||||
.route("/api/auth/login", post(login))
|
||||
@@ -73,6 +80,27 @@ pub fn routes() -> Router<AppState> {
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/users/register-captcha
|
||||
async fn register_captcha(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let challenge = state.register_captcha.issue(Some(addr.ip()));
|
||||
let id = challenge.id;
|
||||
let image = challenge.image;
|
||||
let expires_in_seconds = challenge.expires_in_seconds;
|
||||
Ok(Json(json!({
|
||||
"id": id,
|
||||
"image": image,
|
||||
"expiresInSeconds": expires_in_seconds,
|
||||
"data": {
|
||||
"id": id,
|
||||
"image": image,
|
||||
"expiresInSeconds": expires_in_seconds,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/auth/register
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
@@ -93,6 +121,19 @@ async fn register(
|
||||
}
|
||||
|
||||
let client_ip = Some(addr.ip());
|
||||
if let Err(err) =
|
||||
state
|
||||
.register_captcha
|
||||
.verify(&payload.captcha_id, &payload.captcha_code, client_ip)
|
||||
{
|
||||
let message = match err {
|
||||
CaptchaVerifyError::Missing => "请输入图像验证码",
|
||||
CaptchaVerifyError::Expired => "图像验证码已过期,请刷新后重试",
|
||||
CaptchaVerifyError::Mismatch => "图像验证码错误,请重新输入",
|
||||
};
|
||||
return Err(AppError::BadRequest(message.into()));
|
||||
}
|
||||
|
||||
if let Err(limit) = state
|
||||
.registration_limiter
|
||||
.check_and_record_attempt(client_ip, &payload.email)
|
||||
|
||||
+326
-1
@@ -11,7 +11,7 @@ use crate::{
|
||||
};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, Query, State},
|
||||
extract::{ws::Message, Path, Query, State, WebSocketUpgrade},
|
||||
http::{HeaderMap, Method, StatusCode, Uri},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{any, get, post},
|
||||
@@ -20,7 +20,9 @@ use axum::{
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tokio_tungstenite::{connect_async, tungstenite};
|
||||
use tracing::info;
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
|
||||
/// Shared HTTP client
|
||||
fn http_client() -> Client {
|
||||
@@ -85,11 +87,188 @@ pub fn routes() -> Router<AppState> {
|
||||
// Public ComfyUI reverse proxy for the embedded iframe UI.
|
||||
.route("/api/comfyui/proxy", any(proxy_static_root))
|
||||
.route("/api/comfyui/proxy/", any(proxy_static_root))
|
||||
.route("/api/comfyui/proxy/ws", get(proxy_ws))
|
||||
.route(
|
||||
"/api/comfyui/proxy/api/experiment/models",
|
||||
get(proxy_model_folders),
|
||||
)
|
||||
.route(
|
||||
"/api/comfyui/proxy/api/experiment/models/{folder}",
|
||||
get(proxy_models_in_folder),
|
||||
)
|
||||
.route(
|
||||
"/api/comfyui/proxy/api/view_metadata/{folder}",
|
||||
get(proxy_model_metadata),
|
||||
)
|
||||
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
|
||||
// HTML page (public)
|
||||
.route("/comfyui", get(comfyui_page))
|
||||
}
|
||||
|
||||
async fn proxy_model_folders(State(state): State<AppState>) -> AppResult<Response> {
|
||||
let mut folders = proxy_to_comfyui_json(&state, "GET", "/api/experiment/models", None).await?;
|
||||
if let Some(items) = folders.as_array_mut() {
|
||||
if !items
|
||||
.iter()
|
||||
.any(|item| item.get("name").and_then(Value::as_str) == Some("boosapi_models"))
|
||||
{
|
||||
items.insert(0, serde_json::json!({ "name": "boosapi_models" }));
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
folders.to_string(),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn proxy_models_in_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
if folder == "boosapi_models" {
|
||||
let models = list_boosapi_models(&state).await?;
|
||||
let items: Vec<Value> = models
|
||||
.into_iter()
|
||||
.map(|name| serde_json::json!({ "name": name, "pathIndex": 0 }))
|
||||
.collect();
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
Value::Array(items).to_string(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let path = format!("/api/experiment/models/{}", urlencoding(&folder));
|
||||
proxy_to_comfyui_response(&state, "GET", &path, None).await
|
||||
}
|
||||
|
||||
async fn proxy_model_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(folder): Path<String>,
|
||||
uri: Uri,
|
||||
) -> AppResult<Response> {
|
||||
if folder == "boosapi_models" {
|
||||
let filename = uri
|
||||
.query()
|
||||
.and_then(|query| {
|
||||
query.split('&').find_map(|part| {
|
||||
let (key, value) = part.split_once('=')?;
|
||||
(key == "filename").then_some(value.replace("%2F", "/").replace("%20", " "))
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "BoosAPI model".to_string());
|
||||
let body = serde_json::json!({
|
||||
"title": filename,
|
||||
"author": "BoosAPI",
|
||||
"description": "BoosAPI 后台模型路由。双击可创建 BoosAPI 节点,实际调用本地 /v1 模型网关。",
|
||||
"modelspec.tags": "boosapi,api,model",
|
||||
});
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
body.to_string(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let path = match uri.query() {
|
||||
Some(query) => format!("/api/view_metadata/{}?{}", urlencoding(&folder), query),
|
||||
None => format!("/api/view_metadata/{}", urlencoding(&folder)),
|
||||
};
|
||||
proxy_to_comfyui_response(&state, "GET", &path, None).await
|
||||
}
|
||||
|
||||
async fn proxy_ws(
|
||||
State(state): State<AppState>,
|
||||
ws: WebSocketUpgrade,
|
||||
uri: Uri,
|
||||
) -> AppResult<Response> {
|
||||
let mut target = comfyui_base(&state)
|
||||
.replacen("https://", "wss://", 1)
|
||||
.replacen("http://", "ws://", 1);
|
||||
target.push_str("/ws");
|
||||
if let Some(query) = uri.query() {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
Ok(ws.on_upgrade(move |socket| proxy_ws_bridge(socket, target)))
|
||||
}
|
||||
|
||||
async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target: String) {
|
||||
let request = match target.as_str().into_client_request() {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
let _ = client_socket
|
||||
.send(Message::Text(
|
||||
format!("invalid ComfyUI websocket URL: {err}").into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Ok((upstream_socket, _)) = connect_async(request).await else {
|
||||
let _ = client_socket
|
||||
.send(Message::Text("ComfyUI websocket connect failed".into()))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
let (mut upstream_write, mut upstream_read) = upstream_socket.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
from_upstream = upstream_read.next() => {
|
||||
match from_upstream {
|
||||
Some(Ok(message)) => {
|
||||
let outgoing = match message {
|
||||
tungstenite::Message::Text(text) => Message::Text(text.to_string().into()),
|
||||
tungstenite::Message::Binary(bytes) => Message::Binary(bytes.to_vec().into()),
|
||||
tungstenite::Message::Ping(bytes) => Message::Ping(bytes.to_vec().into()),
|
||||
tungstenite::Message::Pong(bytes) => Message::Pong(bytes.to_vec().into()),
|
||||
tungstenite::Message::Close(frame) => {
|
||||
let _ = client_socket.send(Message::Close(frame.map(|frame| axum::extract::ws::CloseFrame {
|
||||
code: frame.code.into(),
|
||||
reason: frame.reason.to_string().into(),
|
||||
}))).await;
|
||||
break;
|
||||
}
|
||||
tungstenite::Message::Frame(_) => continue,
|
||||
};
|
||||
if client_socket.send(outgoing).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => break,
|
||||
}
|
||||
}
|
||||
from_client = client_socket.recv() => {
|
||||
match from_client {
|
||||
Some(Ok(message)) => {
|
||||
let outgoing = match message {
|
||||
Message::Text(text) => tungstenite::Message::Text(text.to_string().into()),
|
||||
Message::Binary(bytes) => tungstenite::Message::Binary(bytes.to_vec().into()),
|
||||
Message::Ping(bytes) => tungstenite::Message::Ping(bytes.to_vec().into()),
|
||||
Message::Pong(bytes) => tungstenite::Message::Pong(bytes.to_vec().into()),
|
||||
Message::Close(frame) => {
|
||||
let _ = upstream_write.send(tungstenite::Message::Close(frame.map(|frame| tungstenite::protocol::CloseFrame {
|
||||
code: frame.code.into(),
|
||||
reason: frame.reason.to_string().into(),
|
||||
}))).await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if upstream_write.send(outgoing).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Business route handlers (require auth) ───────────────────────────
|
||||
|
||||
/// POST /api/comfyui/prompt
|
||||
@@ -243,6 +422,7 @@ async fn proxy_static_request(
|
||||
.replace("comfyui", "BoosAPI");
|
||||
// Rewrite URLs
|
||||
let rewritten = rewrite_proxy_urls(&rebranded);
|
||||
let rewritten = inject_boosapi_model_library_patch(&rewritten);
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
@@ -274,6 +454,96 @@ async fn proxy_static_request(
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_to_comfyui_json(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(state), path);
|
||||
let req = match method {
|
||||
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
|
||||
_ => client.get(&url),
|
||||
};
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(text));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| AppError::UpstreamError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn proxy_to_comfyui_response(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(state), path);
|
||||
let req = match method {
|
||||
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
|
||||
_ => client.get(&url),
|
||||
};
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn list_boosapi_models(state: &AppState) -> AppResult<Vec<String>> {
|
||||
let rows: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT COALESCE(NULLIF(TRIM(model_mapping), ''), model_pattern)
|
||||
FROM token_routes
|
||||
WHERE COALESCE(enabled, 1) = 1
|
||||
AND TRIM(COALESCE(model_pattern, '')) <> ''
|
||||
ORDER BY 1 COLLATE NOCASE",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut models: Vec<String> = rows
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| seen.insert(value.clone()))
|
||||
.collect();
|
||||
if models.is_empty() {
|
||||
models.extend([
|
||||
"gpt-5.5".to_string(),
|
||||
"gpt-5.4".to_string(),
|
||||
"gpt-image-2".to_string(),
|
||||
]);
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// GET /comfyui — HTML page with iframe
|
||||
async fn comfyui_page() -> Html<&'static str> {
|
||||
Html(
|
||||
@@ -335,6 +605,61 @@ fn rewrite_proxy_urls(html: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
fn inject_boosapi_model_library_patch(html: &str) -> String {
|
||||
let patch = r#"<script>
|
||||
(() => {
|
||||
const isBoosapiModelText = (text) => {
|
||||
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
return value && !value.includes('boosapi_models') && !value.endsWith('_models');
|
||||
};
|
||||
const isImageModel = (name) => /image|dall|flux|sd|midjourney|stable|wan|seedream/i.test(name);
|
||||
const addBoosapiNode = (model) => {
|
||||
try {
|
||||
const graph = window.app?.graph || window.LiteGraph?.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = window.app?.canvas || window.LiteGraph?.LGraphCanvas?.active_canvas;
|
||||
if (!graph || !window.LiteGraph?.createNode) return false;
|
||||
const nodeType = isImageModel(model) ? 'BoosAPITextToImage' : 'BoosAPILLM';
|
||||
const node = window.LiteGraph.createNode(nodeType, nodeType);
|
||||
if (!node) return false;
|
||||
node.pos = canvas?.convertEventToCanvasOffset
|
||||
? canvas.convertEventToCanvasOffset({ clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 })
|
||||
: [120, 120];
|
||||
graph.add(node);
|
||||
const modelWidget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (modelWidget) modelWidget.value = model;
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[BoosAPI] failed to add model node', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const patchModelClicks = () => {
|
||||
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
|
||||
if (el.__boosapiModelPatch) return;
|
||||
el.__boosapiModelPatch = true;
|
||||
el.addEventListener('dblclick', (event) => {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!isBoosapiModelText(text)) return;
|
||||
const hasBoosapiAncestor = Array.from(el.parentElement?.querySelectorAll?.('*') || [])
|
||||
.some((child) => String(child.textContent || '').includes('boosapi_models'));
|
||||
if (!hasBoosapiAncestor && !text.match(/gpt|claude|gemini|image|dall|flux|seed|deepseek|qwen|glm|doubao|wan/i)) return;
|
||||
if (addBoosapiNode(text)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', () => setTimeout(patchModelClicks, 100), true);
|
||||
setInterval(patchModelClicks, 1200);
|
||||
setTimeout(patchModelClicks, 500);
|
||||
})();
|
||||
</script>"#;
|
||||
html.replace("</head>", &format!("{patch}\n</head>"))
|
||||
}
|
||||
|
||||
/// Build query string from params
|
||||
fn build_query_string(params: &HashMap<String, String>) -> String {
|
||||
if params.is_empty() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{auth::jwt::validate_jwt, db::models::downstream_api_key::DownstreamApiKey, AppState};
|
||||
use axum::{
|
||||
extract::State,
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, Sse},
|
||||
routing::{get, post},
|
||||
@@ -59,12 +59,15 @@ emotion: 平静
|
||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||
"#;
|
||||
|
||||
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
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))
|
||||
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -698,7 +701,8 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("file");
|
||||
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||
let header =
|
||||
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||
|
||||
if let Some(text) = attachment
|
||||
.text
|
||||
|
||||
@@ -118,7 +118,13 @@ async fn generate(
|
||||
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() {
|
||||
if body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(AppError::BadRequest("model is required".into()));
|
||||
}
|
||||
if body
|
||||
|
||||
+1068
-133
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum WorkspaceCaller {
|
||||
@@ -318,7 +319,11 @@ async fn get_asset_file(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
||||
let path = PathBuf::from(file_path);
|
||||
let bytes = tokio::fs::read(&path)
|
||||
let file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||
let mime_type = row
|
||||
@@ -334,11 +339,13 @@ async fn get_asset_file(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime_type)
|
||||
.header(header::CONTENT_LENGTH, metadata.len().to_string())
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
||||
)
|
||||
.body(Body::from(bytes))
|
||||
.body(Body::from_stream(ReaderStream::new(file)))
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export function parseBatchApiKeys(input) {
|
||||
if (Array.isArray(input)) {
|
||||
return Array.from(new Set(input
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter((item) => item.length > 0)));
|
||||
}
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw)
|
||||
return [];
|
||||
return Array.from(new Set(raw
|
||||
.split(/[\r\n,,;;\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)));
|
||||
}
|
||||
export function buildBatchApiKeyConnectionName(baseName, index, total) {
|
||||
const normalized = String(baseName || '').trim();
|
||||
if (!normalized)
|
||||
return '';
|
||||
if (total <= 1)
|
||||
return normalized;
|
||||
return `${normalized} #${index + 1}`;
|
||||
}
|
||||
@@ -1,779 +0,0 @@
|
||||
const BRAND_DEFINITIONS = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
icon: 'openai',
|
||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
||||
rules: [
|
||||
{ keyword: 'gpt', mode: 'startsWith' },
|
||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
||||
{ keyword: 'whisper', mode: 'startsWith' },
|
||||
{ keyword: 'text-embedding', mode: 'startsWith' },
|
||||
{ keyword: 'text-moderation', mode: 'startsWith' },
|
||||
{ keyword: 'davinci', mode: 'startsWith' },
|
||||
{ keyword: 'babbage', mode: 'startsWith' },
|
||||
{ keyword: 'codex-mini', mode: 'startsWith' },
|
||||
{ keyword: 'o1', mode: 'startsWith' },
|
||||
{ keyword: 'o3', mode: 'startsWith' },
|
||||
{ keyword: 'o4', mode: 'startsWith' },
|
||||
{ keyword: 'tts', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Anthropic',
|
||||
icon: 'claude-color',
|
||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
||||
rules: [
|
||||
{ keyword: 'claude', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Google',
|
||||
icon: 'gemini-color',
|
||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
||||
rules: [
|
||||
{ keyword: 'gemini', mode: 'includes' },
|
||||
{ keyword: 'gemma', mode: 'includes' },
|
||||
{ keyword: 'google/', mode: 'includes' },
|
||||
{ keyword: 'palm', mode: 'includes' },
|
||||
{ keyword: 'paligemma', mode: 'includes' },
|
||||
{ keyword: 'shieldgemma', mode: 'includes' },
|
||||
{ keyword: 'recurrentgemma', mode: 'includes' },
|
||||
{ keyword: 'deplot', mode: 'includes' },
|
||||
{ keyword: 'codegemma', mode: 'includes' },
|
||||
{ keyword: 'imagen', mode: 'includes' },
|
||||
{ keyword: 'learnlm', mode: 'includes' },
|
||||
{ keyword: 'aqa', mode: 'includes' },
|
||||
{ keyword: 'veo', mode: 'startsWith' },
|
||||
{ keyword: 'google/', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepSeek',
|
||||
icon: 'deepseek-color',
|
||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
||||
rules: [
|
||||
{ keyword: 'deepseek', mode: 'includes' },
|
||||
{ keyword: 'ds-chat', mode: 'segment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '通义千问',
|
||||
icon: 'qwen-color',
|
||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
||||
rules: [
|
||||
{ keyword: 'qwen', mode: 'includes' },
|
||||
{ keyword: 'qwq', mode: 'includes' },
|
||||
{ keyword: 'tongyi', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '智谱 AI',
|
||||
icon: 'zhipu-color',
|
||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'glm', mode: 'includes' },
|
||||
{ keyword: 'chatglm', mode: 'includes' },
|
||||
{ keyword: 'codegeex', mode: 'includes' },
|
||||
{ keyword: 'cogview', mode: 'includes' },
|
||||
{ keyword: 'cogvideo', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Meta',
|
||||
icon: 'meta-color',
|
||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
||||
rules: [
|
||||
{ keyword: 'llama', mode: 'includes' },
|
||||
{ keyword: 'code-llama', mode: 'includes' },
|
||||
{ keyword: 'codellama', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Mistral',
|
||||
icon: 'mistral-color',
|
||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
||||
rules: [
|
||||
{ keyword: 'mistral', mode: 'includes' },
|
||||
{ keyword: 'mixtral', mode: 'includes' },
|
||||
{ keyword: 'codestral', mode: 'includes' },
|
||||
{ keyword: 'pixtral', mode: 'includes' },
|
||||
{ keyword: 'ministral', mode: 'includes' },
|
||||
{ keyword: 'voxtral', mode: 'includes' },
|
||||
{ keyword: 'magistral', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Moonshot',
|
||||
icon: 'moonshot',
|
||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
||||
rules: [
|
||||
{ keyword: 'moonshot', mode: 'includes' },
|
||||
{ keyword: 'kimi', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '零一万物',
|
||||
icon: 'yi-color',
|
||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
||||
rules: [
|
||||
{ keyword: 'yi-', mode: 'startsWith' },
|
||||
{ keyword: 'yi', mode: 'boundary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '文心一言',
|
||||
icon: 'wenxin-color',
|
||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
||||
rules: [
|
||||
{ keyword: 'ernie', mode: 'includes' },
|
||||
{ keyword: 'eb-', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '讯飞星火',
|
||||
icon: 'spark-color',
|
||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
||||
rules: [
|
||||
{ keyword: 'spark', mode: 'includes' },
|
||||
{ keyword: 'generalv', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '腾讯混元',
|
||||
icon: 'hunyuan-color',
|
||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
||||
rules: [
|
||||
{ keyword: 'hunyuan', mode: 'includes' },
|
||||
{ keyword: 'tencent-hunyuan', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '豆包',
|
||||
icon: 'doubao-color',
|
||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
||||
rules: [
|
||||
{ keyword: 'doubao', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'MiniMax',
|
||||
icon: 'minimax-color',
|
||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
||||
rules: [
|
||||
{ keyword: 'minimax', mode: 'includes' },
|
||||
{ keyword: 'abab', mode: 'includes' },
|
||||
{ keyword: 'mini2.1', mode: 'segment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cohere',
|
||||
icon: 'cohere-color',
|
||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
||||
rules: [
|
||||
{ keyword: 'command', mode: 'includes' },
|
||||
{ keyword: 'c4ai-', mode: 'includes' },
|
||||
{ keyword: 'aya', mode: 'includes' },
|
||||
{ keyword: 'embed-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Microsoft',
|
||||
icon: 'microsoft-color',
|
||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
||||
rules: [
|
||||
{ keyword: 'microsoft/', mode: 'includes' },
|
||||
{ keyword: 'phi-', mode: 'includes' },
|
||||
{ keyword: 'kosmos', mode: 'includes' },
|
||||
{ keyword: 'phi4', mode: 'segment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'xAI',
|
||||
icon: 'xai',
|
||||
color: 'linear-gradient(135deg, #111, #444)',
|
||||
rules: [
|
||||
{ keyword: 'grok', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '阶跃星辰',
|
||||
icon: 'stepfun-color',
|
||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
||||
rules: [
|
||||
{ keyword: 'stepfun', mode: 'includes' },
|
||||
{ keyword: 'step-', mode: 'startsWith' },
|
||||
{ keyword: 'step3', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '百川智能',
|
||||
icon: 'baichuan-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'baichuan', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AI21 Labs',
|
||||
icon: 'ai21-brand-color',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
|
||||
rules: [
|
||||
{ keyword: 'ai21', mode: 'includes' },
|
||||
{ keyword: 'jamba', mode: 'startsWith' },
|
||||
{ keyword: 'jamba', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AI2',
|
||||
icon: 'ai2-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'allenai', mode: 'includes' },
|
||||
{ keyword: 'olmo', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Amazon Nova',
|
||||
icon: 'nova',
|
||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'amazon/nova', mode: 'startsWith' },
|
||||
{ keyword: 'amazon.nova', mode: 'includes' },
|
||||
{ keyword: 'us.amazon.nova', mode: 'includes' },
|
||||
{ keyword: 'nova-', mode: 'startsWith' },
|
||||
{ keyword: 'nova-lite', mode: 'startsWith' },
|
||||
{ keyword: 'nova-pro', mode: 'startsWith' },
|
||||
{ keyword: 'nova-micro', mode: 'startsWith' },
|
||||
{ keyword: 'nova-canvas', mode: 'startsWith' },
|
||||
{ keyword: 'nova-reel', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Stability',
|
||||
icon: 'stability-color',
|
||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
||||
rules: [
|
||||
{ keyword: 'flux', mode: 'includes' },
|
||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
||||
{ keyword: 'stable-diffusion', mode: 'includes' },
|
||||
{ keyword: 'sdxl', mode: 'includes' },
|
||||
{ keyword: 'sd3', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'NVIDIA',
|
||||
icon: 'nvidia-color',
|
||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
||||
rules: [
|
||||
{ keyword: 'nvidia/', mode: 'includes' },
|
||||
{ keyword: 'nvclip', mode: 'includes' },
|
||||
{ keyword: 'nemotron', mode: 'includes' },
|
||||
{ keyword: 'nemoretriever', mode: 'includes' },
|
||||
{ keyword: 'neva', mode: 'includes' },
|
||||
{ keyword: 'riva-translate', mode: 'includes' },
|
||||
{ keyword: 'cosmos', mode: 'includes' },
|
||||
{ keyword: 'nv-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'IBM',
|
||||
icon: 'ibm',
|
||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
||||
rules: [
|
||||
{ keyword: 'ibm/', mode: 'includes' },
|
||||
{ keyword: 'granite', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'BAAI',
|
||||
icon: 'baai',
|
||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
||||
rules: [
|
||||
{ keyword: 'baai/', mode: 'includes' },
|
||||
{ keyword: 'bge-', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ByteDance',
|
||||
icon: 'bytedance-color',
|
||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
||||
rules: [
|
||||
{ keyword: 'bytedance', mode: 'includes' },
|
||||
{ keyword: 'seed-oss', mode: 'includes' },
|
||||
{ keyword: 'kolors', mode: 'includes' },
|
||||
{ keyword: 'kwai', mode: 'includes' },
|
||||
{ keyword: 'kwaipilot', mode: 'includes' },
|
||||
{ keyword: 'wan-', mode: 'startsWith' },
|
||||
{ keyword: 'kat-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'InternLM',
|
||||
icon: 'internlm-color',
|
||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
||||
rules: [
|
||||
{ keyword: 'internlm', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Midjourney',
|
||||
icon: 'midjourney',
|
||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
||||
rules: [
|
||||
{ keyword: 'midjourney', mode: 'includes' },
|
||||
{ keyword: 'mj_', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepL',
|
||||
icon: 'deepl-color',
|
||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
||||
rules: [
|
||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
||||
{ keyword: 'deepl/', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jina AI',
|
||||
icon: 'jina',
|
||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
||||
rules: [
|
||||
{ keyword: 'jina', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Relace',
|
||||
icon: 'relace',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'relace', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Arcee',
|
||||
icon: 'arcee-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
rules: [
|
||||
{ keyword: 'arcee-ai', mode: 'includes' },
|
||||
{ keyword: 'arcee', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AionLabs',
|
||||
icon: 'aionlabs-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'aion-labs', mode: 'includes' },
|
||||
{ keyword: 'aionlabs', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepCogito',
|
||||
icon: 'deepcogito-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
|
||||
rules: [
|
||||
{ keyword: 'deepcogito', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Essential AI',
|
||||
icon: 'essentialai-color',
|
||||
color: 'linear-gradient(135deg, #0f172a, #334155)',
|
||||
rules: [
|
||||
{ keyword: 'essentialai', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Inception',
|
||||
icon: 'inception',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
||||
rules: [
|
||||
{ keyword: 'inception', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Inflection',
|
||||
icon: 'inflection',
|
||||
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'inflection', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Liquid AI',
|
||||
icon: 'liquid',
|
||||
color: 'linear-gradient(135deg, #0f172a, #475569)',
|
||||
rules: [
|
||||
{ keyword: 'liquid', mode: 'includes' },
|
||||
{ keyword: 'lfm-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'LongCat',
|
||||
icon: 'longcat-color',
|
||||
color: 'linear-gradient(135deg, #f97316, #fb7185)',
|
||||
rules: [
|
||||
{ keyword: 'longcat', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Morph',
|
||||
icon: 'morph-color',
|
||||
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
|
||||
rules: [
|
||||
{ keyword: 'morph/', mode: 'includes' },
|
||||
{ keyword: 'morph-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nous Research',
|
||||
icon: 'nousresearch',
|
||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
||||
rules: [
|
||||
{ keyword: 'nousresearch', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Upstage',
|
||||
icon: 'upstage-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'upstage', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Xiaomi MiMo',
|
||||
icon: 'xiaomimimo',
|
||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
||||
rules: [
|
||||
{ keyword: 'xiaomi/mimo', mode: 'includes' },
|
||||
{ keyword: 'xiaomimimo', mode: 'includes' },
|
||||
{ keyword: 'mimo-v', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Z.ai',
|
||||
icon: 'zai',
|
||||
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: '2zai', mode: 'startsWith' },
|
||||
{ keyword: 'z-ai', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SenseNova',
|
||||
icon: 'sensenova-brand-color',
|
||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'sensenova', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Perplexity',
|
||||
icon: 'perplexity-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'perplexity', mode: 'includes' },
|
||||
{ keyword: 'pplx-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
icon: 'openrouter',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'openrouter', mode: 'includes' },
|
||||
{ keyword: 'openrouter-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Groq',
|
||||
icon: 'groq',
|
||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
||||
rules: [
|
||||
{ keyword: 'groq', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Fireworks',
|
||||
icon: 'fireworks-color',
|
||||
color: 'linear-gradient(135deg, #fb7185, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'fireworks-ai', mode: 'includes' },
|
||||
{ keyword: 'fireworks', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepInfra',
|
||||
icon: 'deepinfra-color',
|
||||
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
|
||||
rules: [
|
||||
{ keyword: 'deepinfra', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Together AI',
|
||||
icon: 'together-brand-color',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
||||
rules: [
|
||||
{ keyword: 'together.ai', mode: 'includes' },
|
||||
{ keyword: 'together', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Replicate',
|
||||
icon: 'replicate-brand',
|
||||
color: 'linear-gradient(135deg, #111827, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'replicate', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SambaNova',
|
||||
icon: 'sambanova-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
|
||||
rules: [
|
||||
{ keyword: 'sambanova', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cerebras',
|
||||
icon: 'cerebras-brand-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
|
||||
rules: [
|
||||
{ keyword: 'cerebras', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ollama',
|
||||
icon: 'ollama',
|
||||
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
|
||||
rules: [
|
||||
{ keyword: 'ollama', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ModelScope',
|
||||
icon: 'modelscope-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
rules: [
|
||||
{ keyword: 'modelscope', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SiliconCloud',
|
||||
icon: 'siliconcloud-color',
|
||||
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
|
||||
rules: [
|
||||
{ keyword: 'siliconcloud', mode: 'includes' },
|
||||
{ keyword: 'siliconflow', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Azure AI',
|
||||
icon: 'azureai-color',
|
||||
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'azureai', mode: 'includes' },
|
||||
{ keyword: 'azure-openai', mode: 'includes' },
|
||||
{ keyword: 'azure/openai', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AWS Bedrock',
|
||||
icon: 'bedrock-color',
|
||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'bedrock', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Vertex AI',
|
||||
icon: 'vertexai-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'vertexai', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Google Cloud',
|
||||
icon: 'googlecloud-brand-color',
|
||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
||||
rules: [
|
||||
{ keyword: 'googlecloud', mode: 'includes' },
|
||||
{ keyword: 'google-cloud', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '百度智能云',
|
||||
icon: 'baiducloud-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
|
||||
rules: [
|
||||
{ keyword: 'baiducloud', mode: 'includes' },
|
||||
{ keyword: 'qianfan', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '百炼',
|
||||
icon: 'bailian-color',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'bailian', mode: 'includes' },
|
||||
{ keyword: 'dashscope', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '阿里云',
|
||||
icon: 'alibabacloud-color',
|
||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
||||
rules: [
|
||||
{ keyword: 'alibabacloud', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '火山引擎',
|
||||
icon: 'volcengine-color',
|
||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
||||
rules: [
|
||||
{ keyword: 'volcengine', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '七牛云',
|
||||
icon: 'qiniu-color',
|
||||
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
|
||||
rules: [
|
||||
{ keyword: 'qiniu', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
];
|
||||
function normalizeInput(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
export function stripCommonWrappers(value) {
|
||||
return value
|
||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
||||
.replace(/^re:\s*/g, '')
|
||||
.replace(/^\^+/, '')
|
||||
.replace(/\$+$/, '')
|
||||
.trim();
|
||||
}
|
||||
export function collectBrandCandidates(modelName) {
|
||||
const queue = [];
|
||||
const seen = new Set();
|
||||
const push = (value) => {
|
||||
const normalized = normalizeInput(value);
|
||||
if (!normalized || seen.has(normalized))
|
||||
return;
|
||||
seen.add(normalized);
|
||||
queue.push(normalized);
|
||||
};
|
||||
push(modelName);
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const candidate = queue[index];
|
||||
const cleaned = stripCommonWrappers(candidate);
|
||||
push(cleaned);
|
||||
if (cleaned.includes('/')) {
|
||||
for (const part of cleaned.split('/'))
|
||||
push(part);
|
||||
}
|
||||
if (cleaned.includes(':')) {
|
||||
for (const part of cleaned.split(':'))
|
||||
push(part);
|
||||
}
|
||||
if (cleaned.includes(',')) {
|
||||
for (const part of cleaned.split(','))
|
||||
push(part);
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
function buildMatchContext(modelName) {
|
||||
const candidates = collectBrandCandidates(modelName);
|
||||
const raw = candidates[0] || normalizeInput(modelName);
|
||||
const cleaned = stripCommonWrappers(raw);
|
||||
const segments = Array.from(new Set(candidates
|
||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)));
|
||||
return {
|
||||
raw,
|
||||
cleaned,
|
||||
segments,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
function matchesRule(context, rule) {
|
||||
switch (rule.mode) {
|
||||
case 'includes':
|
||||
return context.raw.includes(rule.keyword)
|
||||
|| context.cleaned.includes(rule.keyword)
|
||||
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
|
||||
case 'startsWith':
|
||||
return context.raw.startsWith(rule.keyword)
|
||||
|| context.cleaned.startsWith(rule.keyword)
|
||||
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|
||||
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
|
||||
case 'segment':
|
||||
return context.segments.includes(rule.keyword);
|
||||
case 'boundary': {
|
||||
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
|
||||
return pattern.test(context.raw)
|
||||
|| pattern.test(context.cleaned)
|
||||
|| context.candidates.some((candidate) => pattern.test(candidate));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
||||
brand,
|
||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
||||
}));
|
||||
export function getAllBrands() {
|
||||
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
|
||||
}
|
||||
export function getAllBrandNames() {
|
||||
return BRAND_DEFINITIONS.map((brand) => brand.name);
|
||||
}
|
||||
function toBrandInfo(brand) {
|
||||
return {
|
||||
name: brand.name,
|
||||
icon: brand.icon,
|
||||
color: brand.color,
|
||||
};
|
||||
}
|
||||
export function getMatchingBrands(modelName) {
|
||||
const context = buildMatchContext(modelName);
|
||||
const matches = [];
|
||||
const seen = new Set();
|
||||
const add = (brand) => {
|
||||
if (seen.has(brand.name))
|
||||
return;
|
||||
seen.add(brand.name);
|
||||
matches.push(toBrandInfo(brand));
|
||||
};
|
||||
for (const definition of BRAND_DEFINITIONS) {
|
||||
if (definition.rules.some((rule) => matchesRule(context, rule))) {
|
||||
add(definition);
|
||||
}
|
||||
}
|
||||
for (const candidate of context.candidates) {
|
||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
||||
if (rule.boundaryRegex.test(candidate)) {
|
||||
add(rule.brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
export function getMatchingBrandNames(modelName) {
|
||||
return getMatchingBrands(modelName).map((brand) => brand.name);
|
||||
}
|
||||
export function getBrand(modelName) {
|
||||
return getMatchingBrands(modelName)[0] || null;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
function normalizeString(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
function normalizeDigest(value) {
|
||||
const digest = normalizeString(value);
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||
}
|
||||
function normalizeStableTag(value) {
|
||||
return normalizeStableVersion(value);
|
||||
}
|
||||
function hasSameImageTag(left, right) {
|
||||
const rawLeft = normalizeString(left);
|
||||
const rawRight = normalizeString(right);
|
||||
if (!rawLeft || !rawRight)
|
||||
return false;
|
||||
const stableLeft = normalizeStableTag(left);
|
||||
const stableRight = normalizeStableTag(right);
|
||||
if (stableLeft && stableRight) {
|
||||
return stableLeft === stableRight;
|
||||
}
|
||||
return rawLeft === rawRight;
|
||||
}
|
||||
export function normalizeStableVersion(value) {
|
||||
const raw = normalizeString(value);
|
||||
if (!raw)
|
||||
return '';
|
||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
||||
if (!match)
|
||||
return '';
|
||||
return [
|
||||
Number.parseInt(match[1], 10),
|
||||
Number.parseInt(match[2], 10),
|
||||
Number.parseInt(match[3], 10),
|
||||
].join('.');
|
||||
}
|
||||
export function compareStableVersions(left, right) {
|
||||
const normalizedLeft = normalizeStableVersion(left);
|
||||
const normalizedRight = normalizeStableVersion(right);
|
||||
if (!normalizedLeft || !normalizedRight)
|
||||
return null;
|
||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] === rightParts[index])
|
||||
continue;
|
||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
export function isSameImageTarget(current, target) {
|
||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
||||
const targetDigest = normalizeDigest(target.digest);
|
||||
if (currentDigest && targetDigest) {
|
||||
return currentDigest === targetDigest;
|
||||
}
|
||||
return hasSameImageTag(current?.imageTag, target.tag);
|
||||
}
|
||||
export function buildUpdateReminderCandidateKey(source, candidate) {
|
||||
const tagName = normalizeString(candidate.tagName);
|
||||
const digest = normalizeDigest(candidate.digest);
|
||||
if (!tagName)
|
||||
return '';
|
||||
if (source === 'docker-hub-tag' && digest) {
|
||||
return `${source}:${tagName}@${digest}`;
|
||||
}
|
||||
return `${source}:${tagName}`;
|
||||
}
|
||||
export function resolveUpdateReminderCandidate(input) {
|
||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
||||
if (githubTag) {
|
||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
||||
return {
|
||||
source: 'github-release',
|
||||
kind: 'new-version',
|
||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
||||
tagName: githubTag,
|
||||
digest: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
||||
if (!dockerTag)
|
||||
return null;
|
||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
||||
return null;
|
||||
}
|
||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
||||
if (dockerVersionCompare === -1) {
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-version',
|
||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
||||
tagName: dockerTag,
|
||||
digest: dockerDigest || null,
|
||||
};
|
||||
}
|
||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-digest',
|
||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
||||
tagName: dockerTag,
|
||||
digest: dockerDigest,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::config::ComfyUIConfig;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::Instant};
|
||||
use std::{path::Path, process::Stdio, sync::Arc, time::Instant};
|
||||
use tokio::{
|
||||
process::{Child, Command},
|
||||
sync::{Mutex, RwLock},
|
||||
@@ -67,7 +67,7 @@ impl ComfyUIProcess {
|
||||
}
|
||||
|
||||
pub fn internal_base_url(&self) -> String {
|
||||
if self.config.enabled {
|
||||
if self.config.enabled || self.config.uses_docker_backend() {
|
||||
format!("http://127.0.0.1:{}", self.config.port)
|
||||
} else {
|
||||
self.config.target_base_url()
|
||||
@@ -92,7 +92,9 @@ impl ComfyUIProcess {
|
||||
pub async fn status_response(&self) -> ComfyUIStatusResponse {
|
||||
let status = self.status().await;
|
||||
let base_url = self.internal_base_url();
|
||||
let mode = if self.config.enabled {
|
||||
let mode = if self.config.uses_docker_backend() {
|
||||
"docker"
|
||||
} else if self.config.enabled {
|
||||
"embedded"
|
||||
} else {
|
||||
"external"
|
||||
@@ -154,6 +156,10 @@ impl ComfyUIProcess {
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
if self.config.uses_docker_backend() {
|
||||
return self.start_docker().await;
|
||||
}
|
||||
|
||||
if !self.config.enabled {
|
||||
*self.status.write().await = ComfyUIStatus::Disabled;
|
||||
return Ok(());
|
||||
@@ -223,6 +229,28 @@ impl ComfyUIProcess {
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> anyhow::Result<()> {
|
||||
if self.config.uses_docker_backend() {
|
||||
let name = self.config.container_name.clone();
|
||||
let output = Command::new(docker_bin())
|
||||
.args(["stop", "-t", "10", &name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("No such container") {
|
||||
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||
last_error: stderr.trim().to_string(),
|
||||
};
|
||||
anyhow::bail!("docker stop failed: {}", stderr.trim());
|
||||
}
|
||||
}
|
||||
*self.started_at.lock().await = None;
|
||||
*self.status.write().await = ComfyUIStatus::Stopped;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut child_slot = self.child.lock().await;
|
||||
if let Some(child) = child_slot.as_mut() {
|
||||
if let Err(err) = child.start_kill() {
|
||||
@@ -265,6 +293,89 @@ impl ComfyUIProcess {
|
||||
.map(|started| started.elapsed().as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn start_docker(&self) -> anyhow::Result<()> {
|
||||
if self.health_check().await {
|
||||
*self.started_at.lock().await = Some(Instant::now());
|
||||
*self.status.write().await = ComfyUIStatus::Running {
|
||||
pid: None,
|
||||
uptime_secs: 0,
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*self.status.write().await = ComfyUIStatus::Starting;
|
||||
let name = self.config.container_name.clone();
|
||||
let _ = Command::new(docker_bin())
|
||||
.args(["rm", "-f", &name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let mut command = Command::new(docker_bin());
|
||||
command
|
||||
.arg("run")
|
||||
.arg("-d")
|
||||
.arg("--rm")
|
||||
.arg("--name")
|
||||
.arg(&name)
|
||||
.arg("-p")
|
||||
.arg(format!("127.0.0.1:{}:8188", self.config.port))
|
||||
.arg("-e")
|
||||
.arg(format!("BOOSAPI_BASE={}", self.model_backend_url()))
|
||||
.arg("-e")
|
||||
.arg(format!("BOOSAPI_BUILTIN_KEY={}", self.builtin_key))
|
||||
.arg("-e")
|
||||
.arg(format!("BOOSAPI_KEY={}", self.builtin_key));
|
||||
|
||||
let data_root = std::env::var("BOOSAPI_COMFY_DATA_ROOT")
|
||||
.unwrap_or_else(|_| "/data/boosapi-comfyui".to_string());
|
||||
for (host, container) in [
|
||||
("input", "/workspace/ComfyUI/input"),
|
||||
("output", "/workspace/ComfyUI/output"),
|
||||
("temp", "/workspace/ComfyUI/temp"),
|
||||
("user", "/workspace/ComfyUI/user"),
|
||||
] {
|
||||
let host_path = format!("{data_root}/{host}");
|
||||
tokio::fs::create_dir_all(&host_path).await?;
|
||||
command
|
||||
.arg("--mount")
|
||||
.arg(format!("type=bind,source={host_path},target={container}"));
|
||||
}
|
||||
|
||||
command.arg(&self.config.docker_image);
|
||||
let output = command.output().await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||
last_error: stderr.clone(),
|
||||
};
|
||||
anyhow::bail!("docker run failed: {stderr}");
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + std::time::Duration::from_secs(45);
|
||||
while Instant::now() < deadline {
|
||||
if self.health_check().await {
|
||||
*self.started_at.lock().await = Some(Instant::now());
|
||||
*self.status.write().await = ComfyUIStatus::Running {
|
||||
pid: None,
|
||||
uptime_secs: 0,
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||
}
|
||||
|
||||
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||
last_error: "Docker ComfyUI did not become ready in 45s".to_string(),
|
||||
};
|
||||
anyhow::bail!("Docker ComfyUI did not become ready in 45s")
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_bin() -> String {
|
||||
std::env::var("BOOSAPI_DOCKER_BIN").unwrap_or_else(|_| "docker".to_string())
|
||||
}
|
||||
|
||||
fn write_boosapi_config(
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod comfyui_process;
|
||||
pub mod cron;
|
||||
pub mod notification;
|
||||
pub mod oauth;
|
||||
pub mod register_captcha;
|
||||
pub mod registration_limiter;
|
||||
pub mod route_rebuild;
|
||||
pub mod shared;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::IpAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CAPTCHA_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
const CAPTCHA_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RegisterCaptchaChallenge {
|
||||
pub id: String,
|
||||
pub image: String,
|
||||
#[serde(rename = "expiresInSeconds")]
|
||||
pub expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CaptchaVerifyError {
|
||||
Missing,
|
||||
Expired,
|
||||
Mismatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CaptchaEntry {
|
||||
answer: String,
|
||||
expires_at: Instant,
|
||||
ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RegisterCaptchaStore {
|
||||
inner: Mutex<HashMap<String, CaptchaEntry>>,
|
||||
}
|
||||
|
||||
impl RegisterCaptchaStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn issue(&self, ip: Option<IpAddr>) -> RegisterCaptchaChallenge {
|
||||
let now = Instant::now();
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let answer = generate_answer();
|
||||
let expires_at = now + CAPTCHA_TTL;
|
||||
|
||||
let mut guard = self.inner.lock();
|
||||
guard.retain(|_, entry| entry.expires_at > now);
|
||||
guard.insert(
|
||||
id.clone(),
|
||||
CaptchaEntry {
|
||||
answer: answer.clone(),
|
||||
expires_at,
|
||||
ip,
|
||||
},
|
||||
);
|
||||
|
||||
RegisterCaptchaChallenge {
|
||||
id,
|
||||
image: render_svg_data_url(&answer),
|
||||
expires_in_seconds: CAPTCHA_TTL.as_secs(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify(
|
||||
&self,
|
||||
id: &str,
|
||||
code: &str,
|
||||
ip: Option<IpAddr>,
|
||||
) -> Result<(), CaptchaVerifyError> {
|
||||
let captcha_id = id.trim();
|
||||
let submitted = normalize_code(code);
|
||||
if captcha_id.is_empty() || submitted.is_empty() {
|
||||
return Err(CaptchaVerifyError::Missing);
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut guard = self.inner.lock();
|
||||
guard.retain(|_, entry| entry.expires_at > now);
|
||||
|
||||
let Some(entry) = guard.remove(captcha_id) else {
|
||||
return Err(CaptchaVerifyError::Expired);
|
||||
};
|
||||
|
||||
if entry.expires_at <= now {
|
||||
return Err(CaptchaVerifyError::Expired);
|
||||
}
|
||||
|
||||
if entry.ip.is_some() && ip.is_some() && entry.ip != ip {
|
||||
return Err(CaptchaVerifyError::Mismatch);
|
||||
}
|
||||
|
||||
if entry.answer == submitted {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CaptchaVerifyError::Mismatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_answer() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes.copy_from_slice(Uuid::new_v4().as_bytes());
|
||||
(0..5)
|
||||
.map(|index| {
|
||||
let offset = bytes[index] as usize % CAPTCHA_ALPHABET.len();
|
||||
CAPTCHA_ALPHABET[offset] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_code(code: &str) -> String {
|
||||
code.trim()
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.flat_map(|ch| ch.to_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_svg_data_url(answer: &str) -> String {
|
||||
let seed = Uuid::new_v4();
|
||||
let bytes = seed.as_bytes();
|
||||
let mut chars = String::new();
|
||||
for (index, ch) in answer.chars().enumerate() {
|
||||
let x = 18 + index * 28;
|
||||
let y = 42 + (bytes[index] % 9) as i32 - 4;
|
||||
let rotate = (bytes[index + 5] % 25) as i32 - 12;
|
||||
chars.push_str(&format!(
|
||||
r#"<text x="{x}" y="{y}" transform="rotate({rotate} {x} {y})">{ch}</text>"#
|
||||
));
|
||||
}
|
||||
|
||||
let mut lines = String::new();
|
||||
for index in 0..5 {
|
||||
let a = bytes[index] as u32;
|
||||
let b = bytes[index + 5] as u32;
|
||||
let c = bytes[index + 10] as u32;
|
||||
lines.push_str(&format!(
|
||||
r#"<path d="M{} {} C{} {},{} {},{} {}" />"#,
|
||||
a % 24,
|
||||
12 + b % 44,
|
||||
34 + b % 28,
|
||||
4 + c % 56,
|
||||
76 + a % 28,
|
||||
4 + b % 56,
|
||||
130 + c % 28,
|
||||
12 + a % 44
|
||||
));
|
||||
}
|
||||
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-label="captcha">
|
||||
<rect width="180" height="64" rx="8" fill="#f8fafc"/>
|
||||
<g fill="none" stroke="#94a3b8" stroke-width="1.5" opacity=".55">{lines}</g>
|
||||
<g fill="#0f172a" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="28" font-weight="800" letter-spacing="3">{chars}</g>
|
||||
<g fill="#64748b" opacity=".45">
|
||||
<circle cx="22" cy="14" r="2"/><circle cx="152" cy="18" r="2"/><circle cx="46" cy="54" r="1.7"/><circle cx="128" cy="49" r="1.8"/>
|
||||
</g>
|
||||
</svg>"##
|
||||
);
|
||||
format!(
|
||||
"data:image/svg+xml;base64,{}",
|
||||
general_purpose::STANDARD.encode(svg.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
#[test]
|
||||
fn captcha_is_one_time() {
|
||||
let store = RegisterCaptchaStore::new();
|
||||
let challenge = store.issue(None);
|
||||
let answer = store
|
||||
.inner
|
||||
.lock()
|
||||
.get(&challenge.id)
|
||||
.unwrap()
|
||||
.answer
|
||||
.clone();
|
||||
|
||||
store.verify(&challenge.id, &answer, None).unwrap();
|
||||
assert_eq!(
|
||||
store.verify(&challenge.id, &answer, None).unwrap_err(),
|
||||
CaptchaVerifyError::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captcha_rejects_other_ip() {
|
||||
let store = RegisterCaptchaStore::new();
|
||||
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
let answer = store
|
||||
.inner
|
||||
.lock()
|
||||
.get(&challenge.id)
|
||||
.unwrap()
|
||||
.answer
|
||||
.clone();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.verify(
|
||||
&challenge.id,
|
||||
&answer,
|
||||
Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))
|
||||
)
|
||||
.unwrap_err(),
|
||||
CaptchaVerifyError::Mismatch
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,29 @@ pub async fn record_base64_asset(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_bytes_asset(
|
||||
state: &AppState,
|
||||
mut input: WorkspaceAssetInput,
|
||||
bytes: &[u8],
|
||||
mime_type: &str,
|
||||
) -> Result<StoredWorkspaceAsset, AppError> {
|
||||
let public_id = format!("wa_{}", Uuid::new_v4().simple());
|
||||
input.remote_url = None;
|
||||
let ext = extension_for_mime(mime_type);
|
||||
let path = write_asset_file(input.user_id, &input.source, &public_id, ext, bytes).await?;
|
||||
insert_asset(
|
||||
state,
|
||||
&input,
|
||||
&public_id,
|
||||
"completed",
|
||||
Some(mime_type),
|
||||
Some(&path),
|
||||
bytes.len() as i64,
|
||||
input.metadata.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_image_assets_from_response(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
export declare function parseBatchApiKeys(input: unknown): string[];
|
||||
export declare function buildBatchApiKeyConnectionName(
|
||||
baseName: string | null | undefined,
|
||||
index: number,
|
||||
total: number,
|
||||
): string;
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
buildBatchApiKeyConnectionName,
|
||||
parseBatchApiKeys,
|
||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
||||
Vendored
-40
@@ -1,40 +0,0 @@
|
||||
export type UpdateVersionCandidateLike = {
|
||||
normalizedVersion?: string | null;
|
||||
displayVersion?: string | null;
|
||||
tagName?: string | null;
|
||||
digest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateHelperRuntimeLike = {
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateReminderCandidate = {
|
||||
source: 'github-release' | 'docker-hub-tag';
|
||||
kind: 'new-version' | 'new-digest';
|
||||
candidateKey: string;
|
||||
displayVersion: string;
|
||||
tagName: string;
|
||||
digest: string | null;
|
||||
};
|
||||
|
||||
export declare function normalizeStableVersion(value?: string | null): string;
|
||||
export declare function compareStableVersions(
|
||||
left?: string | null,
|
||||
right?: string | null,
|
||||
): number | null;
|
||||
export declare function isSameImageTarget(
|
||||
current: UpdateHelperRuntimeLike | null | undefined,
|
||||
target: { tag?: string | null; digest?: string | null },
|
||||
): boolean;
|
||||
export declare function buildUpdateReminderCandidateKey(
|
||||
source: 'github-release' | 'docker-hub-tag',
|
||||
candidate: { tagName?: string | null; digest?: string | null },
|
||||
): string;
|
||||
export declare function resolveUpdateReminderCandidate(input: {
|
||||
currentVersion?: string | null;
|
||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateReminderCandidate | null;
|
||||
@@ -1,7 +0,0 @@
|
||||
export {
|
||||
buildUpdateReminderCandidateKey,
|
||||
compareStableVersions,
|
||||
isSameImageTarget,
|
||||
normalizeStableVersion,
|
||||
resolveUpdateReminderCandidate,
|
||||
} from '../server/shared/updateCenterReminder.js';
|
||||
+7
-1
@@ -2022,7 +2022,13 @@ export const api = {
|
||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
};
|
||||
export const userApi = {
|
||||
register: (data: { username: string; email: string; password: string }) =>
|
||||
register: (data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
captchaCode: string;
|
||||
}) =>
|
||||
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
|
||||
login: (data: { email: string; password: string }) =>
|
||||
userRequest("/api/users/login", { method: "POST", body: JSON.stringify(data) }),
|
||||
|
||||
@@ -6,7 +6,7 @@ export {
|
||||
stripCommonWrappers,
|
||||
type BrandInfo,
|
||||
type BrandMatchContext,
|
||||
} from '../../server/shared/modelBrand.js';
|
||||
} from '../../shared/modelBrand.js';
|
||||
|
||||
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
||||
anthropic: 'claude-color',
|
||||
|
||||
@@ -101,6 +101,11 @@ const WELCOME_MESSAGE: AgentMessage = {
|
||||
'你好,我是视频助手。把脚本、分镜或角色想法发给我;确认后我会分步生成角色四视图、场景图、TTS 和 ComfyUI 工作流。',
|
||||
};
|
||||
|
||||
const MAX_ATTACHMENT_COUNT = 8;
|
||||
const MAX_INLINE_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_INLINE_TOTAL_BYTES = 36 * 1024 * 1024;
|
||||
const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
'帮我把这个创意拆成角色、场景和镜头节奏',
|
||||
'开始制作,先生成角色和场景,不要一次输出工作流',
|
||||
@@ -308,6 +313,18 @@ function fileExtension(name: string): string {
|
||||
return name.split('.').pop()?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function isTextAttachment(file: File, mimeType: string): boolean {
|
||||
if (mimeType.startsWith('text/')) return true;
|
||||
if (['application/json', 'application/x-ndjson', 'application/xml'].includes(mimeType)) {
|
||||
@@ -400,10 +417,35 @@ async function fileToAttachment(file: File): Promise<AgentAttachment> {
|
||||
mediaKind,
|
||||
};
|
||||
|
||||
if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
if (file.size > MAX_INLINE_IMAGE_BYTES) {
|
||||
return {
|
||||
...base,
|
||||
text: [
|
||||
`[图片素材: ${file.name}]`,
|
||||
`类型: ${mimeType}`,
|
||||
`大小: ${formatBytes(file.size)}`,
|
||||
`图片超过 ${formatBytes(MAX_INLINE_IMAGE_BYTES)},已作为引用记录,未内联发送原图数据。`,
|
||||
'如需精确参考,请压缩图片或上传较小版本。',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { ...base, dataUrl: await fileToDataUrl(file) };
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
|
||||
return {
|
||||
...base,
|
||||
text: [
|
||||
`[${mimeType.startsWith('video/') ? '视频' : '音频'}素材: ${file.name}]`,
|
||||
`类型: ${mimeType}`,
|
||||
`大小: ${formatBytes(file.size)}`,
|
||||
'当前版本会记录该素材引用和用途说明,不会把完整二进制内容塞进对话请求。',
|
||||
'请在输入框说明需要参考的镜头、节奏、音色或时间段;后续接入服务端解析后会自动提取媒体信息。',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
if (isDocumentAttachment(file, mimeType) && !isTextAttachment(file, mimeType)) {
|
||||
return {
|
||||
...base,
|
||||
@@ -1236,8 +1278,29 @@ export default function ComfyUIAgent() {
|
||||
if (!files?.length) return;
|
||||
setReadingAttachments(true);
|
||||
try {
|
||||
const next = await Promise.all(Array.from(files).slice(0, 8).map(fileToAttachment));
|
||||
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, 8));
|
||||
const selectedFiles = Array.from(files).slice(0, MAX_ATTACHMENT_COUNT);
|
||||
const oversized = selectedFiles.filter((file) => file.size > MAX_ATTACHMENT_BYTES);
|
||||
if (oversized.length > 0) {
|
||||
toast.error(`附件过大:${oversized[0].name} 超过 ${formatBytes(MAX_ATTACHMENT_BYTES)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingInlineBytes = attachments.reduce((sum, attachment) => (
|
||||
sum + (attachment.dataUrl ? attachment.size : 0)
|
||||
), 0);
|
||||
const nextInlineBytes = selectedFiles.reduce((sum, file) => (
|
||||
sum + (file.type.startsWith('image/') && file.size <= MAX_INLINE_IMAGE_BYTES ? file.size : 0)
|
||||
), 0);
|
||||
if (existingInlineBytes + nextInlineBytes > MAX_INLINE_TOTAL_BYTES) {
|
||||
toast.error(`图片附件总量超过 ${formatBytes(MAX_INLINE_TOTAL_BYTES)},请减少数量或压缩后再上传`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length > MAX_ATTACHMENT_COUNT || attachments.length + selectedFiles.length > MAX_ATTACHMENT_COUNT) {
|
||||
toast.info(`最多保留 ${MAX_ATTACHMENT_COUNT} 个附件,超出的部分已忽略。`);
|
||||
}
|
||||
const next = await Promise.all(selectedFiles.map(fileToAttachment));
|
||||
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, MAX_ATTACHMENT_COUNT));
|
||||
setAttachments(withReferences);
|
||||
if (next.some((attachment) => isDocumentAttachment(
|
||||
new File([], attachment.name, { type: attachment.mimeType }),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import UserManagement from './UserManagement.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUsers: vi.fn(),
|
||||
getUserUsage: vi.fn(),
|
||||
getUserKeys: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
createUserKey: vi.fn(),
|
||||
deleteUserKey: vi.fn(),
|
||||
resetUserKey: vi.fn(),
|
||||
updateUserKey: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function flushLoad() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('UserManagement', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('window', {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
prompt: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
apiMock.getUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 4,
|
||||
username: 'autouser',
|
||||
email: 'auto@test.com',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
total_requests: 15,
|
||||
total_cost: 0,
|
||||
last_7_days_cost: 0,
|
||||
},
|
||||
],
|
||||
stats: { total: 1, active: 1, disabled: 0, admins: 0 },
|
||||
meta: { page: 1, pageSize: 25, total: 1, totalPages: 1 },
|
||||
});
|
||||
apiMock.getUserUsage.mockResolvedValue({
|
||||
data: {
|
||||
totalRequests: 15,
|
||||
totalCost: 0,
|
||||
last7DaysCost: 0,
|
||||
trend: [{ day: '2026-05-25', requests: 2, cost: 0 }],
|
||||
recentLogs: [
|
||||
{
|
||||
id: 1,
|
||||
model: 'gpt-image-2',
|
||||
cost: 0,
|
||||
promptTokens: 10,
|
||||
completionTokens: 0,
|
||||
totalTokens: 10,
|
||||
createdAt: '2026-05-25 11:30:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('expands user usage with the camelCase API response without crashing', async () => {
|
||||
let root!: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(<UserManagement />);
|
||||
});
|
||||
await flushLoad();
|
||||
|
||||
const rows = root.root.findAll((node) => (
|
||||
node.type === 'tr'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('autouser')
|
||||
));
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
rows[0].props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('用量详情');
|
||||
expect(text).toContain('gpt-image-2');
|
||||
expect(apiMock.getUserUsage).toHaveBeenCalledWith(4);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,30 @@ interface UserListMeta {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface UserUsageTrendItem {
|
||||
day: string;
|
||||
requests: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
interface UserUsageLogItem {
|
||||
id: number | string;
|
||||
model: string;
|
||||
cost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface UserUsageData {
|
||||
totalRequests: number;
|
||||
totalCost: number;
|
||||
last7DaysCost: number;
|
||||
trend: UserUsageTrendItem[];
|
||||
recentLogs: UserUsageLogItem[];
|
||||
}
|
||||
|
||||
type UserFormMode = 'create' | 'edit';
|
||||
|
||||
interface UserFormState {
|
||||
@@ -104,6 +128,35 @@ function normalizeMeta(result: any, users: User[], page: number, pageSize: numbe
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUserUsage(raw: any): UserUsageData {
|
||||
const data = raw?.data ?? raw ?? {};
|
||||
const trend = Array.isArray(data.trend) ? data.trend : [];
|
||||
const recentLogs = Array.isArray(data.recentLogs)
|
||||
? data.recentLogs
|
||||
: Array.isArray(data.recent_logs)
|
||||
? data.recent_logs
|
||||
: [];
|
||||
return {
|
||||
totalRequests: Number(data.totalRequests ?? data.total_requests ?? 0),
|
||||
totalCost: Number(data.totalCost ?? data.total_cost ?? 0),
|
||||
last7DaysCost: Number(data.last7DaysCost ?? data.last_7_days_cost ?? 0),
|
||||
trend: trend.map((item: any) => ({
|
||||
day: String(item.day ?? ''),
|
||||
requests: Number(item.requests ?? item.request_count ?? 0),
|
||||
cost: Number(item.cost ?? 0),
|
||||
})),
|
||||
recentLogs: recentLogs.map((log: any, index: number) => ({
|
||||
id: log.id ?? `${log.createdAt ?? log.created_at ?? 'log'}-${index}`,
|
||||
model: String(log.model ?? '-'),
|
||||
cost: Number(log.cost ?? 0),
|
||||
promptTokens: Number(log.promptTokens ?? log.prompt_tokens ?? 0),
|
||||
completionTokens: Number(log.completionTokens ?? log.completion_tokens ?? 0),
|
||||
totalTokens: Number(log.totalTokens ?? log.total_tokens ?? 0),
|
||||
createdAt: log.createdAt ?? log.created_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '-';
|
||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||
@@ -145,7 +198,7 @@ export default function UserManagement() {
|
||||
const [form, setForm] = useState<UserFormState>(emptyForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
|
||||
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
|
||||
const [usageData, setUsageData] = useState<any>(null);
|
||||
const [usageData, setUsageData] = useState<UserUsageData | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [keysData, setKeysData] = useState<any[]>([]);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
@@ -156,9 +209,10 @@ export default function UserManagement() {
|
||||
setUsageLoading(true);
|
||||
try {
|
||||
const data = await api.getUserUsage(userId);
|
||||
setUsageData(data.data);
|
||||
setUsageData(normalizeUserUsage(data));
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load usage:', err);
|
||||
setUsageData(null);
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
@@ -630,15 +684,15 @@ export default function UserManagement() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
|
||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.total_requests.toLocaleString()}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.totalRequests.toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.total_cost.toFixed(4)}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.totalCost.toFixed(4)}</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last_7_days_cost.toFixed(4)}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last7DaysCost.toFixed(4)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -678,15 +732,15 @@ export default function UserManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usageData.recent_logs.map((log: any) => (
|
||||
{usageData.recentLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td>{formatDate(log.created_at)}</td>
|
||||
<td>{formatDate(log.createdAt)}</td>
|
||||
<td><code style={{ fontSize: 11 }}>{log.model}</code></td>
|
||||
<td style={{ color: 'var(--color-primary)' }}>${log.cost.toFixed(5)}</td>
|
||||
<td>{log.prompt_tokens}/{log.completion_tokens}/{log.total_tokens}</td>
|
||||
<td>{log.promptTokens}/{log.completionTokens}/{log.totalTokens}</td>
|
||||
</tr>
|
||||
))}
|
||||
{usageData.recent_logs.length === 0 && (
|
||||
{usageData.recentLogs.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import LoginShell from './auth/LoginShell.js';
|
||||
@@ -9,9 +9,42 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [captchaId, setCaptchaId] = useState('');
|
||||
const [captchaImage, setCaptchaImage] = useState('');
|
||||
const [captchaCode, setCaptchaCode] = useState('');
|
||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadCaptcha = useCallback(async () => {
|
||||
setCaptchaLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/users/register-captcha', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`验证码加载失败 (${res.status})`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const challenge = payload?.data || payload;
|
||||
setCaptchaId(String(challenge?.id || ''));
|
||||
setCaptchaImage(String(challenge?.image || ''));
|
||||
setCaptchaCode('');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '验证码加载失败');
|
||||
setCaptchaId('');
|
||||
setCaptchaImage('');
|
||||
setCaptchaCode('');
|
||||
} finally {
|
||||
setCaptchaLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCaptcha();
|
||||
}, [loadCaptcha]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !email.trim() || !password) return;
|
||||
@@ -23,13 +56,23 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
setError('密码至少 6 个字符');
|
||||
return;
|
||||
}
|
||||
if (!captchaId || !captchaCode.trim()) {
|
||||
setError('请输入图像验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.trim(), email: email.trim(), password }),
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
captchaId,
|
||||
captchaCode: captchaCode.trim(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -41,6 +84,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '注册失败');
|
||||
void loadCaptcha();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -113,9 +157,60 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}>
|
||||
<input
|
||||
id="reg-captcha-input"
|
||||
type="text"
|
||||
placeholder="输入右侧字符"
|
||||
value={captchaCode}
|
||||
onChange={(e) => { setCaptchaCode(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
maxLength={8}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCaptcha()}
|
||||
disabled={captchaLoading || loading}
|
||||
aria-label="刷新图像验证码"
|
||||
title="点击刷新验证码"
|
||||
style={{
|
||||
height: 44,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: 0,
|
||||
background: 'var(--color-surface)',
|
||||
cursor: captchaLoading || loading ? 'not-allowed' : 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{captchaImage && !captchaLoading ? (
|
||||
<img
|
||||
src={captchaImage}
|
||||
alt="图像验证码"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
|
||||
disabled={
|
||||
loading
|
||||
|| captchaLoading
|
||||
|| !username.trim()
|
||||
|| !email.trim()
|
||||
|| !password
|
||||
|| !confirmPassword
|
||||
|| !captchaId
|
||||
|| !captchaCode.trim()
|
||||
}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
+44
-14
@@ -1,34 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
type WorkflowRuntime = {
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
const LOCAL_WORKFLOW_URL = '/workflow-ui/';
|
||||
const DOCKER_WORKFLOW_URL = '/api/comfyui/proxy/';
|
||||
|
||||
export default function Workflows() {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
||||
const [workflowUrl, setWorkflowUrl] = React.useState(LOCAL_WORKFLOW_URL);
|
||||
const [isCloud, setIsCloud] = React.useState(false);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const openFullscreen = () => {
|
||||
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
|
||||
window.open(workflowUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const reloadFrame = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = '/workflow-ui/';
|
||||
iframeRef.current.src = workflowUrl;
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
const loadRuntime = async () => {
|
||||
const runtimeResponse = await fetch('/api/workflow-ui/runtime', { signal: controller.signal });
|
||||
const runtime = runtimeResponse.ok ? ((await runtimeResponse.json()) as WorkflowRuntime) : {};
|
||||
const cloudMode = runtime.mode === 'cloud';
|
||||
const dockerMode = runtime.mode === 'docker';
|
||||
const targetUrl = dockerMode ? DOCKER_WORKFLOW_URL : LOCAL_WORKFLOW_URL;
|
||||
if (cancelled) return;
|
||||
setIsCloud(cloudMode);
|
||||
setWorkflowUrl(targetUrl);
|
||||
const response = await fetch(targetUrl, { signal: controller.signal });
|
||||
if (cancelled) return;
|
||||
setReady(response.ok);
|
||||
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||
};
|
||||
void loadRuntime().catch((error) => {
|
||||
if (cancelled || error?.name === 'AbortError') return;
|
||||
setReady(false);
|
||||
setMessage('工作流入口不可用,请检查服务配置');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
@@ -39,6 +56,8 @@ export default function Workflows() {
|
||||
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={toolbarStyle}>
|
||||
<div style={toolbarTitleStyle}>工作流</div>
|
||||
{!isCloud && workflowUrl === DOCKER_WORKFLOW_URL && <div style={cloudBadgeStyle}>Docker 完整版 ComfyUI</div>}
|
||||
{isCloud && <div style={cloudBadgeStyle}>Comfy Cloud API 后端</div>}
|
||||
<div style={toolbarActionsStyle}>
|
||||
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
||||
刷新
|
||||
@@ -51,9 +70,10 @@ export default function Workflows() {
|
||||
{ready ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/workflow-ui/"
|
||||
src={workflowUrl}
|
||||
title="Workflow Studio"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
@@ -79,6 +99,16 @@ const toolbarStyle: React.CSSProperties = {
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const cloudBadgeStyle: React.CSSProperties = {
|
||||
marginRight: 'auto',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 999,
|
||||
padding: '3px 8px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const toolbarTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
|
||||
+195
-47
@@ -1,9 +1,13 @@
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 PAGE_SIZE = 30;
|
||||
const AUTO_PREVIEW_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||
const AUTO_PREVIEW_COUNT = 12;
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'all', label: '全部来源' },
|
||||
{ value: 'picture-agent', label: '生图助手' },
|
||||
@@ -51,6 +55,23 @@ function assetCreatedAtLabel(asset: WorkspaceAsset) {
|
||||
return formatDateTimeLocal(asset.createdAt);
|
||||
}
|
||||
|
||||
function fileEndpoint(audience: SessionAudience, publicId: string) {
|
||||
return `${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(publicId)}/file`;
|
||||
}
|
||||
|
||||
function canAutoPreview(asset: WorkspaceAsset) {
|
||||
return Boolean(
|
||||
asset.hasLocalFile
|
||||
&& asset.kind === 'image'
|
||||
&& Number(asset.sizeBytes || 0) > 0
|
||||
&& Number(asset.sizeBytes || 0) <= AUTO_PREVIEW_LIMIT_BYTES,
|
||||
);
|
||||
}
|
||||
|
||||
function canRenderPreview(asset: WorkspaceAsset) {
|
||||
return asset.kind === 'image' || asset.kind === 'video' || asset.kind === 'audio';
|
||||
}
|
||||
|
||||
export default function Workspace() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
@@ -62,7 +83,11 @@ export default function Workspace() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
||||
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
|
||||
const [loadedLocalPreviews, setLoadedLocalPreviews] = useState<Set<string>>(() => new Set());
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
|
||||
const sourceStats = useMemo(() => {
|
||||
@@ -78,9 +103,13 @@ export default function Workspace() {
|
||||
source: source === 'all' ? undefined : source,
|
||||
kind: kind === 'all' ? undefined : kind,
|
||||
q: query.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 60,
|
||||
}), [kind, query, source]);
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}), [kind, page, query, source]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [kind, query, source]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -89,11 +118,13 @@ export default function Workspace() {
|
||||
const response = audience === 'user'
|
||||
? await userApi.getWorkspaceAssets(params)
|
||||
: await api.getWorkspaceAssets(params);
|
||||
setItems(Array.isArray(response.items) ? response.items : []);
|
||||
const nextItems = Array.isArray(response.items) ? response.items : [];
|
||||
setItems((current) => (params.page && params.page > 1 ? [...current, ...nextItems] : nextItems));
|
||||
setTotal(Number(response.total || 0));
|
||||
setSelectedId((current) => {
|
||||
if (current && response.items?.some((item) => item.publicId === current)) return current;
|
||||
return response.items?.[0]?.publicId || '';
|
||||
if (params.page && params.page > 1 && current) return current;
|
||||
if (current && nextItems.some((item) => item.publicId === current)) return current;
|
||||
return nextItems[0]?.publicId || '';
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '工作空间加载失败');
|
||||
@@ -106,38 +137,76 @@ export default function Workspace() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrlsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const objectUrls: string[] = [];
|
||||
async function hydratePreviews() {
|
||||
const candidates = items
|
||||
.filter((item) => item.hasLocalFile && (item.kind === 'image' || item.kind === 'video' || item.kind === 'audio'))
|
||||
.slice(0, 24);
|
||||
.slice(0, AUTO_PREVIEW_COUNT)
|
||||
.filter((item) => canAutoPreview(item) && !previewUrls[item.publicId])
|
||||
.slice(0, AUTO_PREVIEW_COUNT);
|
||||
const next: Record<string, string> = {};
|
||||
await Promise.all(candidates.map(async (asset) => {
|
||||
for (const asset of candidates) {
|
||||
if (cancelled) break;
|
||||
try {
|
||||
const response = await fetchSessionResponse(
|
||||
audience,
|
||||
`${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`,
|
||||
{ timeoutMs: 30_000 },
|
||||
fileEndpoint(audience, asset.publicId),
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
if (!response.ok) continue;
|
||||
const blob = await response.blob();
|
||||
if (cancelled) break;
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrls.push(url);
|
||||
objectUrlsRef.current.add(url);
|
||||
next[asset.publicId] = url;
|
||||
} catch {
|
||||
// Preview hydration is best-effort; the asset row still remains usable.
|
||||
}
|
||||
}));
|
||||
if (!cancelled) setPreviewUrls(next);
|
||||
}
|
||||
if (!cancelled && Object.keys(next).length > 0) {
|
||||
setPreviewUrls((current) => ({ ...current, ...next }));
|
||||
setLoadedLocalPreviews((current) => new Set([...current, ...Object.keys(next)]));
|
||||
}
|
||||
}
|
||||
void hydratePreviews();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [audience, items]);
|
||||
}, [audience, items, previewUrls]);
|
||||
|
||||
const loadPreview = useCallback(async (asset: WorkspaceAsset) => {
|
||||
if (!asset.hasLocalFile || !canRenderPreview(asset)) return;
|
||||
setPreviewLoading((current) => ({ ...current, [asset.publicId]: true }));
|
||||
try {
|
||||
const response = await fetchSessionResponse(
|
||||
audience,
|
||||
fileEndpoint(audience, asset.publicId),
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
if (!response.ok) throw new Error('预览加载失败');
|
||||
const blob = await response.blob();
|
||||
const oldUrl = previewUrls[asset.publicId];
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrlsRef.current.add(url);
|
||||
setPreviewUrls((current) => ({ ...current, [asset.publicId]: url }));
|
||||
setLoadedLocalPreviews((current) => new Set(current).add(asset.publicId));
|
||||
if (oldUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(oldUrl);
|
||||
objectUrlsRef.current.delete(oldUrl);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '预览加载失败');
|
||||
} finally {
|
||||
setPreviewLoading((current) => ({ ...current, [asset.publicId]: false }));
|
||||
}
|
||||
}, [audience, previewUrls]);
|
||||
|
||||
const handleDelete = async (asset: WorkspaceAsset) => {
|
||||
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
|
||||
@@ -154,18 +223,60 @@ export default function Workspace() {
|
||||
};
|
||||
|
||||
const handleDownload = async (asset: WorkspaceAsset) => {
|
||||
const previewUrl = previewUrls[asset.publicId];
|
||||
const href = previewUrl || asset.remoteUrl || '';
|
||||
if (!href) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = asset.fileName || `${asset.publicId}`;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
try {
|
||||
let href = asset.remoteUrl || '';
|
||||
if (asset.hasLocalFile) {
|
||||
const response = await fetchSessionResponse(
|
||||
audience,
|
||||
fileEndpoint(audience, asset.publicId),
|
||||
{ timeoutMs: 180_000 },
|
||||
);
|
||||
if (!response.ok) throw new Error('下载失败');
|
||||
const blob = await response.blob();
|
||||
href = URL.createObjectURL(blob);
|
||||
}
|
||||
if (!href) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = asset.fileName || `${asset.publicId}`;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
if (href.startsWith('blob:')) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(href), 30_000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const renderPreview = (asset: WorkspaceAsset, compact = false) => {
|
||||
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||
const isLargeLocal = Boolean(asset.hasLocalFile && Number(asset.sizeBytes || 0) > AUTO_PREVIEW_LIMIT_BYTES);
|
||||
if (asset.kind === 'image' && preview) {
|
||||
return <img src={preview} alt="" style={mediaStyle} loading="lazy" />;
|
||||
}
|
||||
if (asset.kind === 'video' && preview) {
|
||||
return <video src={preview} style={mediaStyle} muted controls={!compact} preload="metadata" />;
|
||||
}
|
||||
if (asset.kind === 'audio' && preview) {
|
||||
return compact ? <div style={placeholderStyle}>音频</div> : <audio src={preview} controls style={{ width: '100%' }} />;
|
||||
}
|
||||
return (
|
||||
<div style={placeholderStyle}>
|
||||
<span>{kindLabel(asset.kind)}</span>
|
||||
{asset.hasLocalFile && canRenderPreview(asset) ? (
|
||||
<span style={placeholderHintStyle}>
|
||||
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hasMore = items.length < total;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
@@ -257,16 +368,11 @@ export default function Workspace() {
|
||||
onClick={() => setSelectedId(asset.publicId)}
|
||||
>
|
||||
<div style={thumbStyle}>
|
||||
{asset.kind === 'image' && preview ? (
|
||||
<img src={preview} alt="" style={mediaStyle} />
|
||||
) : asset.kind === 'video' && preview ? (
|
||||
<video src={preview} style={mediaStyle} muted />
|
||||
) : asset.kind === 'audio' ? (
|
||||
<div style={placeholderStyle}>音频</div>
|
||||
) : (
|
||||
<div style={placeholderStyle}>{kindLabel(asset.kind)}</div>
|
||||
)}
|
||||
{renderPreview(asset, true)}
|
||||
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
|
||||
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
|
||||
<span style={previewBadgeStyle}>{formatBytes(asset.sizeBytes)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={cardBodyStyle}>
|
||||
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
||||
@@ -280,6 +386,18 @@ export default function Workspace() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div style={loadMoreStyle}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '加载中...' : `加载更多(${items.length}/${total})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside style={detailStyle}>
|
||||
@@ -293,16 +411,19 @@ export default function Workspace() {
|
||||
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
|
||||
</div>
|
||||
<div style={detailPreviewStyle}>
|
||||
{selected.kind === 'image' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
|
||||
<img src={previewUrls[selected.publicId] || selected.remoteUrl || ''} alt="" style={mediaStyle} />
|
||||
) : selected.kind === 'video' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
|
||||
<video src={previewUrls[selected.publicId] || selected.remoteUrl || ''} controls style={mediaStyle} />
|
||||
) : selected.kind === 'audio' && previewUrls[selected.publicId] ? (
|
||||
<audio src={previewUrls[selected.publicId]} controls style={{ width: '100%' }} />
|
||||
) : (
|
||||
<div style={placeholderStyle}>{kindLabel(selected.kind)}</div>
|
||||
)}
|
||||
{renderPreview(selected)}
|
||||
</div>
|
||||
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ width: '100%', marginTop: 10 }}
|
||||
onClick={() => void loadPreview(selected)}
|
||||
disabled={Boolean(previewLoading[selected.publicId])}
|
||||
>
|
||||
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
|
||||
</button>
|
||||
)}
|
||||
<div style={detailMetaStyle}>
|
||||
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
||||
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
||||
@@ -402,13 +523,22 @@ const placeholderStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
color: 'var(--color-text-muted)',
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
|
||||
};
|
||||
|
||||
const placeholderHintStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
const cardBodyStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -535,6 +665,24 @@ const typeBadgeStyle: CSSProperties = {
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const previewBadgeStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
borderRadius: 999,
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const loadMoreStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: 18,
|
||||
};
|
||||
|
||||
const cardFooterStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -159,8 +159,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | '
|
||||
};
|
||||
}
|
||||
|
||||
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
|
||||
// src/server/services/siteApiEndpointService.ts.
|
||||
// Keep this in sync with the Rust site endpoint normalization in admin account routes.
|
||||
function normalizeSiteApiEndpointUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
+175
-20
@@ -9,7 +9,7 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkflowProgressEvent {
|
||||
@@ -20,16 +20,26 @@ pub enum WorkflowProgressEvent {
|
||||
NodeStarted {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
node_type: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
},
|
||||
NodeCompleted {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
node_type: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
output: Value,
|
||||
},
|
||||
NodeProgress {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
node_type: String,
|
||||
value: usize,
|
||||
max: usize,
|
||||
message: String,
|
||||
},
|
||||
Finished {
|
||||
prompt_id: String,
|
||||
success: bool,
|
||||
@@ -86,6 +96,7 @@ pub async fn run_workflow_with_progress(
|
||||
WorkflowProgressEvent::NodeStarted {
|
||||
prompt_id: run_public_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
index,
|
||||
total,
|
||||
},
|
||||
@@ -94,7 +105,18 @@ pub async fn run_workflow_with_progress(
|
||||
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 {
|
||||
match execute_node(
|
||||
state,
|
||||
&ctx,
|
||||
node,
|
||||
inputs.clone(),
|
||||
progress.clone(),
|
||||
run_public_id.clone(),
|
||||
index,
|
||||
total,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
storage::complete_node_run(state, node_run_id, &result.outputs).await?;
|
||||
emit_progress(
|
||||
@@ -102,6 +124,7 @@ pub async fn run_workflow_with_progress(
|
||||
WorkflowProgressEvent::NodeCompleted {
|
||||
prompt_id: run_public_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
index: index + 1,
|
||||
total,
|
||||
output: result.outputs.clone(),
|
||||
@@ -183,6 +206,10 @@ async fn execute_node(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
progress: Option<WorkflowProgressCallback>,
|
||||
prompt_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
) -> AppResult<NodeExecution> {
|
||||
match node.node_type.as_str() {
|
||||
"input.text" => {
|
||||
@@ -202,8 +229,12 @@ async fn execute_node(
|
||||
"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.text_to_video" => {
|
||||
text_to_video(ctx, node, inputs, progress, prompt_id, index, total).await
|
||||
}
|
||||
"boosapi.image_to_video" => {
|
||||
image_to_video(ctx, node, inputs, progress, prompt_id, index, total).await
|
||||
}
|
||||
"boosapi.video_status" => video_status(ctx, node, inputs).await,
|
||||
"CheckpointLoaderSimple" => {
|
||||
let model = param_str(node, "ckpt_name", "gpt-image-2");
|
||||
@@ -286,9 +317,7 @@ async fn execute_node(
|
||||
.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_or_else(|| AppError::BadRequest(format!("{} missing images input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "IMAGE": image, "image": image }),
|
||||
@@ -323,7 +352,12 @@ async fn execute_node(
|
||||
.get("file")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("image").cloned())
|
||||
.or_else(|| inputs.get("images").and_then(|images| images.get(0)).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,
|
||||
@@ -542,6 +576,10 @@ async fn text_to_video(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
progress: Option<WorkflowProgressCallback>,
|
||||
prompt_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
@@ -551,13 +589,17 @@ async fn text_to_video(
|
||||
)));
|
||||
}
|
||||
let body = video_request_body(node, prompt, None);
|
||||
execute_video_generation(ctx, inputs, body).await
|
||||
execute_video_generation(ctx, node, inputs, body, progress, prompt_id, index, total).await
|
||||
}
|
||||
|
||||
async fn image_to_video(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
progress: Option<WorkflowProgressCallback>,
|
||||
prompt_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
@@ -573,7 +615,7 @@ async fn image_to_video(
|
||||
Some(image_url)
|
||||
};
|
||||
let body = video_request_body(node, prompt, image_url);
|
||||
execute_video_generation(ctx, inputs, body).await
|
||||
execute_video_generation(ctx, node, inputs, body, progress, prompt_id, index, total).await
|
||||
}
|
||||
|
||||
async fn video_status(
|
||||
@@ -604,7 +646,10 @@ async fn video_status(
|
||||
|
||||
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(
|
||||
"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, "");
|
||||
@@ -627,22 +672,94 @@ fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<Str
|
||||
|
||||
async fn execute_video_generation(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
body: Value,
|
||||
progress: Option<WorkflowProgressCallback>,
|
||||
prompt_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
) -> AppResult<NodeExecution> {
|
||||
emit_node_progress(
|
||||
&progress,
|
||||
&prompt_id,
|
||||
node,
|
||||
index,
|
||||
total,
|
||||
"正在创建视频任务...",
|
||||
);
|
||||
let raw = post_json(ctx, "/videos", &body).await?;
|
||||
let task_id = extract_task_id(&raw);
|
||||
let video = video_output_from_response(&raw);
|
||||
let mut final_raw = raw.clone();
|
||||
let mut video = video_output_from_response(&raw);
|
||||
if video.is_null() && !task_id.is_null() && param_bool(node, "wait_for_result", true) {
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("seedance-1-0-lite-t2v");
|
||||
let interval = param_i64(node, "poll_interval_secs", 5).clamp(1, 30) as u64;
|
||||
let timeout = param_i64(node, "timeout_secs", 180).clamp(10, 1800) as u64;
|
||||
let task = task_id.as_str().unwrap_or_default();
|
||||
let started = std::time::Instant::now();
|
||||
let mut attempt = 0_usize;
|
||||
let max_attempts = ((timeout + interval - 1) / interval).max(1) as usize;
|
||||
while started.elapsed() < Duration::from_secs(timeout) {
|
||||
attempt += 1;
|
||||
emit_node_progress(
|
||||
&progress,
|
||||
&prompt_id,
|
||||
node,
|
||||
index,
|
||||
total,
|
||||
&format!("视频任务已创建,正在查询结果 {attempt}/{max_attempts}..."),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||
let status_path = format!(
|
||||
"/videos/{}?model={}",
|
||||
simple_urlencode(task),
|
||||
simple_urlencode(model)
|
||||
);
|
||||
final_raw = get_json(ctx, &status_path).await?;
|
||||
video = video_output_from_response(&final_raw);
|
||||
if !video.is_null() || video_task_is_terminal_failure(&final_raw) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"task_id": task_id,
|
||||
"video": video,
|
||||
"raw": raw
|
||||
"raw": final_raw,
|
||||
"create_response": raw
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_node_progress(
|
||||
progress: &Option<WorkflowProgressCallback>,
|
||||
prompt_id: &str,
|
||||
node: &WorkflowNode,
|
||||
index: usize,
|
||||
total: usize,
|
||||
message: &str,
|
||||
) {
|
||||
let max = total.saturating_mul(10).max(1);
|
||||
let value = index.saturating_mul(10).saturating_add(4).min(max);
|
||||
emit_progress(
|
||||
progress,
|
||||
WorkflowProgressEvent::NodeProgress {
|
||||
prompt_id: prompt_id.to_string(),
|
||||
node_id: node.id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
value,
|
||||
max,
|
||||
message: message.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async fn vae_decode_to_image(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
@@ -665,10 +782,7 @@ async fn vae_decode_to_image(
|
||||
.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 width = samples.get("width").and_then(Value::as_i64).unwrap_or(1024);
|
||||
let height = samples
|
||||
.get("height")
|
||||
.and_then(Value::as_i64)
|
||||
@@ -784,7 +898,14 @@ async fn get_json(ctx: &RunContext, path: &str) -> AppResult<Value> {
|
||||
}
|
||||
|
||||
fn extract_task_id(value: &Value) -> Value {
|
||||
for pointer in ["/id", "/task_id", "/data/id", "/data/task_id", "/result/id", "/result/task_id"] {
|
||||
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);
|
||||
@@ -804,9 +925,41 @@ fn video_output_from_response(value: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn video_task_is_terminal_failure(value: &Value) -> bool {
|
||||
let status = find_string_key(value, &["status", "state", "task_status", "taskStatus"])
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
matches!(
|
||||
status.as_str(),
|
||||
"failed" | "fail" | "error" | "cancelled" | "canceled"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?;
|
||||
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
||||
find_url_key(
|
||||
value,
|
||||
&["url", "remote_url", "remoteUrl", "image_url", "imageUrl"],
|
||||
)
|
||||
}
|
||||
|
||||
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for key in keys {
|
||||
if let Some(text) = map
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
{
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
}
|
||||
map.values().find_map(|child| find_string_key(child, keys))
|
||||
}
|
||||
Value::Array(items) => items.iter().find_map(|child| find_string_key(child, keys)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_url_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
@@ -832,7 +985,9 @@ 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)) =>
|
||||
&& extensions
|
||||
.iter()
|
||||
.any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
||||
{
|
||||
Some(text.clone())
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct WorkflowRunSummary {
|
||||
pub public_id: String,
|
||||
pub workflow_id: i64,
|
||||
pub workflow_public_id: Option<String>,
|
||||
pub workflow_name: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub status: String,
|
||||
pub input_overrides: Value,
|
||||
|
||||
@@ -186,7 +186,10 @@ pub fn node_types() -> Vec<NodeTypeDefinition> {
|
||||
"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 }
|
||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 },
|
||||
"wait_for_result": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||
"poll_interval_secs": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||
"timeout_secs": { "ui": "number", "type": "NUMBER", "default": 180, "min": 10, "max": 1800 }
|
||||
}),
|
||||
outputs: json!({
|
||||
"task_id": { "type": "STRING" },
|
||||
@@ -209,7 +212,10 @@ pub fn node_types() -> Vec<NodeTypeDefinition> {
|
||||
"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 }
|
||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 },
|
||||
"wait_for_result": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||
"poll_interval_secs": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||
"timeout_secs": { "ui": "number", "type": "NUMBER", "default": 180, "min": 10, "max": 1800 }
|
||||
}),
|
||||
outputs: json!({
|
||||
"task_id": { "type": "STRING" },
|
||||
|
||||
@@ -69,6 +69,7 @@ fn row_to_run(
|
||||
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(),
|
||||
workflow_name: row.try_get("workflow_name").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!({})),
|
||||
@@ -149,7 +150,7 @@ pub async fn get_run(
|
||||
allow_admin: bool,
|
||||
) -> AppResult<WorkflowRunSummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id, workflows.name AS workflow_name
|
||||
FROM workflow_runs
|
||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||
WHERE workflow_runs.public_id = ?
|
||||
@@ -183,7 +184,7 @@ pub async fn list_runs(
|
||||
limit: i64,
|
||||
) -> AppResult<Vec<WorkflowRunSummary>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id, workflows.name AS workflow_name
|
||||
FROM workflow_runs
|
||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||
WHERE (? = 1 OR workflow_runs.user_id = ?)
|
||||
|
||||
+1
-7
@@ -13,13 +13,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@db/*": ["./src/server/db/*"],
|
||||
"@services/*": ["./src/server/services/*"],
|
||||
"@routes/*": ["./src/server/routes/*"],
|
||||
"@middleware/*": ["./src/server/middleware/*"]
|
||||
}
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"outDir": "/tmp/tsc_out",
|
||||
"rootDir": "/root/metapi",
|
||||
"allowJs": false,
|
||||
"removeComments": false
|
||||
},
|
||||
"include": [
|
||||
"src/server/services/shared/apiKeyBatchCore.ts",
|
||||
"src/server/shared/modelBrand.ts",
|
||||
"src/server/shared/updateCenterReminder.ts"
|
||||
]
|
||||
}
|
||||
+1
Submodule vendor/ComfyUI_frontend added at d405002127
Reference in New Issue
Block a user