25 Commits

Author SHA1 Message Date
boos 023384b14b style: refine website section spacing 2026-06-06 16:18:11 +08:00
boos d315f0ffe2 feat: polish website model radar 2026-06-06 16:14:05 +08:00
boos f0385c9c8c chore: install vercel agent skills 2026-06-06 16:07:34 +08:00
boos 339f91d014 feat: sync upstream analytics schema support 2026-06-06 16:07:13 +08:00
boos 0d231ec467 feat: enhance website model radar motion 2026-06-06 15:46:51 +08:00
boos be1fc1df48 feat: optimize routes and registration UX 2026-06-06 15:37:37 +08:00
boos dd874bae30 feat: refresh BossAI console frontend 2026-06-06 15:25:04 +08:00
boos 45aa741d9c feat: refine BossAI console and website design 2026-06-06 15:09:53 +08:00
boos 49c398db5e feat: polish BossAI website motion 2026-06-06 14:56:33 +08:00
boos 4d748dd938 feat: refine BossAI website product story 2026-06-06 06:27:28 +08:00
boos b40c522749 feat: add gsap motion to BossAI website 2026-06-06 06:18:40 +08:00
boos d467e27fd8 fix: guard embedded frontend assets 2026-06-06 06:11:15 +08:00
boos 5ef017c54e feat: serve website at root path 2026-06-06 05:59:48 +08:00
boos 03046152fd feat: isolate BossAI website with Next.js 2026-06-06 05:30:10 +08:00
boos d75daf72d6 feat: add premium public landing page 2026-06-06 05:06:37 +08:00
boos d39100b56d feat: optimize money printer factory 2026-06-06 04:56:02 +08:00
boos 2e4c5801b6 chore: tighten repository hygiene 2026-06-06 04:40:25 +08:00
boos 870b2a2da3 feat: surface admin settings attention states 2026-06-06 03:57:15 +08:00
boos 4448567368 feat: optimize admin settings page 2026-06-06 03:49:30 +08:00
boos fae0f607aa test: restore project verification suite 2026-06-06 03:35:29 +08:00
boos e4630c4cb6 fix: harden workflow smoke issues 2026-06-06 03:23:39 +08:00
boos e72e323313 feat: refine money printer comfy template mode 2026-06-06 03:09:08 +08:00
boos 2b836b3e1e Add built-in Cloud video template fallback 2026-06-06 02:58:16 +08:00
boos 598b2f48d6 Handle Comfy Cloud video job status 2026-06-05 22:01:03 +08:00
boos 3f22d7b1ab Make short video factory use Comfy templates 2026-06-05 21:35:12 +08:00
67 changed files with 14422 additions and 439 deletions
+16
View File
@@ -2,3 +2,19 @@ comfy_source/venv
comfy_source/.git comfy_source/.git
comfy_source/__pycache__ comfy_source/__pycache__
**/__pycache__ **/__pycache__
target/
node_modules/
dist/
release/
data/
logs/
tmp/
.agents/
.cache/
.codex/
.claude/
.comfyui/
boosapi
bossai
boosapi.db
docker/*.tar
+7 -1
View File
@@ -1,13 +1,15 @@
/target /target
**/*.rs.bk **/*.rs.bk
Cargo.lock
.env .env
.env.* .env.*
data/ data/
node_modules/ node_modules/
website/.next/
website/out/
dist/ dist/
release/ release/
*.log *.log
*.tsbuildinfo
logs/ logs/
tmp/ tmp/
!tmp/.gitkeep !tmp/.gitkeep
@@ -19,5 +21,9 @@ docker/.env
.comfyui/ .comfyui/
.claude/ .claude/
.codex/ .codex/
.agents/
.cache/
openspec/ openspec/
boosapi boosapi
bossai
docker/*.tar
Generated
+4174
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -81,6 +81,8 @@ boosapi
config.toml config.toml
``` ```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置: 启动前检查配置:
```bash ```bash
+32 -3
View File
@@ -325,7 +325,7 @@ docker compose up -d
│ ├── billing/ # 计费逻辑 │ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送 │ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移 ├── migrations/ # SQLite 迁移
├── src/web/ # 前端源码(React 19 + Vite) ├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶) │ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage) │ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/ │ ├── pages/
@@ -334,6 +334,7 @@ docker compose up -d
│ │ ├── Workflows.tsx # Workflow Studio 入口 │ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ... │ │ └── ...
│ └── components/ │ └── components/
├── website/ # 独立官网(Next.js 16 App Router + React 19)
└── dist/web/ # 前端构建产物(由 Rust serve) └── dist/web/ # 前端构建产物(由 Rust serve)
``` ```
@@ -391,7 +392,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
| 协议转换 | OpenAI / Claude / Gemini 全兼容 | | 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 | | Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 | | 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) | | 管理台前端 | React + Vite(仅构建工具) |
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
| 样式 | Tailwind CSS v4 | | 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart | | 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s | | 容器化 | Docker + Docker Compose + K3s |
@@ -399,6 +401,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。 > **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
--- ---
## 🛠️ 本地开发 ## 🛠️ 本地开发
@@ -421,7 +425,7 @@ export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1 export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
export BOOSAPI_UPSTREAM_KEY=sk-boos4721 export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
``` ```
### 前端(React SPA) ### 前端(React SPA)
@@ -456,6 +460,24 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。 > 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。 > 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js)
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
```bash
# 首次安装
npm --prefix website install
# 本地开发官网
npm run website:dev
# 类型检查与生产构建
npm run website:typecheck
npm run website:build
```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建 ### 生产单二进制构建
```bash ```bash
@@ -469,6 +491,13 @@ boosapi
config.toml config.toml
``` ```
仓库卫生说明:
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
### Docker 部署 ### Docker 部署
```bash ```bash
+51
View File
@@ -282,6 +282,55 @@ build_frontend() {
ok "前端构建完成 → dist/web/" ok "前端构建完成 → dist/web/"
} }
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
return 0
fi
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
}
ensure_workflow_ui_for_embedding() { ensure_workflow_ui_for_embedding() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)" log "跳过 Workflow UI 构建检查(--skip-frontend)"
@@ -374,12 +423,14 @@ cleanup_started_server() {
if [ "$FRONTEND_ONLY" -eq 1 ]; then if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend build_frontend
build_public_website
log "跳过启动(--frontend-only)" log "跳过启动(--frontend-only)"
exit 0 exit 0
fi fi
if [ "$BACKEND_ONLY" -ne 1 ]; then if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend build_frontend
build_public_website
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding ensure_workflow_ui_for_embedding
fi fi
+7 -2
View File
@@ -32,20 +32,25 @@
"typecheck:web": "tsc -p tsconfig.web.json", "typecheck:web": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json", "typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test", "typecheck": "npm run typecheck:web && npm run typecheck:web:test",
"website:dev": "npm --prefix website run dev",
"website:build": "npm --prefix website run build",
"website:start": "npm --prefix website run start",
"website:typecheck": "npm --prefix website run typecheck",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all", "workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external", "workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud", "workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"test": "vitest run --root .", "test": "vitest run --root src/web",
"test:watch": "vitest --root ." "test:watch": "vitest --root src/web"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@visactor/react-vchart": "^2.0.21", "@visactor/react-vchart": "^2.0.21",
"gsap": "^3.15.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
+8
View File
@@ -20,6 +20,9 @@ importers:
'@visactor/react-vchart': '@visactor/react-vchart':
specifier: ^2.0.21 specifier: ^2.0.21
version: 2.0.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.0.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
gsap:
specifier: ^3.15.0
version: 3.15.0
marked: marked:
specifier: ^17.0.5 specifier: ^17.0.5
version: 17.0.5 version: 17.0.5
@@ -2210,6 +2213,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
hachure-fill@0.5.2: hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
@@ -5138,6 +5144,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
gsap@3.15.0: {}
hachure-fill@0.5.2: {} hachure-fill@0.5.2: {}
has-symbols@1.1.0: has-symbols@1.1.0:
+980 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@ use sqlx::{
pub mod models; pub mod models;
pub mod queries; pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres; pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+351
View File
@@ -0,0 +1,351 @@
use anyhow::Context;
use sqlx::{Row, SqlitePool};
use std::collections::HashSet;
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
ensure_columns(
pool,
"sites",
&[
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
("post_refresh_probe_model", "TEXT DEFAULT ''"),
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
(
"post_refresh_probe_latency_threshold_ms",
"INTEGER DEFAULT 0",
),
],
)
.await?;
ensure_columns(
pool,
"downstream_api_keys",
&[
("excluded_site_ids", "TEXT"),
("excluded_credential_refs", "TEXT"),
],
)
.await?;
ensure_columns(
pool,
"route_channels",
&[("oauth_route_unit_id", "INTEGER")],
)
.await?;
ensure_columns(
pool,
"proxy_logs",
&[
("client_family", "TEXT"),
("client_app_id", "TEXT"),
("client_app_name", "TEXT"),
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
],
)
.await?;
run_compat_ddl(pool).await?;
Ok(())
}
async fn ensure_columns(
pool: &SqlitePool,
table: &str,
columns: &[(&str, &str)],
) -> anyhow::Result<()> {
let existing = table_columns(pool, table).await?;
if existing.is_empty() {
return Ok(());
}
for (name, definition) in columns {
if existing.contains(*name) {
continue;
}
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("add column {table}.{name}"))?;
}
Ok(())
}
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
let sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&sql).fetch_all(pool).await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.collect())
}
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
for statement in SQLITE_COMPAT_DDL {
sqlx::query(statement)
.execute(pool)
.await
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
}
Ok(())
}
const SQLITE_COMPAT_DDL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at)",
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id)",
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at)",
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index)",
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at)",
"CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled)",
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until)",
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at)",
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_spend >= 0
AND total_latency_ms >= 0 AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model)",
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id)",
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model)",
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id)",
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id)",
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id)",
];
+194
View File
@@ -299,6 +299,200 @@ const TABLES: &[TableSpec] = &[
col!("read_at", text), col!("read_at", text),
], ],
}, },
TableSpec {
name: "site_api_endpoints",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("url", text),
col!("enabled", bool),
col!("sort_order", i64),
col!("cooldown_until", text),
col!("last_selected_at", text),
col!("last_failed_at", text),
col!("last_failure_reason", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_units",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("provider", text),
col!("name", text),
col!("strategy", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_unit_members",
columns: &[
col!("id", i64),
col!("unit_id", i64),
col!("account_id", i64),
col!("sort_order", i64),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_traces",
columns: &[
col!("id", i64),
col!("downstream_path", text),
col!("client_kind", text),
col!("session_id", text),
col!("trace_hint", text),
col!("requested_model", text),
col!("downstream_api_key_id", i64),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("sticky_session_key", text),
col!("sticky_hit_channel_id", i64),
col!("selected_channel_id", i64),
col!("selected_route_id", i64),
col!("selected_account_id", i64),
col!("selected_site_id", i64),
col!("selected_site_platform", text),
col!("endpoint_candidates_json", text),
col!("endpoint_runtime_state_json", text),
col!("decision_summary_json", text),
col!("final_status", text),
col!("final_http_status", i64),
col!("final_upstream_path", text),
col!("final_response_headers_json", text),
col!("final_response_body_json", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_attempts",
columns: &[
col!("id", i64),
col!("trace_id", i64),
col!("attempt_index", i64),
col!("endpoint", text),
col!("request_path", text),
col!("target_url", text),
col!("runtime_executor", text),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("response_status", i64),
col!("response_headers_json", text),
col!("response_body_json", text),
col!("raw_error_text", text),
col!("recover_applied", bool),
col!("downgrade_decision", bool),
col!("downgrade_reason", text),
col!("memory_write_json", text),
col!("created_at", text),
],
},
TableSpec {
name: "admin_snapshots",
columns: &[
col!("id", i64),
col!("namespace", text),
col!("snapshot_key", text),
col!("payload", text),
col!("generated_at", text),
col!("expires_at", text),
col!("stale_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "analytics_projection_checkpoints",
columns: &[
col!("projector_key", text),
col!("time_zone", text),
col!("last_proxy_log_id", i64),
col!("watermark_created_at", text),
col!("recompute_from_id", i64),
col!("recompute_requested_at", text),
col!("recompute_reason", text),
col!("recompute_started_at", text),
col!("recompute_completed_at", text),
col!("last_projected_at", text),
col!("last_successful_at", text),
col!("last_error", text),
col!("lease_owner", text),
col!("lease_token", text),
col!("lease_expires_at", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "model_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("model", text),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_hour_usage",
columns: &[
col!("id", i64),
col!("bucket_start_utc", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
]; ];
pub async fn migrate( pub async fn migrate(
+1
View File
@@ -116,6 +116,7 @@ async fn main() -> anyhow::Result<()> {
// Run migrations // Run migrations
db::run_migrations(db_kind, Some(&pool), None).await?; db::run_migrations(db_kind, Some(&pool), None).await?;
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
tracing::info!("Database migrations applied"); tracing::info!("Database migrations applied");
if args.migrate_only { if args.migrate_only {
+30 -2
View File
@@ -963,16 +963,44 @@ async fn comfy_json(
} }
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> { async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
let history = comfy_json(state, reqwest::Method::GET, &format!("/history/{prompt_id}"), None).await?; let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}")
} else {
format!("/history/{prompt_id}")
};
let history = comfy_json(state, reqwest::Method::GET, &path, None).await?;
let failed = comfy_failure_message(&history);
Ok(json!({ Ok(json!({
"id": prompt_id, "id": prompt_id,
"object": "video.generation.task", "object": "video.generation.task",
"backend": "comfy", "backend": "comfy",
"status": if comfy_output_objects(&history) { "succeeded" } else { "running" }, "status": if failed.is_some() {
"failed"
} else if comfy_output_objects(&history) {
"succeeded"
} else {
"running"
},
"error": failed,
"history": history, "history": history,
})) }))
} }
fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
.or_else(|| scalar_string(value.get("message")))
.unwrap_or_else(|| format!("Comfy job {status}")),
);
}
scalar_string(value.pointer("/execution_error/exception_message"))
}
fn comfy_output_objects(value: &Value) -> bool { fn comfy_output_objects(value: &Value) -> bool {
match value { match value {
Value::Object(object) => { Value::Object(object) => {
+1
View File
@@ -359,6 +359,7 @@ mod tests {
site_id INTEGER NOT NULL, site_id INTEGER NOT NULL,
access_token TEXT NOT NULL, access_token TEXT NOT NULL,
api_token TEXT, api_token TEXT,
unit_cost REAL,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
)", )",
) )
+2
View File
@@ -368,6 +368,8 @@ async fn status_handler(
"keyName": key_name, "keyName": key_name,
"userId": auth.user_id, "userId": auth.user_id,
"role": auth.role, "role": auth.role,
"apiBase": upstream_base,
"upstreamBase": upstream_base,
"upstreamLabel": upstream_label, "upstreamLabel": upstream_label,
"comfyui": state.comfyui.status_response().await, "comfyui": state.comfyui.status_response().await,
}))) })))
+24 -1
View File
@@ -720,7 +720,27 @@ async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Va
} }
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> { async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}")
} else {
format!("/history/{prompt_id}")
};
native_json(state, Method::GET, &path, None).await
}
fn prompt_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
.or_else(|| scalar_string(value.get("message")))
.unwrap_or_else(|| format!("Comfy job {status}")),
);
}
scalar_string(value.pointer("/execution_error/exception_message"))
} }
fn output_objects(value: &Value) -> Vec<Value> { fn output_objects(value: &Value) -> Vec<Value> {
@@ -1015,6 +1035,9 @@ async fn create_job(
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频") update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
.await?; .await?;
let history = prompt_history(&state_for_task, &prompt_id).await?; let history = prompt_history(&state_for_task, &prompt_id).await?;
if let Some(message) = prompt_failure_message(&history) {
return Err(AppError::UpstreamError(message));
}
let outputs = output_objects(&history); let outputs = output_objects(&history);
if !outputs.is_empty() { if !outputs.is_empty() {
update_progress( update_progress(
+96 -25
View File
@@ -13,6 +13,10 @@ use tower::service_fn;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web"); static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
const _: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
mod admin; mod admin;
mod auth; mod auth;
@@ -82,28 +86,28 @@ pub fn build_router(state: AppState) -> Router {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
{ {
Some(web_dir) => { Some(web_dir) => {
let web_dir_owned = web_dir.clone(); let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "dist/website".to_string());
let static_service = ServeDir::new(&web_dir) let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true) .append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| { .fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone(); let web_dir = web_dir.clone();
let website_dir = website_dir.clone();
async move { async move {
let path = req.uri().path(); let path = req.uri().path();
if is_api_path(path) { if is_api_path(path) {
return Ok::<_, Infallible>(api_not_found_response(path)); return Ok::<_, Infallible>(api_not_found_response(path));
} }
let index_path = format!("{}/index.html", web_dir); Ok::<_, Infallible>(filesystem_static_response(
match tokio::fs::read_to_string(&index_path).await { path,
Ok(html) => Ok::<_, Infallible>(html_response(html)), &web_dir,
Err(_) => Ok::<_, Infallible>( &website_dir,
Response::builder() )
.status(StatusCode::NOT_FOUND) .await)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
),
}
} }
})); }));
router.fallback_service(static_service) router.fallback_service(static_service)
@@ -122,6 +126,49 @@ fn api_not_found_response(path: &str) -> Response {
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()) .unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
} }
async fn filesystem_static_response(path: &str, web_dir: &str, website_dir: &str) -> Response {
let root_dir = if should_serve_website(path) {
website_dir
} else {
web_dir
};
let asset_path = embedded_asset_path(path);
let candidate = format!("{}/{}", root_dir.trim_end_matches('/'), asset_path);
match tokio::fs::read(&candidate).await {
Ok(bytes) => return embedded_file_response(&asset_path, &bytes),
Err(_) => {
if candidate.ends_with("/index.html") {
let without_index = candidate.trim_end_matches("index.html");
if let Ok(bytes) = tokio::fs::read(without_index).await {
return embedded_file_response(&asset_path, &bytes);
}
}
}
}
let index_path = format!("{}/index.html", root_dir.trim_end_matches('/'));
match tokio::fs::read(&index_path).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) if should_serve_website(path) => {
let fallback_index = format!("{}/index.html", web_dir.trim_end_matches('/'));
match tokio::fs::read(&fallback_index).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) => not_found_response(path),
}
}
Err(_) => not_found_response(path),
}
}
fn not_found_response(path: &str) -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
async fn embedded_static_handler(req: Request<Body>) -> Response { async fn embedded_static_handler(req: Request<Body>) -> Response {
let path = req.uri().path(); let path = req.uri().path();
if is_api_path(path) { if is_api_path(path) {
@@ -129,23 +176,55 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
} }
let asset_path = embedded_asset_path(path); let asset_path = embedded_asset_path(path);
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) { let static_dir = if should_serve_website(path) {
&EMBEDDED_WEBSITE
} else {
&EMBEDDED_WEB
};
if let Some(file) = static_dir.get_file(&asset_path) {
return embedded_file_response(&asset_path, file.contents()); return embedded_file_response(&asset_path, file.contents());
} }
if let Some(file) = EMBEDDED_WEB.get_file("index.html") { if let Some(file) = static_dir.get_file("index.html") {
return embedded_file_response("index.html", file.contents()); return embedded_file_response("index.html", file.contents());
} }
let disk_fallback = filesystem_static_response(path, "dist/web", "dist/website").await;
if disk_fallback.status() != StatusCode::NOT_FOUND {
return disk_fallback;
}
if should_serve_website(path) && EMBEDDED_WEBSITE.get_file("index.html").is_none() {
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
return embedded_file_response("index.html", file.contents());
}
}
Response::builder() Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("embedded dist/web/index.html not found")) .body(Body::from(
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()) .unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
} }
fn is_api_path(path: &str) -> bool { fn is_api_path(path: &str) -> bool {
path.starts_with("/api/") || path.starts_with("/v1/") path.starts_with("/api/")
|| path.starts_with("/v1/")
|| path == "/health"
|| path == "/ready"
|| path == "/metrics"
}
fn should_serve_website(path: &str) -> bool {
path == "/"
|| path.starts_with("/_next/")
|| path == "/favicon.ico"
|| path == "/favicon.png"
|| path == "/favicon-64.png"
|| path == "/logo.png"
} }
fn embedded_asset_path(path: &str) -> String { fn embedded_asset_path(path: &str) -> String {
@@ -157,14 +236,6 @@ fn embedded_asset_path(path: &str) -> String {
} }
} }
fn html_response(html: String) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response { fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
let content_type = mime_guess::from_path(path) let content_type = mime_guess::from_path(path)
.first_or_octet_stream() .first_or_octet_stream()
+297 -17
View File
@@ -14,7 +14,7 @@ use axum::{
}; };
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Map, Value};
use std::time::Duration; use std::time::Duration;
const TASK_TYPE_MONEY_PRINTER: &str = "money-printer-video"; const TASK_TYPE_MONEY_PRINTER: &str = "money-printer-video";
@@ -126,6 +126,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> { async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?; let auth = resolve_auth(&headers, &state).await?;
let cloud_backend = state.config.comfyui.uses_cloud_backend();
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"mode": "boosapi-native", "mode": "boosapi-native",
@@ -142,6 +143,15 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
"enabled": state.config.comfyui.uses_native_backend(), "enabled": state.config.comfyui.uses_native_backend(),
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(), "backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
"baseUrl": state.config.comfyui.target_base_url() "baseUrl": state.config.comfyui.target_base_url()
},
"builtinCloudTemplate": {
"enabled": cloud_backend,
"label": "内置 Cloud Seedance 文生视频",
"nodeType": "ByteDanceTextToVideoNode",
"model": "seedance-1-0-lite-t2v-250428",
"duration": { "min": 3, "max": 12, "default": 5 },
"resolutions": ["480p", "720p", "1080p"],
"ratios": ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"]
} }
}))) })))
} }
@@ -585,6 +595,24 @@ fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str
.map(|(id, _)| id.clone()) .map(|(id, _)| id.clone())
} }
fn first_existing_input_field(
prompt: &serde_json::Map<String, Value>,
node_id: &str,
fields: &[&str],
fallback: &str,
) -> String {
let inputs = prompt
.get(node_id)
.and_then(|node| node.get("inputs"))
.and_then(Value::as_object);
fields
.iter()
.find(|field| inputs.is_some_and(|inputs| inputs.contains_key(**field)))
.copied()
.unwrap_or(fallback)
.to_string()
}
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool { fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
match prompt { match prompt {
Value::Object(object) => { Value::Object(object) => {
@@ -616,10 +644,14 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
.or_else(|| first_node_by_class(object, "KSampler")) .or_else(|| first_node_by_class(object, "KSampler"))
.or_else(|| first_node_by_class(object, "EmptyLatentImage")) .or_else(|| first_node_by_class(object, "EmptyLatentImage"))
.unwrap_or_default(); .unwrap_or_default();
let prompt_field = first_existing_input_field(object, &prompt_node, &["prompt", "positive", "text"], "text");
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
json!({ json!({
"prompt": { "prompt": {
"nodeId": prompt_node, "nodeId": prompt_node,
"field": "text" "field": prompt_field
}, },
"negativePrompt": { "negativePrompt": {
"nodeId": negative_node, "nodeId": negative_node,
@@ -627,15 +659,15 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
}, },
"ratio": { "ratio": {
"nodeId": video_node, "nodeId": video_node,
"field": "ratio" "field": ratio_field
}, },
"resolution": { "resolution": {
"nodeId": video_node, "nodeId": video_node,
"field": "resolution" "field": resolution_field
}, },
"duration": { "duration": {
"nodeId": video_node, "nodeId": video_node,
"field": "duration" "field": duration_field
} }
}) })
} }
@@ -701,6 +733,25 @@ fn workflow_prompt_from_graph(graph: &Value) -> Value {
.unwrap_or_else(|| graph.clone()) .unwrap_or_else(|| graph.clone())
} }
fn default_cloud_seedance_prompt() -> Value {
json!({
"1": {
"class_type": "ByteDanceTextToVideoNode",
"inputs": {
"model": "seedance-1-0-lite-t2v-250428",
"prompt": "A clean vertical product demo video, minimal futuristic interface, smooth camera movement, bright studio lighting",
"resolution": "480p",
"aspect_ratio": "9:16",
"duration": 5,
"seed": 0,
"camera_fixed": false,
"watermark": false,
"generate_audio": false
}
}
})
}
async fn workflow_prompt_from_body( async fn workflow_prompt_from_body(
state: &AppState, state: &AppState,
auth: &MoneyPrinterAuth, auth: &MoneyPrinterAuth,
@@ -725,8 +776,11 @@ async fn workflow_prompt_from_body(
.await?; .await?;
return Ok(workflow_prompt_from_graph(&workflow.graph)); return Ok(workflow_prompt_from_graph(&workflow.graph));
} }
if state.config.comfyui.uses_cloud_backend() {
return Ok(default_cloud_seedance_prompt());
}
Err(AppError::BadRequest( Err(AppError::BadRequest(
"Comfy API 模式需要选择真实 Comfy/Cloud 视频工作流模板,不能使用 BoosAPI 自定义节点".into(), "Comfy API 模式需要选择真实 Comfy/Cloud 视频工作流模板;Cloud 模式可使用内置 Seedance 兜底模板".into(),
)) ))
} }
@@ -744,6 +798,47 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
Ok(()) Ok(())
} }
fn node_has_input(prompt: &Value, node_id: &str, field: &str) -> bool {
prompt
.get(node_id)
.and_then(|node| node.get("inputs"))
.and_then(Value::as_object)
.is_some_and(|inputs| inputs.contains_key(field))
}
fn apply_optional_node_input(
prompt: &mut Value,
mapping: &Value,
mapping_keys: &[&str],
default_field: &str,
value: Value,
) {
let Some(node_id) = mapping_node_id(mapping, mapping_keys) else {
return;
};
let field = mapping_field(mapping, mapping_keys, default_field);
if !node_has_input(prompt, &node_id, &field) {
return;
}
let _ = apply_node_input(prompt, &node_id, &field, value);
}
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
.map(|duration| duration.clamp(2, 120))
}
fn normalize_resolution_for_comfy(value: &Value) -> Value {
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
match text.as_str() {
"720p" | "720" => json!("720P"),
"1080p" | "1080" => json!("1080P"),
_ => value.clone(),
}
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> { fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> {
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| { let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
plan.get("video_prompt") plan.get("video_prompt")
@@ -773,18 +868,160 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
json!(negative), json!(negative),
)?; )?;
} }
for (keys, request_keys, default_field) in [ if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"), apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"), }
(&["duration", "durationNode"][..], &["duration"][..], "duration"), if let Some(value) = ["resolution", "size"].iter().find_map(|key| body.get(*key).cloned()) {
] { apply_optional_node_input(
if let Some(node_id) = mapping_node_id(mapping, keys) { prompt,
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) { mapping,
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?; &["resolution", "resolutionNode"],
} "resolution",
normalize_resolution_for_comfy(&value),
);
}
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
}
repair_money_printer_comfy_prompt(prompt);
Ok(video_prompt)
}
fn repair_money_printer_comfy_prompt(prompt: &mut Value) {
repair_bytedance_video_nodes(prompt);
repair_resize_image_mask_nodes(prompt);
repair_text_generate_ltx2_prompt_nodes(prompt);
repair_wan2_api_nodes(prompt);
}
fn repair_bytedance_video_nodes(prompt: &mut Value) {
let Some(nodes) = prompt.as_object_mut() else {
return;
};
for (_node_id, node) in nodes.iter_mut() {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
continue;
}
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
}
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
}
if inputs
.get("resolution")
.is_some_and(|value| {
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
})
{
inputs.insert("resolution".to_string(), json!("480p"));
} else if let Some(normalized) = inputs
.get("resolution")
.and_then(Value::as_str)
.map(|value| value.trim().to_ascii_lowercase())
{
inputs.insert("resolution".to_string(), json!(normalized));
}
if inputs
.get("aspect_ratio")
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
{
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
} }
} }
Ok(video_prompt) }
fn repair_resize_image_mask_nodes(prompt: &mut Value) {
let Some(nodes) = prompt.as_object_mut() else {
return;
};
for (_node_id, node) in nodes.iter_mut() {
if node.get("class_type").and_then(Value::as_str) != Some("ResizeImageMaskNode") {
continue;
}
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
if inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
{
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
}
}
}
fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
let Some(nodes) = prompt.as_object_mut() else {
return;
};
for (_node_id, node) in nodes.iter_mut() {
if node.get("class_type").and_then(Value::as_str) != Some("TextGenerateLTX2Prompt") {
continue;
}
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
if inputs
.get("sampling_mode.repetition_penalty")
.and_then(Value::as_f64)
.is_some_and(|value| !(0.0..=5.0).contains(&value))
{
inputs.insert("sampling_mode".to_string(), Value::String("on".to_string()));
inputs.insert("sampling_mode.temperature".to_string(), json!(0.7));
inputs.insert("sampling_mode.top_k".to_string(), json!(64));
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
inputs.entry("sampling_mode.seed".to_string()).or_insert_with(|| json!(0));
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
}
}
}
fn repair_wan2_api_nodes(prompt: &mut Value) {
let Some(nodes) = prompt.as_object_mut() else {
return;
};
for (_node_id, node) in nodes.iter_mut() {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
continue;
}
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
repair_wan2_model_group(inputs);
}
}
fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
let model_value = inputs.get("model").cloned().unwrap_or(Value::Null);
if let Some(items) = model_value.as_array() {
if let Some(model) = items.first().cloned() {
inputs.insert("model".to_string(), model);
}
if let Some(resolution) = items.get(1).cloned() {
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
}
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
inputs.insert("model.duration".to_string(), json!(duration));
}
}
if inputs
.get("model.resolution")
.is_some_and(|value| !matches!(value.as_str(), Some("720P" | "1080P")))
{
inputs.insert("model.resolution".to_string(), json!("1080P"));
}
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
inputs.insert("model.duration".to_string(), json!(duration));
}
} }
async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> { async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> {
@@ -805,7 +1042,30 @@ async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(Stri
} }
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> { async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await let path = if state.config.comfyui.uses_cloud_backend() {
format!("/jobs/{prompt_id}")
} else {
format!("/history/{prompt_id}")
};
native_json(state, Method::GET, &path, None).await
}
fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
.or_else(|| scalar_string(value.get("message")))
.unwrap_or_else(|| format!("Comfy job {status}")),
);
}
if let Some(message) = scalar_string(value.pointer("/execution_error/exception_message")) {
return Some(message);
}
None
} }
fn comfy_output_objects(value: &Value) -> Vec<Value> { fn comfy_output_objects(value: &Value) -> Vec<Value> {
@@ -1154,6 +1414,23 @@ async fn create_job(
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let submitted_result = json!({
"userId": auth_for_task.user_id,
"role": auth_for_task.role,
"keyName": auth_for_task.key_name,
"topic": topic,
"request": body,
"script": script,
"videoBackend": video_backend,
"model": "comfy-api",
"prompt": prompt,
"video": submitted,
});
sqlx::query("UPDATE background_tasks SET result_json = ?, updated_at = datetime('now') WHERE id = ? AND status IN ('pending', 'running')")
.bind(submitted_result.to_string())
.bind(&task_id_for_spawn)
.execute(&*state_for_task.db)
.await?;
let mut final_history = Value::Null; let mut final_history = Value::Null;
for _ in 0..COMFY_MAX_POLLS { for _ in 0..COMFY_MAX_POLLS {
if is_cancelled(&state_for_task, &task_id_for_spawn).await? { if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
@@ -1161,6 +1438,9 @@ async fn create_job(
} }
update_progress(&state_for_task, &task_id_for_spawn, 4, "Comfy 正在生成视频").await?; update_progress(&state_for_task, &task_id_for_spawn, 4, "Comfy 正在生成视频").await?;
let history = comfy_history(&state_for_task, &prompt_id).await?; let history = comfy_history(&state_for_task, &prompt_id).await?;
if let Some(message) = comfy_failure_message(&history) {
return Err(AppError::UpstreamError(message));
}
let outputs = comfy_output_objects(&history); let outputs = comfy_output_objects(&history);
if !outputs.is_empty() { if !outputs.is_empty() {
update_progress(&state_for_task, &task_id_for_spawn, 5, "Comfy 视频完成,正在归档").await?; update_progress(&state_for_task, &task_id_for_spawn, 5, "Comfy 视频完成,正在归档").await?;
+1
View File
@@ -1570,6 +1570,7 @@ mod tests {
site_id INTEGER NOT NULL, site_id INTEGER NOT NULL,
access_token TEXT NOT NULL, access_token TEXT NOT NULL,
api_token TEXT, api_token TEXT,
unit_cost REAL,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
)", )",
) )
+214 -4
View File
@@ -25,6 +25,15 @@ pub struct SiteDistributionQuery {
_refresh: Option<String>, _refresh: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct ProxyDebugTraceQuery {
limit: Option<i64>,
offset: Option<i64>,
session_id: Option<String>,
model: Option<String>,
status: Option<String>,
}
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/stats/dashboard", get(dashboard)) .route("/api/stats/dashboard", get(dashboard))
@@ -523,14 +532,215 @@ async fn proxy_log_detail(
Ok(Json(json!({ "item": item, "data": item }))) Ok(Json(json!({ "item": item, "data": item })))
} }
async fn proxy_debug_traces() -> Json<Value> { async fn proxy_debug_traces(
Json(json!({ "items": [], "data": [], "total": 0 })) State(state): State<AppState>,
Query(q): Query<ProxyDebugTraceQuery>,
) -> AppResult<Json<Value>> {
let limit = q.limit.unwrap_or(50).clamp(1, 200);
let offset = q.offset.unwrap_or(0).max(0);
let mut where_sql = Vec::new();
let mut bind_values = Vec::new();
if let Some(session_id) = q
.session_id
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
where_sql.push("session_id = ?");
bind_values.push(session_id.to_string());
}
if let Some(model) = q.model.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
where_sql.push("requested_model LIKE ?");
bind_values.push(format!("%{model}%"));
}
if let Some(status) = q.status.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
where_sql.push("final_status = ?");
bind_values.push(status.to_string());
}
let filter = if where_sql.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_sql.join(" AND "))
};
let list_sql = format!(
r#"
SELECT
id, downstream_path, client_kind, session_id, trace_hint,
requested_model, downstream_api_key_id, sticky_session_key,
sticky_hit_channel_id, selected_channel_id, selected_route_id,
selected_account_id, selected_site_id, selected_site_platform,
final_status, final_http_status, final_upstream_path,
created_at, updated_at
FROM proxy_debug_traces
{filter}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
"#
);
let mut list_query = sqlx::query_as::<_, ProxyDebugTraceRow>(&list_sql);
for value in &bind_values {
list_query = list_query.bind(value);
}
let items: Vec<Value> = list_query
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
.collect();
let count_sql = format!("SELECT COUNT(*) FROM proxy_debug_traces{filter}");
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for value in &bind_values {
count_query = count_query.bind(value);
}
let total = count_query.fetch_one(&*state.db).await?;
Ok(Json(json!({
"items": items,
"data": items,
"total": total,
"page": (offset / limit) + 1,
"pageSize": limit,
})))
} }
async fn proxy_debug_trace_detail( async fn proxy_debug_trace_detail(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Json<Value> { ) -> AppResult<Json<Value>> {
Json(json!({ "id": id, "events": [], "data": null })) let trace: Option<ProxyDebugTraceDetailRow> = sqlx::query_as(
r#"
SELECT
id, downstream_path, client_kind, session_id, trace_hint,
requested_model, downstream_api_key_id, request_headers_json,
request_body_json, sticky_session_key, sticky_hit_channel_id,
selected_channel_id, selected_route_id, selected_account_id,
selected_site_id, selected_site_platform, endpoint_candidates_json,
endpoint_runtime_state_json, decision_summary_json, final_status,
final_http_status, final_upstream_path, final_response_headers_json,
final_response_body_json, created_at, updated_at
FROM proxy_debug_traces
WHERE id = ?
"#,
)
.bind(id)
.fetch_optional(&*state.db)
.await?;
let Some(trace) = trace else {
return Err(crate::error::AppError::NotFound(
"proxy debug trace not found".into(),
));
};
let attempts: Vec<Value> = sqlx::query_as::<_, ProxyDebugAttemptRow>(
r#"
SELECT
id, trace_id, attempt_index, endpoint, request_path, target_url,
runtime_executor, request_headers_json, request_body_json,
response_status, response_headers_json, response_body_json,
raw_error_text, recover_applied, downgrade_decision,
downgrade_reason, memory_write_json, created_at
FROM proxy_debug_attempts
WHERE trace_id = ?
ORDER BY attempt_index ASC, id ASC
"#,
)
.bind(id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
.collect();
let data = serde_json::to_value(trace).unwrap_or_else(|_| json!({}));
Ok(Json(json!({
"id": id,
"item": data,
"data": data,
"attempts": attempts,
"events": attempts,
})))
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct ProxyDebugTraceRow {
id: i64,
downstream_path: String,
client_kind: Option<String>,
session_id: Option<String>,
trace_hint: Option<String>,
requested_model: Option<String>,
downstream_api_key_id: Option<i64>,
sticky_session_key: Option<String>,
sticky_hit_channel_id: Option<i64>,
selected_channel_id: Option<i64>,
selected_route_id: Option<i64>,
selected_account_id: Option<i64>,
selected_site_id: Option<i64>,
selected_site_platform: Option<String>,
final_status: Option<String>,
final_http_status: Option<i64>,
final_upstream_path: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct ProxyDebugTraceDetailRow {
id: i64,
downstream_path: String,
client_kind: Option<String>,
session_id: Option<String>,
trace_hint: Option<String>,
requested_model: Option<String>,
downstream_api_key_id: Option<i64>,
request_headers_json: Option<String>,
request_body_json: Option<String>,
sticky_session_key: Option<String>,
sticky_hit_channel_id: Option<i64>,
selected_channel_id: Option<i64>,
selected_route_id: Option<i64>,
selected_account_id: Option<i64>,
selected_site_id: Option<i64>,
selected_site_platform: Option<String>,
endpoint_candidates_json: Option<String>,
endpoint_runtime_state_json: Option<String>,
decision_summary_json: Option<String>,
final_status: Option<String>,
final_http_status: Option<i64>,
final_upstream_path: Option<String>,
final_response_headers_json: Option<String>,
final_response_body_json: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct ProxyDebugAttemptRow {
id: i64,
trace_id: i64,
attempt_index: i64,
endpoint: String,
request_path: String,
target_url: String,
runtime_executor: Option<String>,
request_headers_json: Option<String>,
request_body_json: Option<String>,
response_status: Option<i64>,
response_headers_json: Option<String>,
response_body_json: Option<String>,
raw_error_text: Option<String>,
recover_applied: Option<bool>,
downgrade_decision: Option<bool>,
downgrade_reason: Option<String>,
memory_write_json: Option<String>,
created_at: Option<String>,
} }
/// GET /api/stats/model-by-site?siteId=...&days=... /// GET /api/stats/model-by-site?siteId=...&days=...
+21 -46
View File
@@ -5183,55 +5183,30 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
} }
fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String { fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
let cloud_api_key = workflow_cloud_api_key(config);
if config.uses_native_backend() && !config.uses_cloud_backend() { if config.uses_native_backend() && !config.uses_cloud_backend() {
return inject_external_bootstrap(html, cloud_api_key); return inject_external_bootstrap(html);
} }
inject_enhanced_bootstrap(html, cloud_api_key) inject_enhanced_bootstrap(html)
} }
fn workflow_api_key_bootstrap(api_key: Option<&str>) -> String { fn workflow_secret_cleanup_bootstrap() -> &'static str {
let Some(api_key) = api_key else { r#"<script>
return String::new(); (function () {
}; const clearLeakedComfyApiKey = () => {
let api_key = serde_json::to_string(api_key).unwrap_or_else(|_| "null".to_string()); try {
format!( localStorage.removeItem('comfy_api_key');
r#"<script> sessionStorage.removeItem('comfy_api_key');
(function () {{ document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
const apiKey = {api_key}; } catch (err) {}
if (!apiKey) return; };
const installComfyApiKey = () => {{ clearLeakedComfyApiKey();
try {{ window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
localStorage.setItem('comfy_api_key', apiKey); })();
sessionStorage.setItem('comfy_api_key', apiKey);
for (const api of [window.app?.api, window.comfyAPI?.api, window.api]) {{
if (api) {{
api.apiKey = api.apiKey || apiKey;
if (!api.__boosapiCloudApiKeyQueuePatch && typeof api.queuePrompt === 'function') {{
const originalQueuePrompt = api.queuePrompt.bind(api);
api.queuePrompt = function() {{
if (!this.apiKey) this.apiKey = apiKey;
return originalQueuePrompt(...arguments);
}};
api.__boosapiCloudApiKeyQueuePatch = true;
}}
}}
}}
document.cookie = 'comfy_api_key=' + encodeURIComponent(apiKey) + '; Path=/workflow-ui; SameSite=Lax';
}} catch (err) {{}}
}};
installComfyApiKey();
window.addEventListener('DOMContentLoaded', installComfyApiKey);
window.setTimeout(installComfyApiKey, 300);
window.setTimeout(installComfyApiKey, 1200);
window.setInterval(installComfyApiKey, 5000);
}})();
</script>"# </script>"#
)
} }
fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> String { fn inject_external_bootstrap(html: String) -> String {
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key); let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
let shell = r#"<script> let shell = r#"<script>
(function () { (function () {
const renameComfyUiBrand = () => { const renameComfyUiBrand = () => {
@@ -5310,12 +5285,12 @@ fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
window.setTimeout(installWorkflowValidationButton, 1800); window.setTimeout(installWorkflowValidationButton, 1800);
})(); })();
</script>"#; </script>"#;
let bootstrap = format!("{api_key_bootstrap}{shell}"); let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
html.replace("</head>", &format!("{bootstrap}\n</head>")) html.replace("</head>", &format!("{bootstrap}\n</head>"))
} }
fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> String { fn inject_enhanced_bootstrap(html: String) -> String {
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key); let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
let shell = r#"<style id="boosapi-workflow-shell-style"> let shell = r#"<style id="boosapi-workflow-shell-style">
</style> </style>
<script> <script>
@@ -5713,7 +5688,7 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
window.setTimeout(patchWorkflowShell, 1200); window.setTimeout(patchWorkflowShell, 1200);
})(); })();
</script>"#; </script>"#;
let bootstrap = format!("{api_key_bootstrap}{shell}"); let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
html.replace("</head>", &format!("{bootstrap}\n</head>")) html.replace("</head>", &format!("{bootstrap}\n</head>"))
} }
+468 -1
View File
@@ -2,8 +2,9 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
services::shared::{enforce_key_request_limit, increment_key_usage}, services::shared::{enforce_key_request_limit, increment_key_usage},
}; };
use chrono::Timelike;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, Row, SqlitePool};
use std::sync::Arc; use std::sync::Arc;
#[derive(Clone)] #[derive(Clone)]
@@ -152,6 +153,215 @@ impl UsageTracker {
Ok(()) Ok(())
} }
pub async fn project_proxy_usage_for_db(db: &SqlitePool) -> AppResult<i64> {
Self::ensure_schema(db).await?;
let last_id: i64 = sqlx::query_scalar(
"SELECT COALESCE(last_proxy_log_id, 0)
FROM analytics_projection_checkpoints
WHERE projector_key = 'usage-aggregates-v1'",
)
.fetch_optional(db)
.await?
.unwrap_or(0);
let rows = sqlx::query(
r#"
SELECT
l.id,
l.created_at,
l.status,
l.latency_ms,
l.total_tokens,
l.estimated_cost,
COALESCE(NULLIF(l.model_actual, ''), NULLIF(l.model_requested, ''), 'unknown') AS model,
a.site_id,
s.platform
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE l.id > ?
ORDER BY l.id ASC
LIMIT 5000
"#,
)
.bind(last_id)
.fetch_all(db)
.await?;
if rows.is_empty() {
touch_projection_checkpoint(db, last_id, None).await?;
return Ok(0);
}
let mut max_id = last_id;
let mut tx = db.begin().await?;
for row in rows {
let id: i64 = row.try_get("id").unwrap_or(0);
max_id = max_id.max(id);
let site_id: Option<i64> = row.try_get("site_id").ok().flatten();
let Some(site_id) = site_id.filter(|value| *value > 0) else {
continue;
};
let created_at: Option<String> = row.try_get("created_at").ok().flatten();
let Some(created_at) = created_at else {
continue;
};
let local_day = sqlite_day_key(&created_at);
let bucket_start_utc = sqlite_hour_key(&created_at);
let status: Option<String> = row.try_get("status").ok().flatten();
let is_success = status
.as_deref()
.unwrap_or_default()
.eq_ignore_ascii_case("success");
let latency_ms = non_negative_i64(row.try_get("latency_ms").ok().flatten());
let latency_count = i64::from(latency_ms > 0);
let total_tokens = non_negative_i64(row.try_get("total_tokens").ok().flatten());
let estimated_cost = non_negative_f64(row.try_get("estimated_cost").ok().flatten());
let platform: Option<String> = row.try_get("platform").ok().flatten();
let model: String = row
.try_get::<Option<String>, _>("model")
.ok()
.flatten()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "unknown".to_string());
let summary_spend = if estimated_cost > 0.0 {
estimated_cost
} else if platform
.as_deref()
.unwrap_or_default()
.trim()
.eq_ignore_ascii_case("veloera")
{
total_tokens as f64 / 1_000_000.0
} else {
total_tokens as f64 / 500_000.0
};
let site_spend = if estimated_cost > 0.0 {
estimated_cost
} else {
fallback_token_cost(total_tokens, platform.as_deref())
};
let model_spend = if estimated_cost > 0.0 {
estimated_cost
} else {
total_tokens as f64 / 500_000.0
};
sqlx::query(
r#"
INSERT INTO site_day_usage
(local_day, site_id, total_calls, success_calls, failed_calls,
total_tokens, total_summary_spend, total_site_spend,
total_latency_ms, latency_count, updated_at)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(local_day, site_id) DO UPDATE SET
total_calls = total_calls + 1,
success_calls = success_calls + excluded.success_calls,
failed_calls = failed_calls + excluded.failed_calls,
total_tokens = total_tokens + excluded.total_tokens,
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
total_site_spend = total_site_spend + excluded.total_site_spend,
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
latency_count = latency_count + excluded.latency_count,
updated_at = datetime('now')
"#,
)
.bind(&local_day)
.bind(site_id)
.bind(i64::from(is_success))
.bind(i64::from(!is_success))
.bind(total_tokens)
.bind(summary_spend)
.bind(site_spend)
.bind(latency_ms)
.bind(latency_count)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO site_hour_usage
(bucket_start_utc, site_id, total_calls, success_calls, failed_calls,
total_tokens, total_summary_spend, total_site_spend,
total_latency_ms, latency_count, updated_at)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(bucket_start_utc, site_id) DO UPDATE SET
total_calls = total_calls + 1,
success_calls = success_calls + excluded.success_calls,
failed_calls = failed_calls + excluded.failed_calls,
total_tokens = total_tokens + excluded.total_tokens,
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
total_site_spend = total_site_spend + excluded.total_site_spend,
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
latency_count = latency_count + excluded.latency_count,
updated_at = datetime('now')
"#,
)
.bind(&bucket_start_utc)
.bind(site_id)
.bind(i64::from(is_success))
.bind(i64::from(!is_success))
.bind(total_tokens)
.bind(summary_spend)
.bind(site_spend)
.bind(latency_ms)
.bind(latency_count)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO model_day_usage
(local_day, site_id, model, total_calls, success_calls, failed_calls,
total_tokens, total_spend, total_latency_ms, latency_count, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(local_day, site_id, model) DO UPDATE SET
total_calls = total_calls + 1,
success_calls = success_calls + excluded.success_calls,
failed_calls = failed_calls + excluded.failed_calls,
total_tokens = total_tokens + excluded.total_tokens,
total_spend = total_spend + excluded.total_spend,
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
latency_count = latency_count + excluded.latency_count,
updated_at = datetime('now')
"#,
)
.bind(&local_day)
.bind(site_id)
.bind(&model)
.bind(i64::from(is_success))
.bind(i64::from(!is_success))
.bind(total_tokens)
.bind(model_spend)
.bind(latency_ms)
.bind(latency_count)
.execute(&mut *tx)
.await?;
}
sqlx::query(
r#"
INSERT INTO analytics_projection_checkpoints
(projector_key, time_zone, last_proxy_log_id, watermark_created_at,
last_projected_at, last_successful_at, last_error, updated_at)
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), datetime('now'), NULL, datetime('now'))
ON CONFLICT(projector_key) DO UPDATE SET
last_proxy_log_id = excluded.last_proxy_log_id,
watermark_created_at = excluded.watermark_created_at,
last_projected_at = excluded.last_projected_at,
last_successful_at = excluded.last_successful_at,
last_error = NULL,
updated_at = datetime('now')
"#,
)
.bind(max_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(max_id - last_id)
}
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> { async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS usage_logs ( "CREATE TABLE IF NOT EXISTS usage_logs (
@@ -190,10 +400,179 @@ impl UsageTracker {
) )
.execute(db) .execute(db)
.await?; .await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, site_id, model)
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, site_id)
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(bucket_start_utc, site_id)
)",
)
.execute(db)
.await?;
Ok(()) Ok(())
} }
} }
async fn touch_projection_checkpoint(
db: &SqlitePool,
last_proxy_log_id: i64,
error: Option<&str>,
) -> AppResult<()> {
sqlx::query(
r#"
INSERT INTO analytics_projection_checkpoints
(projector_key, time_zone, last_proxy_log_id, last_projected_at,
last_successful_at, last_error, updated_at)
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), ?, datetime('now'))
ON CONFLICT(projector_key) DO UPDATE SET
last_projected_at = datetime('now'),
last_successful_at = CASE WHEN ? IS NULL THEN datetime('now') ELSE last_successful_at END,
last_error = ?,
updated_at = datetime('now')
"#,
)
.bind(last_proxy_log_id)
.bind(error)
.bind(error)
.bind(error)
.execute(db)
.await?;
Ok(())
}
fn non_negative_i64(value: Option<i64>) -> i64 {
value.unwrap_or(0).max(0)
}
fn non_negative_f64(value: Option<f64>) -> f64 {
value.unwrap_or(0.0).max(0.0)
}
fn sqlite_day_key(created_at: &str) -> String {
parse_sqlite_datetime(created_at)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| created_at.chars().take(10).collect())
}
fn sqlite_hour_key(created_at: &str) -> String {
parse_sqlite_datetime(created_at)
.map(|dt| {
dt.with_minute(0)
.and_then(|dt| dt.with_second(0))
.and_then(|dt| dt.with_nanosecond(0))
.unwrap_or(dt)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| {
let prefix: String = created_at.chars().take(13).collect();
if prefix.len() == 13 {
format!("{prefix}:00:00")
} else {
created_at.to_string()
}
})
}
fn parse_sqlite_datetime(value: &str) -> Option<chrono::NaiveDateTime> {
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
.ok()
.or_else(|| {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.naive_utc())
})
}
fn fallback_token_cost(tokens: i64, platform: Option<&str>) -> f64 {
if tokens <= 0 {
return 0.0;
}
match platform
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"veloera" => tokens as f64 / 1_000_000.0,
_ => tokens as f64 / 500_000.0,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -282,4 +661,92 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[tokio::test]
async fn projects_proxy_logs_to_site_and_model_usage() {
let pool = pool().await;
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
platform TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE proxy_logs (
id INTEGER PRIMARY KEY,
account_id INTEGER,
model_requested TEXT,
model_actual TEXT,
status TEXT,
latency_ms INTEGER,
total_tokens INTEGER,
estimated_cost REAL,
created_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO sites (id, name, platform) VALUES (10, 'Site', 'new-api')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id) VALUES (20, 10)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO proxy_logs
(id, account_id, model_requested, model_actual, status, latency_ms, total_tokens, estimated_cost, created_at)
VALUES
(1, 20, 'gpt-test', NULL, 'success', 120, 1000, 0.12, '2026-06-06 08:12:30')",
)
.execute(&pool)
.await
.unwrap();
let projected = UsageTracker::project_proxy_usage_for_db(&pool).await.unwrap();
assert_eq!(projected, 1);
let model_usage: (i64, i64, f64, i64) = sqlx::query_as(
"SELECT total_calls, total_tokens, total_spend, total_latency_ms
FROM model_day_usage
WHERE site_id = 10 AND model = 'gpt-test'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(model_usage, (1, 1000, 0.12, 120));
let site_usage: (i64, i64, f64) = sqlx::query_as(
"SELECT total_calls, success_calls, total_site_spend
FROM site_day_usage
WHERE site_id = 10 AND local_day = '2026-06-06'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(site_usage, (1, 1, 0.12));
let hour_bucket: String = sqlx::query_scalar(
"SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(hour_bucket, "2026-06-06 08:00:00");
}
} }
+4
View File
@@ -79,6 +79,10 @@ impl CronScheduler {
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> { async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?; UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
let projected = UsageTracker::project_proxy_usage_for_db(&db).await?;
if projected > 0 {
tracing::debug!(projected, "usage aggregates projected");
}
sqlx::query( sqlx::query(
"UPDATE downstream_api_keys "UPDATE downstream_api_keys
SET updated_at = datetime('now') SET updated_at = datetime('now')
+1 -1
View File
@@ -37,7 +37,7 @@ describe('Login surface', () => {
)); ));
expect(pageText).toContain('BoosAPI'); expect(pageText).toContain('BoosAPI');
expect(pageText).toContain('中转站的中转站'); expect(pageText).toContain('AI 应用平台');
expect(pageText).not.toContain('一个 API Key,一个入口'); expect(pageText).not.toContain('一个 API Key,一个入口');
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API'); expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
expect(pageText).toContain('统一代理网关'); expect(pageText).toContain('统一代理网关');
+21 -20
View File
@@ -4,6 +4,7 @@ import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js'; import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js'; import TooltipLayer from './components/TooltipLayer.js';
import MotionDiv from './components/AppMotion.js';
import { api, userApi } from './api.js'; import { api, userApi } from './api.js';
import { clearSession, getSessionToken } from './authSession.js'; import { clearSession, getSessionToken } from './authSession.js';
import { import {
@@ -378,7 +379,7 @@ export const sidebarGroups = [
{ {
label: '控制台', label: '控制台',
items: [ items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> }, { to: '/dashboard', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> }, { to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> }, { to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
@@ -430,7 +431,7 @@ export const userSidebarGroups = [
}, },
]; ];
const topNavItems = [ const topNavItems = [
{ label: '控制台', to: '/' }, { label: '控制台', to: '/dashboard' },
{ label: '模型广场', to: '/models' }, { label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' }, { label: '生图助手', to: '/picture-agent' },
{ label: '短视频工厂', to: '/money-printer' }, { label: '短视频工厂', to: '/money-printer' },
@@ -441,7 +442,11 @@ const topNavItems = [
]; ];
function PageTransition({ children }: { children: React.ReactNode }) { function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation(); const location = useLocation();
return <div key={location.pathname} className="page-enter">{children}</div>; return (
<MotionDiv key={location.pathname} motionKey={location.pathname} className="page-enter">
{children}
</MotionDiv>
);
} }
function RouteLoadingFallback() { function RouteLoadingFallback() {
return ( return (
@@ -697,16 +702,14 @@ function AppShell() {
}; };
const isAdminLoginPage = location.pathname === '/boos'; const isAdminLoginPage = location.pathname === '/boos';
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname); const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
// The default `/` route now serves the user-facing login; admins land on `/boos`.
const isDefaultLoginRoot = !authed && !userAuthed && location.pathname === '/';
if (isUserPage && isPublicUserPage) { if (isUserPage && isPublicUserPage) {
if (userAuthed) return <Navigate to="/user/dashboard" />; if (userAuthed) return <Navigate to="/user/dashboard" />;
if (authed) return <Navigate to="/" />; if (authed) return <Navigate to="/dashboard" />;
} }
if (isAdminLoginPage) { if (isAdminLoginPage) {
if (authed) return <Navigate to="/" />; if (authed) return <Navigate to="/dashboard" />;
return ( return (
<AdminLoginPage <AdminLoginPage
t={t} t={t}
@@ -715,10 +718,6 @@ function AppShell() {
); );
} }
if (isDefaultLoginRoot) {
return <Navigate to="/user/login" />;
}
if (!authed && !isUserPage) { if (!authed && !isUserPage) {
if (userAuthed) { if (userAuthed) {
return <Navigate to="/user/dashboard" />; return <Navigate to="/user/dashboard" />;
@@ -773,8 +772,10 @@ function AppShell() {
</button> </button>
)} )}
<div className="topbar-logo"> <div className="topbar-logo">
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} /> <span className="topbar-logo-mark">
<span className="topbar-logo-text">BoosAPI</span> <img src="/logo.png" alt="BossAI" />
</span>
<span className="topbar-logo-text">BossAI</span>
</div> </div>
<nav className="topbar-nav"> <nav className="topbar-nav">
{currentTopNavItems.map((item) => ( {currentTopNavItems.map((item) => (
@@ -906,8 +907,8 @@ function AppShell() {
closeLabel={t('关闭导航')} closeLabel={t('关闭导航')}
> >
<div className="mobile-drawer-header"> <div className="mobile-drawer-header">
<img src="/logo.png" alt="BoosAPI" /> <img src="/logo.png" alt="BossAI" />
<span>BoosAPI</span> <span>BossAI</span>
</div> </div>
<nav className="mobile-nav"> <nav className="mobile-nav">
{currentSidebarGroups.map((group) => ( {currentSidebarGroups.map((group) => (
@@ -917,7 +918,7 @@ function AppShell() {
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'} end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
onClick={() => setDrawerOpen(false)} onClick={() => setDrawerOpen(false)}
> >
@@ -929,7 +930,7 @@ function AppShell() {
))} ))}
<div className="mobile-nav-group"> <div className="mobile-nav-group">
<div className="mobile-nav-label">{t('更多')}</div> <div className="mobile-nav-label">{t('更多')}</div>
{currentTopNavItems.filter((n) => n.to !== '/' && n.to !== '/user/dashboard').map((item) => ( {currentTopNavItems.filter((n) => n.to !== '/dashboard' && n.to !== '/user/dashboard').map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -951,7 +952,7 @@ function AppShell() {
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'} end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined} data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
aria-label={sidebarCollapsed ? t(item.label) : undefined} aria-label={sidebarCollapsed ? t(item.label) : undefined}
@@ -974,7 +975,7 @@ function AppShell() {
<PageTransition> <PageTransition>
<Suspense fallback={<RouteLoadingFallback />}> <Suspense fallback={<RouteLoadingFallback />}>
<Routes> <Routes>
<Route path="/" element={<Dashboard adminName={displayName} />} /> <Route path="/dashboard" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} /> <Route path="/sites" element={<Sites />} />
<Route path="/site-announcements" element={<SiteAnnouncements />} /> <Route path="/site-announcements" element={<SiteAnnouncements />} />
<Route path="/accounts" element={<Accounts />} /> <Route path="/accounts" element={<Accounts />} />
@@ -1019,7 +1020,7 @@ function AppShell() {
<Route path="/user/workspace" element={<Workspace />} /> <Route path="/user/workspace" element={<Workspace />} />
<Route path="/user/about" element={<About />} /> <Route path="/user/about" element={<About />} />
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} /> <Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/dashboard"} />} />
</Routes> </Routes>
</Suspense> </Suspense>
</PageTransition> </PageTransition>
+29
View File
@@ -1196,6 +1196,31 @@ export type MoneyPrinterJobsResponse = {
jobs: MoneyPrinterJob[]; jobs: MoneyPrinterJob[];
}; };
export type MoneyPrinterStatusResponse = {
success: boolean;
mode: string;
keyName?: string;
capabilities?: string[];
comfy?: {
enabled?: boolean;
backend?: string;
baseUrl?: string;
};
builtinCloudTemplate?: {
enabled?: boolean;
label?: string;
nodeType?: string;
model?: string;
duration?: {
min?: number;
max?: number;
default?: number;
};
resolutions?: string[];
ratios?: string[];
};
};
export const api = { export const api = {
// Sites // Sites
getSites: async () => { getSites: async () => {
@@ -2093,6 +2118,8 @@ export const api = {
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }), request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () => clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }), request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
request<MoneyPrinterStatusResponse>("/api/money-printer/status"),
createMoneyPrinterJob: (data: any) => createMoneyPrinterJob: (data: any) =>
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", { request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST", method: "POST",
@@ -2196,6 +2223,8 @@ export const userApi = {
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }), userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () => clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }), userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
userRequest<MoneyPrinterStatusResponse>("/api/money-printer/status"),
createMoneyPrinterJob: (data: any) => createMoneyPrinterJob: (data: any) =>
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", { userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST", method: "POST",
+2
View File
@@ -6,6 +6,7 @@ export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile'; export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY; export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY;
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1'; export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
export const LEGACY_FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
type StorageLike = { type StorageLike = {
getItem?: (key: string) => string | null; getItem?: (key: string) => string | null;
@@ -28,4 +29,5 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
target.removeItem(USER_PROFILE_STORAGE_KEY); target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(ADMIN_PROFILE_STORAGE_KEY); target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
target.removeItem(FIRST_USE_DOC_REMINDER_KEY); target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
} }
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
type MotionVariant = 'page' | 'login';
interface MotionDivProps extends React.HTMLAttributes<HTMLDivElement> {
motionKey?: string;
motionVariant?: MotionVariant;
}
const PAGE_TARGET_SELECTOR = [
':scope > .console-hero',
':scope > .page-header',
':scope > .card',
':scope > .mobile-card',
':scope > .workflow-shell',
':scope > .picture-agent-shell',
':scope > .workspace-layout',
':scope > .dashboard-grid',
':scope > .digital-human-layout',
':scope > .money-printer-layout',
':scope > .about-page',
].join(', ');
const LOGIN_TARGET_SELECTOR = [
'.login-brand-header',
'.login-brand-copy-block',
'.login-compat-line',
'.login-capability-row',
'.login-auth-eyebrow',
'.login-auth-title',
'.login-auth-copy',
'.login-mode-tabs',
'.login-auth-panel form',
'.login-auth-footer',
].join(', ');
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] {
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18);
}
export default function MotionDiv({
children,
className,
motionKey,
motionVariant = 'page',
...props
}: MotionDivProps) {
const rootRef = useRef<HTMLDivElement>(null);
const resolvedMotionKey = motionKey || motionVariant;
useEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const mm = gsap.matchMedia();
mm.add(
{
reduceMotion: '(prefers-reduced-motion: reduce)',
isDesktop: '(min-width: 900px)',
},
(context) => {
const conditions = context.conditions || {};
if (conditions.reduceMotion) {
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
return undefined;
}
const targets = selectMotionTargets(root, motionVariant);
const distance = motionVariant === 'login' ? 16 : 10;
const rootTween = gsap.fromTo(
root,
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
{
autoAlpha: 1,
y: 0,
duration: motionVariant === 'login' ? 0.46 : 0.34,
ease: 'power2.out',
clearProps: 'transform,opacity,visibility',
},
);
const targetTween = targets.length
? gsap.fromTo(
targets,
{ autoAlpha: 0, y: distance },
{
autoAlpha: 1,
y: 0,
duration: motionVariant === 'login' ? 0.58 : 0.42,
ease: 'power2.out',
stagger: motionVariant === 'login' ? 0.045 : 0.025,
delay: 0.04,
clearProps: 'transform,opacity,visibility',
},
)
: null;
return () => {
rootTween.kill();
targetTween?.kill();
};
},
);
return () => mm.revert();
}, [resolvedMotionKey, motionVariant]);
return (
<div
ref={rootRef}
className={['motion-root', className].filter(Boolean).join(' ')}
{...props}
>
{children}
</div>
);
}
+7 -6
View File
@@ -8,11 +8,12 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const targetWindow = window;
const query = breakpoint === MOBILE_BREAKPOINT const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY ? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`; : `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null; const media = typeof targetWindow.matchMedia === 'function' ? targetWindow.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint); const update = () => setIsMobile(media ? media.matches : targetWindow.innerWidth <= breakpoint);
update(); update();
if (media && typeof media.addEventListener === 'function') { if (media && typeof media.addEventListener === 'function') {
@@ -24,14 +25,14 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
}; };
} }
const addResizeListener = typeof window.addEventListener === 'function'; const addResizeListener = typeof targetWindow.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function'; const removeResizeListener = typeof targetWindow.removeEventListener === 'function';
if (!addResizeListener) return; if (!addResizeListener) return;
window.addEventListener('resize', update); targetWindow.addEventListener('resize', update);
return () => { return () => {
if (removeResizeListener) { if (removeResizeListener) {
window.removeEventListener('resize', update); targetWindow.removeEventListener('resize', update);
} }
}; };
}, [breakpoint]); }, [breakpoint]);
+2
View File
@@ -19,6 +19,8 @@ const zhToEn: Record<string, string> = {
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.', '仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
'验证中...': 'Verifying...', '验证中...': 'Verifying...',
'登录': 'Sign In', '登录': 'Sign In',
'AI 应用平台': 'AI Application Platform',
'聚合多模型、多模态、工作流与智能路由,把对话、生图、视频和数字人生产放进一个统一平台。': 'Unify multi-model routing, multimodal creation, workflows, chat, image generation, video, and digital human production in one platform.',
'中转站的中转站': 'The Hub of Hubs', '中转站的中转站': 'The Hub of Hubs',
'把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。': 'Turn fragmented New API / One API / OneHub sites into one unified gateway with auto model discovery, smart routing, and better cost control.', '把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。': 'Turn fragmented New API / One API / OneHub sites into one unified gateway with auto model discovery, smart routing, and better cost control.',
'兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API', '兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API',
+1683 -8
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="BoosAPI - 中转站的中转站,聚合多个 AI API 中转站为统一网关" /> <meta name="description" content="BossAI 聚合多模型多模态的 AI 应用平台" />
<title>BoosAPI</title> <title>BoosAPI</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
+22 -13
View File
@@ -577,18 +577,27 @@ export default function Dashboard({
return ( return (
<div className="dashboard-page animate-fade-in"> <div className="dashboard-page animate-fade-in">
<div <section className="console-hero dashboard-hero">
style={{ <div className="console-hero-copy">
display: "flex", <span className="console-kicker">BossAI Console</span>
justifyContent: "space-between", <h1>{getGreeting()},{normalizedAdminName}</h1>
alignItems: "center", <p>统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。</p>
marginBottom: 24, </div>
}} <div className="console-hero-rail" aria-label="实时运营摘要">
> <div>
<h2 className="greeting"> <span>24h 请求</span>
{getGreeting() + "\uFF0C" + normalizedAdminName} <strong>{Math.round(proxy24hTotal).toLocaleString()}</strong>
</h2> </div>
<div style={{ display: "flex", gap: 8 }}> <div>
<span>RPM</span>
<strong>{Math.round(requestsPerMinute).toLocaleString()}</strong>
</div>
<div>
<span>站点</span>
<strong>{activeSites.length}/{rawSiteAvailability.length}</strong>
</div>
</div>
<div className="console-hero-actions">
<button <button
onClick={() => { onClick={() => {
void load(true); void load(true);
@@ -619,7 +628,7 @@ export default function Dashboard({
</svg> </svg>
</button> </button>
</div> </div>
</div> </section>
<div className="dashboard-stat-grid"> <div className="dashboard-stat-grid">
<div className="stat-card animate-slide-up stagger-1"> <div className="stat-card animate-slide-up stagger-1">
+21 -6
View File
@@ -384,18 +384,33 @@ export default function DigitalHuman() {
return ( return (
<div className="digital-human-page animate-fade-in"> <div className="digital-human-page animate-fade-in">
<div className="page-header"> <section className="console-hero digital-human-hero">
<div> <div className="console-hero-copy">
<h1 className="page-title">数字人</h1> <span className="console-kicker">Digital Human</span>
<p className="page-subtitle">选择 Comfy 工作流模板,上传人物素材和参考音频,生成真人商品讲解视频。</p> <h1>数字人商品讲解</h1>
<p>选择 Comfy 工作流模板,上传人物图片或视频、商品文案和参考音频,后台生成真人口播讲解视频并归档到工作空间。</p>
</div> </div>
<div className="page-actions"> <div className="console-hero-rail" aria-label="数字人任务摘要">
<div>
<span>模板</span>
<strong>{sortedWorkflows.length}</strong>
</div>
<div>
<span>任务</span>
<strong>{jobs.length}</strong>
</div>
<div>
<span>音频用途</span>
<strong>{AUDIO_ROLES.find((item) => item.value === audioRole)?.label || 'both'}</strong>
</div>
</div>
<div className="console-hero-actions">
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button> <button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}> <button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
{submitting ? '提交中...' : '生成讲解视频'} {submitting ? '提交中...' : '生成讲解视频'}
</button> </button>
</div> </div>
</div> </section>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>} {error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
+5
View File
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
getAccounts: vi.fn(), getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(), getAccountsSnapshot: vi.fn(),
getAccountTokens: vi.fn(), getAccountTokens: vi.fn(),
getSites: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(), getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(), getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(), createDownstreamApiKey: vi.fn(),
@@ -166,6 +167,10 @@ beforeEach(() => {
}, },
}, },
]); ]);
apiMock.getSites.mockResolvedValue([
{ id: 201, name: '站点A', status: 'active' },
{ id: 202, name: '站点B', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([ apiMock.getAccountTokens.mockResolvedValue([
{ {
id: 301, id: 301,
+6 -6
View File
@@ -214,7 +214,7 @@ describe('ImportExport', () => {
success: true, success: true,
config: { config: {
enabled: true, enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json', fileUrl: 'https://dav.example.com/backups/boosapi.json',
username: 'alice', username: 'alice',
exportType: 'all', exportType: 'all',
autoSyncEnabled: true, autoSyncEnabled: true,
@@ -231,7 +231,7 @@ describe('ImportExport', () => {
success: true, success: true,
config: { config: {
enabled: true, enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json', fileUrl: 'https://dav.example.com/backups/boosapi.json',
username: 'alice', username: 'alice',
exportType: 'all', exportType: 'all',
autoSyncEnabled: true, autoSyncEnabled: true,
@@ -246,7 +246,7 @@ describe('ImportExport', () => {
}); });
apiMock.exportBackupToWebdav.mockResolvedValue({ apiMock.exportBackupToWebdav.mockResolvedValue({
success: true, success: true,
fileUrl: 'https://dav.example.com/backups/metapi.json', fileUrl: 'https://dav.example.com/backups/boosapi.json',
exportType: 'all', exportType: 'all',
}); });
apiMock.importBackupFromWebdav.mockResolvedValue({ apiMock.importBackupFromWebdav.mockResolvedValue({
@@ -459,7 +459,7 @@ describe('ImportExport', () => {
const fileUrlInput = root!.root.findAll((node) => ( const fileUrlInput = root!.root.findAll((node) => (
node.type === 'input' node.type === 'input'
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json' && node.props.placeholder === 'https://dav.example.com/backups/boosapi.json'
)).at(-1); )).at(-1);
const cronInput = root!.root.findAll((node) => ( const cronInput = root!.root.findAll((node) => (
node.type === 'input' node.type === 'input'
@@ -507,7 +507,7 @@ describe('ImportExport', () => {
const fileUrlInput = root!.root.findAll((node) => ( const fileUrlInput = root!.root.findAll((node) => (
node.type === 'input' node.type === 'input'
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json' && node.props.placeholder === 'https://dav.example.com/backups/boosapi.json'
)).at(-1); )).at(-1);
expect(fileUrlInput).toBeTruthy(); expect(fileUrlInput).toBeTruthy();
@@ -538,7 +538,7 @@ describe('ImportExport', () => {
success: true, success: true,
config: { config: {
enabled: true, enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json', fileUrl: 'https://dav.example.com/backups/boosapi.json',
username: 'alice', username: 'alice',
exportType: 'all', exportType: 'all',
autoSyncEnabled: true, autoSyncEnabled: true,
@@ -789,7 +789,7 @@ describe('Models marketplace text', () => {
expect(String(latencyBadge.props.className || '')).toContain('badge-muted'); expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
expect(collectText(latencyBadge)).toContain('延迟'); expect(collectText(latencyBadge)).toContain('延迟');
expect(collectText(latencyBadge)).toContain('—'); expect(collectText(latencyBadge)).toContain('暂无');
expect(collectText(root!.root)).not.toContain('680ms'); expect(collectText(root!.root)).not.toContain('680ms');
} finally { } finally {
root?.unmount(); root?.unmount();
+185
View File
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import MoneyPrinter from './MoneyPrinter.js';
const { apiMock, userApiMock } = vi.hoisted(() => {
const base = {
getMoneyPrinterJobs: vi.fn(),
getMoneyPrinterStatus: vi.fn(),
getWorkflows: vi.fn(),
createMoneyPrinterJob: vi.fn(),
cancelMoneyPrinterJob: vi.fn(),
clearMoneyPrinterJobs: vi.fn(),
};
return {
apiMock: base,
userApiMock: { ...base },
};
});
vi.mock('../api.js', () => ({
api: apiMock,
userApi: userApiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
async function renderMoneyPrinter(initialPath = '/money-printer') {
let root!: ReactTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={[initialPath]}>
<MoneyPrinter />
</MemoryRouter>,
);
});
await flushMicrotasks();
return root;
}
function findButton(root: ReactTestRenderer, label: string) {
return root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes(label)
));
}
function defaultStatus(overrides: Record<string, any> = {}) {
return {
success: true,
mode: 'boosapi-native',
keyName: 'global-proxy',
comfy: {
enabled: true,
backend: 'cloud',
baseUrl: 'https://cloud.comfy.org',
},
builtinCloudTemplate: {
enabled: true,
label: '内置 Cloud Seedance 文生视频',
nodeType: 'ByteDanceTextToVideoNode',
model: 'seedance-1-0-lite-t2v-250428',
duration: { min: 3, max: 12, default: 5 },
resolutions: ['480p', '720p', '1080p'],
ratios: ['9:16', '16:9'],
},
...overrides,
};
}
describe('MoneyPrinter page', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
apiMock.getMoneyPrinterJobs.mockResolvedValue({ success: true, jobs: [] });
apiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus());
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
apiMock.clearMoneyPrinterJobs.mockResolvedValue({ success: true, cleared: 0 });
apiMock.cancelMoneyPrinterJob.mockResolvedValue({ success: true, job: null });
userApiMock.getMoneyPrinterJobs.mockResolvedValue({ success: true, jobs: [] });
userApiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus());
userApiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('shows the built-in Cloud video template when Cloud backend is active', async () => {
let root!: ReactTestRenderer;
try {
root = await renderMoneyPrinter();
const text = collectText(root.root);
expect(text).toContain('Comfy Cloud');
expect(text).toContain('内置 Cloud Seedance');
expect(text).toContain('完成后进入工作空间');
} finally {
root?.unmount();
}
});
it('applies a quick topic preset into the production form', async () => {
let root!: ReactTestRenderer;
try {
root = await renderMoneyPrinter();
await act(async () => {
findButton(root, '知识科普').props.onClick();
});
const textareas = root.root.findAll((node) => node.type === 'textarea');
expect(textareas[0].props.value).toContain('统一的 AI API 聚合网关');
expect(textareas[1].props.value).toContain('用清晰分镜解释问题');
const ratioSelect = root.root.findAll((node) => node.type === 'select')
.find((node) => node.props.value === '16:9');
expect(ratioSelect).toBeTruthy();
} finally {
root?.unmount();
}
});
it('filters the task queue by failed jobs', async () => {
apiMock.getMoneyPrinterJobs.mockResolvedValue({
success: true,
jobs: [
{
id: 'mp_running',
type: 'money-printer-video',
title: '正在生成的视频',
status: 'running',
message: 'Comfy 正在生成视频',
progress: 4,
progressTotal: 5,
result: { request: { videoBackend: 'comfy', duration: 5, ratio: '9:16', resolution: '720p' } },
createdAt: '2026-05-26 10:00:00',
},
{
id: 'mp_failed',
type: 'money-printer-video',
title: '失败的视频',
status: 'failed',
message: '任务失败',
error: '余额不足',
progress: 3,
progressTotal: 5,
result: { request: { videoBackend: 'comfy', duration: 5, ratio: '9:16', resolution: '720p' } },
createdAt: '2026-05-26 10:01:00',
},
],
});
let root!: ReactTestRenderer;
try {
root = await renderMoneyPrinter();
expect(collectText(root.root)).toContain('正在生成的视频');
expect(collectText(root.root)).toContain('失败的视频');
await act(async () => {
findButton(root, '失败').props.onClick();
});
const filteredText = collectText(root.root);
expect(filteredText).not.toContain('正在生成的视频');
expect(filteredText).toContain('失败的视频');
expect(filteredText).toContain('余额不足');
} finally {
root?.unmount();
}
});
});
+465 -62
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { api, userApi, type MoneyPrinterJob, type WorkflowSummary } from '../api.js'; import { api, userApi, type MoneyPrinterJob, type MoneyPrinterStatusResponse, type WorkflowSummary } from '../api.js';
import type { SessionAudience } from '../authSession.js'; import type { SessionAudience } from '../authSession.js';
const STYLE_PRESETS = [ const STYLE_PRESETS = [
@@ -10,6 +10,59 @@ const STYLE_PRESETS = [
'电影感广告,镜头运动丰富,高级质感', '电影感广告,镜头运动丰富,高级质感',
]; ];
const TOPIC_PRESETS = [
{
title: '商品种草',
desc: '适合 SaaS、工具、服务类产品',
topic: '为 BossAI 生成一个 30 秒商品种草短视频,突出多模型、多模态、工作流和统一 API 网关能力。',
audience: 'AI 工具用户、内容创作者、企业运营',
style: STYLE_PRESETS[1],
requirements: '开头 3 秒给出强钩子,中段展示核心卖点,结尾引导试用。画面干净,有真实产品演示感。',
duration: 30,
ratio: '9:16',
},
{
title: '知识科普',
desc: '解释概念、流程、技术卖点',
topic: '做一个 45 秒知识科普短视频,解释为什么企业需要统一的 AI API 聚合网关。',
audience: '开发者、技术负责人、AI 产品经理',
style: STYLE_PRESETS[2],
requirements: '用清晰分镜解释问题、方案和收益,避免空泛口号,字幕信息密度高但不要堆字。',
duration: 45,
ratio: '16:9',
},
{
title: '功能更新',
desc: '发布新能力和版本亮点',
topic: '生成一个 30 秒 BossAI 工作流新功能发布短视频,重点展示短视频工厂、数字人和工作空间归档。',
audience: '现有用户、团队管理员、内容运营',
style: STYLE_PRESETS[0],
requirements: '节奏要快,按「新功能 -> 使用场景 -> 结果产出」展开,结尾提醒用户打开工作流试用。',
duration: 30,
ratio: '9:16',
},
{
title: '活动预热',
desc: '适合促销、课程、发布会',
topic: '生成一个 20 秒活动预热视频,主题是 BossAI 多模态创作工作流体验日。',
audience: '创作者、营销团队、AI 学习者',
style: STYLE_PRESETS[3],
requirements: '画面需要有倒计时、场景切换和高能结尾,重点营造期待感,不要像普通广告口播。',
duration: 20,
ratio: '9:16',
},
];
const PIPELINE_STEPS = [
'创建任务',
'脚本分镜',
'提交视频',
'生成成片',
'归档产出',
];
type JobFilter = 'all' | 'active' | 'succeeded' | 'failed' | 'cancelled';
function terminal(status: string) { function terminal(status: string) {
return ['succeeded', 'failed', 'cancelled'].includes(status); return ['succeeded', 'failed', 'cancelled'].includes(status);
} }
@@ -22,6 +75,14 @@ function statusLabel(status: string) {
return '排队中'; return '排队中';
} }
function statusTone(status: string) {
if (status === 'succeeded') return 'success';
if (status === 'failed') return 'danger';
if (status === 'cancelled') return 'muted';
if (status === 'running') return 'primary';
return 'warning';
}
function progress(job: MoneyPrinterJob) { function progress(job: MoneyPrinterJob) {
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100))); return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
} }
@@ -37,6 +98,47 @@ function jobAssets(job: MoneyPrinterJob) {
return Array.isArray(job.result?.assets) ? job.result.assets : []; return Array.isArray(job.result?.assets) ? job.result.assets : [];
} }
function backendLabel(value?: string | null) {
const normalized = String(value || '').toLowerCase();
if (normalized === 'cloud') return 'Comfy Cloud';
if (normalized === 'external') return '外部 Comfy';
if (normalized === 'runninghub') return 'RunningHub';
if (normalized === 'docker') return 'Docker Comfy';
if (normalized === 'comfy') return 'Comfy API';
if (normalized === 'boosapi') return 'BoosAPI /v1/videos';
if (!normalized) return '未配置';
return value || '未配置';
}
function jobBackendLabel(job: MoneyPrinterJob) {
return backendLabel(job.result?.videoBackend || job.result?.request?.videoBackend || job.result?.request?.video_backend);
}
function jobModelLabel(job: MoneyPrinterJob) {
return job.result?.model
|| job.result?.request?.videoModel
|| job.result?.request?.model
|| (String(job.result?.videoBackend || job.result?.request?.videoBackend || '').toLowerCase() === 'comfy' ? 'Comfy 模板模型' : '默认视频模型');
}
function jobTemplateLabel(job: MoneyPrinterJob, workflows: WorkflowSummary[]) {
const templateId = job.result?.request?.templateId || job.result?.request?.template_id;
if (!templateId) return '';
if (templateId === BUILTIN_CLOUD_TEMPLATE_VALUE) return '内置 Cloud Seedance';
const workflow = workflows.find((item) => item.publicId === templateId);
return workflow?.name || String(templateId).slice(0, 10);
}
function jobSpecLabel(job: MoneyPrinterJob) {
const request = job.result?.request || {};
const parts = [
request.duration ? `${request.duration} 秒` : '',
request.ratio || request.aspectRatio || request.aspect_ratio || '',
request.resolution || request.size || '',
].filter(Boolean);
return parts.join(' · ');
}
function planValue(job: MoneyPrinterJob, key: string) { function planValue(job: MoneyPrinterJob, key: string) {
return job.result?.script?.plan?.[key] || ''; return job.result?.script?.plan?.[key] || '';
} }
@@ -55,6 +157,11 @@ function mappingFromGraph(graph: any) {
function inferMappingFromPrompt(prompt: any) { function inferMappingFromPrompt(prompt: any) {
const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt) ? Object.entries(prompt as Record<string, any>) : []; const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt) ? Object.entries(prompt as Record<string, any>) : [];
const inputKeys = (nodeId: string) => Object.keys(prompt?.[String(nodeId)]?.inputs || {});
const firstInputField = (nodeId: string, fields: string[], fallback: string) => {
const keys = inputKeys(nodeId);
return fields.find((field) => keys.includes(field)) || fallback;
};
const find = (classWords: string[], inputWords: string[] = []) => { const find = (classWords: string[], inputWords: string[] = []) => {
return nodes.find(([, node]) => { return nodes.find(([, node]) => {
const classType = String(node?.class_type || node?.type || '').toLowerCase(); const classType = String(node?.class_type || node?.type || '').toLowerCase();
@@ -66,11 +173,11 @@ function inferMappingFromPrompt(prompt: any) {
const negativeNode = find(['negative'], ['negative']); const negativeNode = find(['negative'], ['negative']);
const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']); const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']);
return { return {
prompt: { nodeId: String(promptNode), field: prompt?.[String(promptNode)]?.inputs?.prompt !== undefined ? 'prompt' : 'text' }, prompt: { nodeId: String(promptNode), field: firstInputField(String(promptNode), ['prompt', 'positive', 'text', 'positive_prompt'], 'text') },
negativePrompt: { nodeId: String(negativeNode), field: 'text' }, negativePrompt: { nodeId: String(negativeNode), field: 'text' },
ratio: { nodeId: String(videoNode), field: 'ratio' }, ratio: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['ratio', 'model.ratio', 'aspect_ratio'], 'ratio') },
resolution: { nodeId: String(videoNode), field: 'resolution' }, resolution: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['model.resolution', 'resolution', 'size'], 'resolution') },
duration: { nodeId: String(videoNode), field: 'duration' }, duration: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['model.duration', 'duration', 'length'], 'duration') },
}; };
} }
@@ -192,7 +299,6 @@ function workflowLooksVideoCapable(workflow: WorkflowSummary) {
function moneyPrinterWorkflows(items: WorkflowSummary[]) { function moneyPrinterWorkflows(items: WorkflowSummary[]) {
const seen = new Set<string>(); const seen = new Set<string>();
return items return items
.filter((workflow) => workflowLooksVideoCapable(workflow))
.filter((workflow) => { .filter((workflow) => {
const key = workflow.publicId || String(workflow.id); const key = workflow.publicId || String(workflow.id);
if (seen.has(key)) return false; if (seen.has(key)) return false;
@@ -200,6 +306,9 @@ function moneyPrinterWorkflows(items: WorkflowSummary[]) {
return true; return true;
}) })
.sort((a, b) => { .sort((a, b) => {
const aVideo = workflowLooksVideoCapable(a);
const bVideo = workflowLooksVideoCapable(b);
if (aVideo !== bVideo) return aVideo ? -1 : 1;
const aTemplate = a.isTemplate || a.visibility === 'template' || a.visibility === 'public'; const aTemplate = a.isTemplate || a.visibility === 'template' || a.visibility === 'public';
const bTemplate = b.isTemplate || b.visibility === 'template' || b.visibility === 'public'; const bTemplate = b.isTemplate || b.visibility === 'template' || b.visibility === 'public';
if (aTemplate !== bTemplate) return aTemplate ? -1 : 1; if (aTemplate !== bTemplate) return aTemplate ? -1 : 1;
@@ -207,6 +316,22 @@ function moneyPrinterWorkflows(items: WorkflowSummary[]) {
}); });
} }
function parseJsonInput(value: string, label: string) {
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
throw new Error(`${label} 不是有效 JSON`);
}
}
const BUILTIN_CLOUD_TEMPLATE_VALUE = '__builtin_cloud_seedance__';
const DEFAULT_BUILTIN_CLOUD_DURATION = 5;
const DEFAULT_BUILTIN_CLOUD_RESOLUTION = '480p';
const DEFAULT_CLOUD_RATIOS = ['9:16', '16:9', '1:1'];
const DEFAULT_CLOUD_RESOLUTIONS = ['480p', '720p', '1080p'];
export default function MoneyPrinter() { export default function MoneyPrinter() {
const location = useLocation(); const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin'; const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
@@ -215,7 +340,7 @@ export default function MoneyPrinter() {
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频'); const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
const [scriptModel, setScriptModel] = useState('gpt-5.4'); const [scriptModel, setScriptModel] = useState('gpt-5.4');
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v'); const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi'); const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
const [style, setStyle] = useState(STYLE_PRESETS[0]); const [style, setStyle] = useState(STYLE_PRESETS[0]);
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营'); const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
const [duration, setDuration] = useState(30); const [duration, setDuration] = useState(30);
@@ -226,16 +351,46 @@ export default function MoneyPrinter() {
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}'); const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]); const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [templateId, setTemplateId] = useState(''); const [templateId, setTemplateId] = useState('');
const [useAdvancedComfy, setUseAdvancedComfy] = useState(false); const [showAdvancedComfy, setShowAdvancedComfy] = useState(false);
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]); const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
const [status, setStatus] = useState<MoneyPrinterStatusResponse | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [clearing, setClearing] = useState(false);
const [cancellingIds, setCancellingIds] = useState<string[]>([]);
const [jobFilter, setJobFilter] = useState<JobFilter>('all');
const [error, setError] = useState(''); const [error, setError] = useState('');
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
const cloudResolutions = useMemo(
() => status?.builtinCloudTemplate?.resolutions?.length
? status.builtinCloudTemplate.resolutions
: DEFAULT_CLOUD_RESOLUTIONS,
[status?.builtinCloudTemplate?.resolutions],
);
const cloudRatios = useMemo(
() => status?.builtinCloudTemplate?.ratios?.length
? status.builtinCloudTemplate.ratios
: DEFAULT_CLOUD_RATIOS,
[status?.builtinCloudTemplate?.ratios],
);
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
const payload = await client.getMoneyPrinterJobs({ limit: 30 }); const payload = await client.getMoneyPrinterJobs({ limit: 30 });
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []); setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
}, [client]); }, [client]);
const loadStatus = useCallback(async () => {
const payload = await client.getMoneyPrinterStatus();
setStatus(payload);
}, [client]);
const refresh = useCallback(async () => {
await Promise.all([loadJobs(), loadStatus()]);
}, [loadJobs, loadStatus]);
const loadWorkflows = useCallback(async () => { const loadWorkflows = useCallback(async () => {
const payload = await client.getWorkflows({ include_public: true }); const payload = await client.getWorkflows({ include_public: true });
const items = Array.isArray(payload.items) ? payload.items : []; const items = Array.isArray(payload.items) ? payload.items : [];
@@ -243,41 +398,137 @@ export default function MoneyPrinter() {
setWorkflows(videoItems); setWorkflows(videoItems);
setTemplateId((current) => { setTemplateId((current) => {
if (current && videoItems.some((item) => item.publicId === current)) return current; if (current && videoItems.some((item) => item.publicId === current)) return current;
if (isCloudComfy) return BUILTIN_CLOUD_TEMPLATE_VALUE;
return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId
|| videoItems[0]?.publicId || videoItems[0]?.publicId
|| ''; || '';
}); });
}, [client]); }, [client, isCloudComfy]);
useEffect(() => { useEffect(() => {
Promise.all([loadJobs(), loadWorkflows()]).catch((err) => setError(err?.message || '短视频任务加载失败')); Promise.all([loadJobs(), loadWorkflows(), loadStatus()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
}, [loadJobs, loadWorkflows]); }, [loadJobs, loadWorkflows, loadStatus]);
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null; const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
const useBuiltinCloudTemplate = videoBackend === 'comfy' && isCloudComfy && templateId === BUILTIN_CLOUD_TEMPLATE_VALUE;
const comfyHasTemplate = useBuiltinCloudTemplate || Boolean(templateId && selectedWorkflow);
const canSubmit = Boolean(topic.trim()) && !submitting && (videoBackend !== 'comfy' || comfyHasTemplate);
const ratioOptions = useBuiltinCloudTemplate ? cloudRatios : DEFAULT_CLOUD_RATIOS;
const resolutionOptions = useBuiltinCloudTemplate ? cloudResolutions : DEFAULT_CLOUD_RESOLUTIONS;
const durationMin = useBuiltinCloudTemplate ? cloudDurationMin : 5;
const durationMax = useBuiltinCloudTemplate ? cloudDurationMax : 120;
const runningJobs = useMemo(() => jobs.filter((job) => !terminal(job.status)), [jobs]);
const succeededJobs = useMemo(() => jobs.filter((job) => job.status === 'succeeded'), [jobs]);
const failedJobs = useMemo(() => jobs.filter((job) => job.status === 'failed'), [jobs]);
const cancelledJobs = useMemo(() => jobs.filter((job) => job.status === 'cancelled'), [jobs]);
const filteredJobs = useMemo(() => {
if (jobFilter === 'active') return jobs.filter((job) => !terminal(job.status));
if (jobFilter === 'succeeded') return succeededJobs;
if (jobFilter === 'failed') return failedJobs;
if (jobFilter === 'cancelled') return cancelledJobs;
return jobs;
}, [cancelledJobs, failedJobs, jobFilter, jobs, succeededJobs]);
const videoTemplateCount = useMemo(() => workflows.filter(workflowLooksVideoCapable).length, [workflows]);
const comfyBackendLabel = backendLabel(status?.comfy?.backend);
const currentTemplateLabel = useBuiltinCloudTemplate
? '内置 Cloud Seedance'
: selectedWorkflow?.name || (videoBackend === 'comfy' ? '未选择模板' : '不使用模板');
const archivedAssetCount = useMemo(
() => jobs.reduce((total, job) => total + jobAssets(job).filter((asset: any) => asset?.status !== 'failed').length, 0),
[jobs],
);
const activePipelineStep = useMemo(() => {
if (runningJobs.length === 0) return -1;
const step = Math.max(...runningJobs.map((job) => Number(job.progress || 1)));
return Math.max(0, Math.min(PIPELINE_STEPS.length - 1, step - 1));
}, [runningJobs]);
const filterOptions = useMemo(() => [
{ key: 'all' as const, label: '全部', count: jobs.length },
{ key: 'active' as const, label: '进行中', count: runningJobs.length },
{ key: 'succeeded' as const, label: '已完成', count: succeededJobs.length },
{ key: 'failed' as const, label: '失败', count: failedJobs.length },
{ key: 'cancelled' as const, label: '已取消', count: cancelledJobs.length },
], [cancelledJobs.length, failedJobs.length, jobs.length, runningJobs.length, succeededJobs.length]);
useEffect(() => { useEffect(() => {
if (!selectedWorkflow || videoBackend !== 'comfy' || !useAdvancedComfy) return; if (videoBackend !== 'comfy') return;
if (isCloudComfy) {
setTemplateId((current) => current || BUILTIN_CLOUD_TEMPLATE_VALUE);
return;
}
setTemplateId((current) => current === BUILTIN_CLOUD_TEMPLATE_VALUE ? '' : current);
}, [isCloudComfy, videoBackend]);
useEffect(() => {
if (!useBuiltinCloudTemplate) return;
setDuration((current) => {
const numeric = Number.isFinite(current) ? current : cloudDefaultDuration;
return Math.max(cloudDurationMin, Math.min(cloudDurationMax, numeric));
});
setResolution((current) => cloudResolutions.includes(current) ? current : DEFAULT_BUILTIN_CLOUD_RESOLUTION);
setRatio((current) => cloudRatios.includes(current) ? current : '9:16');
}, [cloudDefaultDuration, cloudDurationMax, cloudDurationMin, cloudRatios, cloudResolutions, useBuiltinCloudTemplate]);
useEffect(() => {
if (useBuiltinCloudTemplate) {
setComfyPrompt('');
setComfyNodeMapping('{\n "prompt": { "nodeId": "1", "field": "prompt" },\n "ratio": { "nodeId": "1", "field": "aspect_ratio" },\n "resolution": { "nodeId": "1", "field": "resolution" },\n "duration": { "nodeId": "1", "field": "duration" }\n}');
return;
}
if (!selectedWorkflow || videoBackend !== 'comfy') return;
const prompt = workflowPromptFromGraph(selectedWorkflow.graph); const prompt = workflowPromptFromGraph(selectedWorkflow.graph);
if (prompt) setComfyPrompt(JSON.stringify(prompt, null, 2)); if (prompt) setComfyPrompt(JSON.stringify(prompt, null, 2));
const mapping = mappingFromGraph(selectedWorkflow.graph); const mapping = mappingFromGraph(selectedWorkflow.graph);
setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2)); setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2));
}, [selectedWorkflow?.publicId, videoBackend]); }, [selectedWorkflow?.publicId, videoBackend, useBuiltinCloudTemplate]);
useEffect(() => { useEffect(() => {
if (!jobs.some((job) => !terminal(job.status))) return; if (!jobs.some((job) => !terminal(job.status))) return;
const timer = window.setInterval(() => { const timer = globalThis.setInterval(() => {
client.getMoneyPrinterJobs({ limit: 30 }) client.getMoneyPrinterJobs({ limit: 30 })
.then((payload) => setJobs(payload.jobs || [])) .then((payload) => setJobs(payload.jobs || []))
.catch(() => {}); .catch(() => {});
}, 5000); }, 5000);
return () => window.clearInterval(timer); return () => globalThis.clearInterval(timer);
}, [client, jobs]); }, [client, jobs]);
const refreshNow = async () => {
setRefreshing(true);
setError('');
try {
await refresh();
await loadWorkflows();
} catch (err: any) {
setError(err?.message || '短视频任务刷新失败');
} finally {
setRefreshing(false);
}
};
const applyPreset = (preset: typeof TOPIC_PRESETS[number]) => {
setTopic(preset.topic);
setAudienceText(preset.audience);
setStyle(preset.style);
setRequirements(preset.requirements);
setDuration(preset.duration);
setRatio(preset.ratio);
};
const submit = async () => { const submit = async () => {
if (!topic.trim() || submitting) return; if (!topic.trim() || submitting) return;
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
try { try {
if (videoBackend === 'comfy' && !comfyHasTemplate) {
throw new Error(isCloudComfy ? '请选择 Cloud 内置模板或一个真实视频工作流模板' : '当前 Comfy 后端需要选择一个真实视频工作流模板');
}
const nextDuration = useBuiltinCloudTemplate
? Math.max(cloudDurationMin, Math.min(cloudDurationMax, Number(duration) || cloudDefaultDuration))
: duration;
const nextResolution = useBuiltinCloudTemplate && !cloudResolutions.includes(resolution)
? DEFAULT_BUILTIN_CLOUD_RESOLUTION
: resolution;
const nextRatio = useBuiltinCloudTemplate && !cloudRatios.includes(ratio) ? '9:16' : ratio;
const payload = await client.createMoneyPrinterJob({ const payload = await client.createMoneyPrinterJob({
topic: topic.trim(), topic: topic.trim(),
scriptModel: scriptModel.trim(), scriptModel: scriptModel.trim(),
@@ -285,15 +536,15 @@ export default function MoneyPrinter() {
videoModel: videoModel.trim(), videoModel: videoModel.trim(),
style, style,
audience: audienceText, audience: audienceText,
duration, duration: nextDuration,
ratio, ratio: nextRatio,
resolution, resolution: nextResolution,
requirements, requirements,
language: '中文', language: '中文',
...(videoBackend === 'comfy' ? { ...(videoBackend === 'comfy' ? {
templateId: templateId || undefined, templateId: useBuiltinCloudTemplate ? undefined : templateId,
comfyPrompt: comfyPrompt.trim() ? JSON.parse(comfyPrompt) : undefined, comfyPrompt: useBuiltinCloudTemplate ? undefined : parseJsonInput(comfyPrompt, 'Comfy prompt JSON'),
comfyNodeMapping: comfyNodeMapping.trim() ? JSON.parse(comfyNodeMapping) : undefined, comfyNodeMapping: parseJsonInput(comfyNodeMapping, '节点映射 JSON'),
} : {}), } : {}),
}); });
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]); setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
@@ -305,13 +556,30 @@ export default function MoneyPrinter() {
}; };
const cancel = async (id: string) => { const cancel = async (id: string) => {
const payload = await client.cancelMoneyPrinterJob(id); setCancellingIds((prev) => prev.includes(id) ? prev : [...prev, id]);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item)); setError('');
try {
const payload = await client.cancelMoneyPrinterJob(id);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
} catch (err: any) {
setError(err?.message || '取消短视频任务失败');
} finally {
setCancellingIds((prev) => prev.filter((item) => item !== id));
}
}; };
const clear = async () => { const clear = async () => {
await client.clearMoneyPrinterJobs(); setClearing(true);
await loadJobs(); setError('');
try {
await client.clearMoneyPrinterJobs();
await loadJobs();
setJobFilter('all');
} catch (err: any) {
setError(err?.message || '清空短视频任务失败');
} finally {
setClearing(false);
}
}; };
const retry = (job: MoneyPrinterJob) => { const retry = (job: MoneyPrinterJob) => {
@@ -329,31 +597,102 @@ export default function MoneyPrinter() {
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt); setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping); setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
setTemplateId(request.templateId || templateId); setTemplateId(request.templateId || templateId);
setUseAdvancedComfy(Boolean(request.templateId)); setShowAdvancedComfy(Boolean(request.comfyPrompt || request.comfyNodeMapping));
}; };
return ( return (
<div className="money-printer-page animate-fade-in"> <div className="money-printer-page animate-fade-in">
<div className="page-header"> <section className="console-hero money-printer-hero-shell">
<div> <div className="console-hero-copy">
<h1 className="page-title">短视频工厂</h1> <span className="console-kicker">Short Video Factory</span>
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BoosAPI 视频模型生产成片。</p> <h1>短视频工厂</h1>
<p>把 MoneyPrinterTurbo 的生产思路接入 BossAI:主题生成脚本和分镜,再交给 BoosAPI 或 Comfy API 模板生产成片。</p>
</div> </div>
<div className="page-actions"> <div className="console-hero-rail" aria-label="短视频工厂摘要">
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button> <div>
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting || !topic.trim()}> <span>后端</span>
<strong>{videoBackend === 'comfy' ? comfyBackendLabel : 'BoosAPI'}</strong>
</div>
<div>
<span>队列</span>
<strong>{runningJobs.length}</strong>
</div>
<div>
<span>归档</span>
<strong>{archivedAssetCount}</strong>
</div>
</div>
<div className="console-hero-actions">
<button type="button" className="btn btn-ghost" onClick={() => void refreshNow()} disabled={refreshing}>
{refreshing ? '刷新中...' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
{submitting ? '提交中...' : '生成短视频'} {submitting ? '提交中...' : '生成短视频'}
</button> </button>
</div> </div>
</div> </section>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>} {error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div className="money-printer-summary">
<div className="money-printer-summary-card">
<span>当前后端</span>
<strong>{videoBackend === 'comfy' ? comfyBackendLabel : 'BoosAPI 视频接口'}</strong>
<em>{status?.keyName ? `使用 ${status.keyName}` : '等待状态同步'}</em>
</div>
<div className="money-printer-summary-card">
<span>视频模板</span>
<strong>{videoBackend === 'comfy' ? currentTemplateLabel : videoModel || '默认模型'}</strong>
<em>{videoBackend === 'comfy' ? `${videoTemplateCount} 个视频模板可选` : '直接调用 /v1/videos'}</em>
</div>
<div className="money-printer-summary-card">
<span>队列</span>
<strong>{runningJobs.length} 个进行中</strong>
<em>{failedJobs.length > 0 ? `${failedJobs.length} 个失败待处理` : '队列状态正常'}</em>
</div>
<div className="money-printer-summary-card">
<span>产出归档</span>
<strong>{archivedAssetCount} 个文件</strong>
<em>完成后进入工作空间</em>
</div>
</div>
<div className="money-printer-layout"> <div className="money-printer-layout">
<section className="money-printer-panel"> <section className="money-printer-panel">
<div className="money-printer-hero"> <div className="money-printer-hero">
<span>MoneyPrinterTurbo inside</span> <span>MoneyPrinterTurbo inside</span>
<strong>一句主题,自动拆成脚本、分镜、视频提示词和成片任务。</strong> <strong>一句主题,自动拆成脚本、分镜、视频提示词,再提交到 Comfy API 生成成片。</strong>
</div>
<div className="money-printer-pipeline" aria-label="短视频生成流程">
{PIPELINE_STEPS.map((step, index) => (
<div
key={step}
className={[
'money-printer-pipeline-step',
activePipelineStep >= index ? 'is-active' : '',
activePipelineStep === index ? 'is-current' : '',
].filter(Boolean).join(' ')}
>
<span>{index + 1}</span>
<strong>{step}</strong>
</div>
))}
</div>
<div className="money-printer-section-head money-printer-section-head-compact">
<div>
<h2>快捷选题</h2>
<p>先套一个视频方向,再微调主题和要求。</p>
</div>
</div>
<div className="money-printer-presets">
{TOPIC_PRESETS.map((preset) => (
<button key={preset.title} type="button" className="money-printer-preset-button" onClick={() => applyPreset(preset)}>
<strong>{preset.title}</strong>
<span>{preset.desc}</span>
</button>
))}
</div> </div>
<label className="money-printer-field"> <label className="money-printer-field">
@@ -375,26 +714,27 @@ export default function MoneyPrinter() {
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>视频模型</span> <span>视频模型</span>
<input value={videoModel} onChange={(event) => setVideoModel(event.target.value)} /> <input
value={videoModel}
onChange={(event) => setVideoModel(event.target.value)}
disabled={videoBackend === 'comfy'}
title={videoBackend === 'comfy' ? 'Comfy API 模式使用所选工作流模板里的模型' : undefined}
/>
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>时长</span> <span>时长</span>
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} /> <input type="number" min={durationMin} max={durationMax} value={duration} onChange={(event) => setDuration(Number(event.target.value || durationMin))} />
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>画幅</span> <span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)}> <select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option> {ratioOptions.map((item) => <option key={item} value={item}>{item}</option>)}
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select> </select>
</label> </label>
<label className="money-printer-field"> <label className="money-printer-field">
<span>分辨率</span> <span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)}> <select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="1080p">1080p</option> {resolutionOptions.map((item) => <option key={item} value={item}>{item}</option>)}
<option value="720p">720p</option>
<option value="480p">480p</option>
</select> </select>
</label> </label>
</div> </div>
@@ -427,28 +767,57 @@ export default function MoneyPrinter() {
<label className="money-printer-field"> <label className="money-printer-field">
<span>工作流模板</span> <span>工作流模板</span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}> <select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value="">请选择模板</option> {isCloudComfy && <option value={BUILTIN_CLOUD_TEMPLATE_VALUE}>内置 Cloud Seedance 文生视频</option>}
{!isCloudComfy && <option value="">选择真实 Comfy 视频模板</option>}
{workflows.map((workflow) => ( {workflows.map((workflow) => (
<option key={workflow.publicId} value={workflow.publicId}> <option key={workflow.publicId} value={workflow.publicId}>
{workflowOptionLabel(workflow)} {workflowLooksVideoCapable(workflow) ? '视频 · ' : '模板 · '}{workflowOptionLabel(workflow)}
</option> </option>
))} ))}
</select> </select>
</label> </label>
{selectedWorkflow ? ( {useBuiltinCloudTemplate ? (
<div className="money-printer-template-meta">
<strong>内置 Cloud Seedance 文生视频</strong>
<span>
当前后端为 Cloud,使用官方 {status?.builtinCloudTemplate?.nodeType || 'ByteDanceTextToVideoNode'}
{status?.builtinCloudTemplate?.model ? ` / ${status.builtinCloudTemplate.model}` : ''}。时长 {cloudDurationMin}-{cloudDurationMax} 秒,若账号无余额会直接显示余额不足。
</span>
</div>
) : selectedWorkflow ? (
<div className="money-printer-template-meta"> <div className="money-printer-template-meta">
<strong>{selectedWorkflow.name}</strong> <strong>{selectedWorkflow.name}</strong>
<span>{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '这个模板没有内置节点映射,系统会尝试自动推断。'}</span> <span>
{workflowLooksVideoCapable(selectedWorkflow) ? '已识别为视频工作流' : '未识别视频关键词,请确认这是 Comfy 视频模板'}
{' · '}
{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '系统会按节点结构自动推断提示词和参数映射'}
</span>
</div> </div>
) : null} ) : (
<label className="money-printer-field"> <div className="money-printer-template-meta is-warning">
<span>Comfy prompt JSON</span> <strong>需要视频工作流模板</strong>
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' /> <span>{isCloudComfy ? '可以使用内置 Cloud 模板,也可以选择模板库里的真实视频工作流。' : '当前不是 Cloud 后端,内置 Seedance 模板不可用,请选择 external/runninghub 可执行的视频模板。'}</span>
</label> </div>
<label className="money-printer-field"> )}
<span>节点映射 JSON</span> <button
<textarea value={comfyNodeMapping} onChange={(event) => setComfyNodeMapping(event.target.value)} rows={7} /> type="button"
</label> className="btn btn-ghost money-printer-advanced-toggle"
onClick={() => setShowAdvancedComfy((prev) => !prev)}
>
{showAdvancedComfy ? '收起模板调试' : '展开模板调试'}
</button>
<div className={`anim-collapse ${showAdvancedComfy ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
<label className="money-printer-field">
<span>Comfy prompt JSON</span>
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' />
</label>
<label className="money-printer-field">
<span>节点映射 JSON</span>
<textarea value={comfyNodeMapping} onChange={(event) => setComfyNodeMapping(event.target.value)} rows={7} />
</label>
</div>
</div>
</div> </div>
)} )}
</section> </section>
@@ -459,7 +828,25 @@ export default function MoneyPrinter() {
<h2>任务队列</h2> <h2>任务队列</h2>
<p>脚本、视频生成和工作空间归档都会在后台执行。</p> <p>脚本、视频生成和工作空间归档都会在后台执行。</p>
</div> </div>
<button type="button" className="btn btn-ghost" onClick={() => void clear()}>清空已完成</button> <button type="button" className="btn btn-ghost" onClick={() => void clear()} disabled={clearing}>
{clearing ? '清空中...' : '清空已完成'}
</button>
</div>
<div className="money-printer-filter-bar" role="tablist" aria-label="任务状态筛选">
{filterOptions.map((option) => (
<button
key={option.key}
type="button"
role="tab"
aria-selected={jobFilter === option.key}
className={jobFilter === option.key ? 'is-active' : ''}
onClick={() => setJobFilter(option.key)}
>
<span>{option.label}</span>
<strong>{option.count}</strong>
</button>
))}
</div> </div>
<div className="money-printer-job-list"> <div className="money-printer-job-list">
@@ -468,21 +855,33 @@ export default function MoneyPrinter() {
<div className="empty-state-title">暂无短视频任务</div> <div className="empty-state-title">暂无短视频任务</div>
<div className="empty-state-desc">提交主题后,系统会自动调用 BoosAPI 内置模型生成脚本和视频。</div> <div className="empty-state-desc">提交主题后,系统会自动调用 BoosAPI 内置模型生成脚本和视频。</div>
</div> </div>
) : jobs.map((job) => { ) : filteredJobs.length === 0 ? (
<div className="empty-state" style={{ minHeight: 180 }}>
<div className="empty-state-title">当前筛选没有任务</div>
<div className="empty-state-desc">切换到“全部”可以查看最近 30 条短视频任务。</div>
</div>
) : filteredJobs.map((job) => {
const percent = progress(job); const percent = progress(job);
const assets = jobAssets(job); const assets = jobAssets(job);
const spec = jobSpecLabel(job);
const template = jobTemplateLabel(job, workflows);
const cancelling = cancellingIds.includes(job.id);
return ( return (
<article key={job.id} className="money-printer-job-card"> <article key={job.id} className={`money-printer-job-card is-${statusTone(job.status)}`}>
<div className="money-printer-job-head"> <div className="money-printer-job-head">
<div> <div>
<strong>{job.title || '短视频工厂任务'}</strong> <strong>{job.title || '短视频工厂任务'}</strong>
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span> <span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
</div> </div>
<em>{statusLabel(job.status)}</em> <em className={`money-printer-status-pill is-${statusTone(job.status)}`}>{statusLabel(job.status)}</em>
</div> </div>
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div> <div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
<div className="money-printer-job-meta"> <div className="money-printer-job-meta">
<span>进度 {percent}%</span> <span>进度 {percent}%</span>
<span>{jobBackendLabel(job)}</span>
<span>{jobModelLabel(job)}</span>
{template && <span>{template}</span>}
{spec && <span>{spec}</span>}
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>} {job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
</div> </div>
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>} {planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
@@ -491,13 +890,17 @@ export default function MoneyPrinter() {
<div className="money-printer-assets"> <div className="money-printer-assets">
{assets.map((asset: any) => ( {assets.map((asset: any) => (
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}> <a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
成片 · {asset.publicId || asset.status} 工作空间 · {asset.publicId || asset.status}
</a> </a>
))} ))}
</div> </div>
)} )}
<div className="money-printer-job-buttons"> <div className="money-printer-job-buttons">
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)}>取消</button>} {!terminal(job.status) && (
<button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)} disabled={cancelling}>
{cancelling ? '取消中...' : '取消任务'}
</button>
)}
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>} {terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
</div> </div>
</article> </article>
@@ -520,7 +520,7 @@ describe('ProxyLogs server-driven page', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('metapi.proxyLogs.debugTracePanelExpanded', 'false'); expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('boosapi.proxyLogs.debugTracePanelExpanded', 'false');
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@@ -547,7 +547,7 @@ describe('ProxyLogs server-driven page', () => {
&& node.props['data-debug-trace-panel-body'] === true && node.props['data-debug-trace-panel-body'] === true
)); ));
expect(globalThis.localStorage.getItem).toHaveBeenCalledWith('metapi.proxyLogs.debugTracePanelExpanded'); expect(globalThis.localStorage.getItem).toHaveBeenCalledWith('boosapi.proxyLogs.debugTracePanelExpanded');
expect(restoredToggleButton.props['aria-expanded']).toBe(false); expect(restoredToggleButton.props['aria-expanded']).toBe(false);
expect(String(restoredPanelBody.props.className || '')).not.toContain('is-open'); expect(String(restoredPanelBody.props.className || '')).not.toContain('is-open');
} finally { } finally {
+404 -59
View File
@@ -56,6 +56,19 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
type DbDialect = 'sqlite' | 'mysql' | 'postgres'; type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value']; type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning'; type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
type SettingsNavItem = {
id: string;
label: string;
badge: string;
tone: SettingsPillTone;
};
type SettingsAttentionItem = {
key: string;
title: string;
detail: string;
href: string;
tone: SettingsPillTone;
};
type PayloadRulesEditorSectionKey = PayloadRuleAction; type PayloadRulesEditorSectionKey = PayloadRuleAction;
type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>; type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>;
@@ -574,6 +587,102 @@ export default function Settings() {
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
}; };
const settingsModernSectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
scrollMarginTop: 96,
};
const settingsSummaryGridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(4, minmax(0, 1fr))',
gap: 12,
maxWidth: 1120,
marginBottom: 16,
};
const settingsSummaryCardStyle: React.CSSProperties = {
display: 'grid',
gap: 8,
padding: '14px 16px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg-card) 92%, var(--color-bg))',
boxShadow: 'var(--shadow-card)',
minWidth: 0,
};
const settingsShellStyle: React.CSSProperties = {
maxWidth: 1120,
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '220px minmax(0, 1fr)',
alignItems: 'start',
gap: isMobile ? 14 : 18,
};
const settingsNavStyle: React.CSSProperties = {
display: 'grid',
gap: 6,
padding: 12,
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg-card) 94%, var(--color-bg))',
boxShadow: 'var(--shadow-card)',
position: isMobile ? 'static' : 'sticky',
top: 88,
maxHeight: isMobile ? undefined : 'calc(100dvh - 112px)',
overflowY: 'auto',
};
const settingsNavLinkStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-text-secondary)',
textDecoration: 'none',
fontSize: 13,
lineHeight: 1.35,
};
const settingsContentStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
minWidth: 0,
};
const settingsAnchorCardStyle: React.CSSProperties = {
scrollMarginTop: 96,
};
const settingsAttentionPanelStyle: React.CSSProperties = {
maxWidth: 1120,
display: 'grid',
gap: 12,
padding: '14px 16px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg-card) 94%, var(--color-bg))',
boxShadow: 'var(--shadow-card)',
marginBottom: 16,
};
const settingsAttentionHeaderStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
};
const settingsAttentionGridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: 10,
};
const settingsAttentionItemStyle: React.CSSProperties = {
display: 'grid',
gap: 4,
padding: '12px 14px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg) 78%, var(--color-bg-card))',
textDecoration: 'none',
};
const getSettingsPillStyle = (tone: SettingsPillTone): React.CSSProperties => { const getSettingsPillStyle = (tone: SettingsPillTone): React.CSSProperties => {
const toneStyles: Record<SettingsPillTone, React.CSSProperties> = { const toneStyles: Record<SettingsPillTone, React.CSSProperties> = {
@@ -627,6 +736,83 @@ export default function Settings() {
: savedModelAvailabilityProbeEnabled : savedModelAvailabilityProbeEnabled
? '已启用' ? '已启用'
: '已关闭'; : '已关闭';
const activeRoutingProfileLabel = activeRoutingProfile === 'stable'
? '稳定优先'
: activeRoutingProfile === 'cost'
? '成本优先'
: '均衡';
const runtimeDatabaseSummary = runtimeDatabaseState
? `${runtimeDatabaseState.active.dialect}${runtimeDatabaseState.restartRequired ? ' · 待重启' : ''}`
: '加载中';
const systemProxySummary = runtime.systemProxyUrl.trim() ? '已配置' : '未配置';
const securitySummary = runtime.adminIpAllowlist?.length
? `${runtime.adminIpAllowlist.length} 条白名单`
: '未限制 IP';
const modelGovernanceSummary = [
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
].filter(Boolean).join(' / ') || '全部模型开放';
const settingsSections: SettingsNavItem[] = [
{ id: 'settings-access', label: '管理员令牌', badge: maskedToken ? '已配置' : '检查', tone: maskedToken ? 'neutral' : 'warning' },
{ id: 'settings-schedule', label: '定时任务', badge: runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours}h` : 'Cron', tone: 'neutral' },
{ id: 'settings-proxy', label: '代理与失败判定', badge: systemProxySummary, tone: systemProxySummary === '已配置' ? 'primary' : 'neutral' },
{ id: 'settings-payload', label: 'Payload 规则', badge: configuredPayloadRuleCount > 0 ? `${configuredPayloadRuleCount}` : '0', tone: payloadAdvancedDirty ? 'warning' : configuredPayloadRuleCount > 0 ? 'primary' : 'neutral' },
{ id: 'settings-transport', label: '上游传输', badge: runtime.codexUpstreamWebsocketEnabled ? 'WS' : 'HTTP', tone: runtime.codexUpstreamWebsocketEnabled ? 'primary' : 'neutral' },
{ id: 'settings-probe', label: '批量测活', badge: modelAvailabilityProbeStatusLabel, tone: modelAvailabilityProbeStatusTone },
{ id: 'settings-downstream-token', label: '下游令牌', badge: runtime.proxyTokenMasked ? '已配置' : '未设置', tone: runtime.proxyTokenMasked ? 'primary' : 'warning' },
{ id: 'settings-routing', label: '路由策略', badge: activeRoutingProfileLabel, tone: activeRoutingProfile === 'balanced' ? 'neutral' : 'primary' },
{ id: 'settings-governance', label: '模型治理', badge: blockedBrands.length + allowedModels.length > 0 ? '已配置' : '开放', tone: blockedBrands.length + allowedModels.length > 0 ? 'primary' : 'neutral' },
{ id: 'settings-database', label: '数据库', badge: runtimeDatabaseSummary, tone: runtimeDatabaseState?.restartRequired ? 'warning' : 'neutral' },
{ id: 'settings-maintenance', label: '维护与危险操作', badge: '谨慎', tone: 'danger' },
{ id: 'settings-security', label: '会话与安全', badge: securitySummary, tone: runtime.adminIpAllowlist?.length ? 'primary' : 'neutral' },
];
const settingsAttentionItems: SettingsAttentionItem[] = [
runtimeDatabaseState?.restartRequired
? {
key: 'database-restart',
title: '数据库配置待重启',
detail: '运行数据库和已保存配置不一致,重启后才会切换到新连接。',
href: '#settings-database',
tone: 'warning',
}
: null,
payloadAdvancedDirty
? {
key: 'payload-dirty',
title: 'Payload 高级 JSON 待同步',
detail: '高级编辑器有未同步或未保存内容,离开页面前建议保存。',
href: '#settings-payload',
tone: 'warning',
}
: null,
modelAvailabilityProbeDirty
? {
key: 'probe-dirty',
title: '批量测活开关待保存',
detail: '测活状态已修改但尚未保存,当前运行状态不会立刻改变。',
href: '#settings-probe',
tone: 'warning',
}
: null,
savedModelAvailabilityProbeEnabled
? {
key: 'probe-enabled',
title: '批量测活处于开启状态',
detail: '请确认你的上游中转站允许主动探测,避免触发风控。',
href: '#settings-probe',
tone: 'danger',
}
: null,
!runtime.proxyTokenMasked
? {
key: 'downstream-token',
title: '下游访问令牌未设置',
detail: '下游客户端调用代理接口前,需要先配置 PROXY_TOKEN。',
href: '#settings-downstream-token',
tone: 'warning',
}
: null,
].filter((item): item is SettingsAttentionItem => item !== null);
const syncPayloadRuleDraftsFromObject = (value: unknown) => { const syncPayloadRuleDraftsFromObject = (value: unknown) => {
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value)); setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
@@ -824,7 +1010,7 @@ export default function Settings() {
const res = await api.updateRuntimeSettings({ proxyToken: `${PROXY_TOKEN_PREFIX}${suffix}` }); const res = await api.updateRuntimeSettings({ proxyToken: `${PROXY_TOKEN_PREFIX}${suffix}` });
setRuntime((prev) => ({ ...prev, proxyTokenMasked: res.proxyTokenMasked || prev.proxyTokenMasked })); setRuntime((prev) => ({ ...prev, proxyTokenMasked: res.proxyTokenMasked || prev.proxyTokenMasked }));
setProxyTokenSuffix(''); setProxyTokenSuffix('');
toast.success('Proxy token updated'); toast.success('下游访问令牌已更新');
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || '保存失败'); toast.error(err?.message || '保存失败');
} finally { } finally {
@@ -1070,7 +1256,7 @@ export default function Settings() {
), ),
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback, disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
}); });
toast.success('Routing weights saved'); toast.success('路由策略已保存');
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || '保存失败'); toast.error(err?.message || '保存失败');
} finally { } finally {
@@ -1144,7 +1330,7 @@ export default function Settings() {
? res.currentAdminIp ? res.currentAdminIp
: prev.currentAdminIp, : prev.currentAdminIp,
})); }));
toast.success('Security settings saved'); toast.success('安全设置已保存');
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || '保存失败'); toast.error(err?.message || '保存失败');
} finally { } finally {
@@ -1209,7 +1395,7 @@ export default function Settings() {
const handleTestExternalDatabaseConnection = async () => { const handleTestExternalDatabaseConnection = async () => {
if (!effectiveMigrationConnectionString) { if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first'); toast.info('请先填写目标数据库连接');
return; return;
} }
@@ -1226,9 +1412,9 @@ export default function Settings() {
connectionString: effectiveMigrationConnectionString, connectionString: effectiveMigrationConnectionString,
ssl: migrationSsl, ssl: migrationSsl,
}); });
toast.success(`Connection success: ${res.connection || migrationDialect}`); toast.success(`连接测试成功:${res.connection || migrationDialect}`);
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || 'Target database connection failed'); toast.error(err?.message || '目标数据库连接失败');
} finally { } finally {
setTestingMigrationConnection(false); setTestingMigrationConnection(false);
} }
@@ -1236,7 +1422,7 @@ export default function Settings() {
const handleMigrateToExternalDatabase = async () => { const handleMigrateToExternalDatabase = async () => {
if (!effectiveMigrationConnectionString) { if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first'); toast.info('请先填写目标数据库连接');
return; return;
} }
@@ -1247,8 +1433,8 @@ export default function Settings() {
} }
const warning = migrationOverwrite const warning = migrationOverwrite
? 'Confirm migration and overwrite existing data in target database?' ? '确认迁移并覆盖目标数据库现有数据?'
: 'Confirm migration to target database? If target has data, migration may fail.'; : '确认迁移到目标数据库?如果目标库已有数据,迁移可能失败。';
if (!window.confirm(warning)) return; if (!window.confirm(warning)) return;
setMigratingDatabase(true); setMigratingDatabase(true);
@@ -1260,9 +1446,9 @@ export default function Settings() {
ssl: migrationSsl, ssl: migrationSsl,
}); });
setMigrationSummary(res); setMigrationSummary(res);
toast.success(res?.message || 'Database migration completed'); toast.success(res?.message || '数据库迁移已完成');
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || 'Database migration failed'); toast.error(err?.message || '数据库迁移失败');
} finally { } finally {
setMigratingDatabase(false); setMigratingDatabase(false);
} }
@@ -1270,7 +1456,7 @@ export default function Settings() {
const handleSaveRuntimeDatabaseConfig = async () => { const handleSaveRuntimeDatabaseConfig = async () => {
if (!effectiveMigrationConnectionString) { if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first'); toast.info('请先填写目标数据库连接');
return; return;
} }
@@ -1302,9 +1488,9 @@ export default function Settings() {
: null, : null,
restartRequired: !!res?.restartRequired, restartRequired: !!res?.restartRequired,
}); });
toast.success(res?.message || 'Runtime database config saved'); toast.success(res?.message || '运行数据库配置已保存');
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || 'Runtime database config save failed'); toast.error(err?.message || '运行数据库配置保存失败');
} finally { } finally {
setSavingRuntimeDatabase(false); setSavingRuntimeDatabase(false);
} }
@@ -1322,12 +1508,102 @@ export default function Settings() {
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="page-header"> <div className="page-header">
<h2 className="page-title">系统设置</h2> <div>
<h2 className="page-title">系统设置</h2>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.7, marginTop: 6 }}>
管理员运行台:集中配置访问令牌、代理、路由、模型治理、数据库和安全策略。
</div>
</div>
</div> </div>
<div style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={settingsSummaryGridStyle}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}> <div style={settingsSummaryCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>管理员登录令牌</div> <div style={settingsModernFieldLabelStyle}>运行数据库</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{runtimeDatabaseSummary}</div>
<div style={settingsModernFieldHintStyle}>
{runtimeDatabaseState?.active.connection || '使用默认本地数据库'}
</div>
</div>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>上游传输</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{proxyTransportModeLabel}</div>
<div style={settingsModernFieldHintStyle}>{proxyTransportQueueLabel}</div>
</div>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>路由策略</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{activeRoutingProfileLabel}</div>
<div style={settingsModernFieldHintStyle}>失败冷却上限 {runtime.routeFailureCooldownMaxValue} {ROUTE_COOLDOWN_UNIT_OPTIONS.find((item) => item.value === runtime.routeFailureCooldownMaxUnit)?.label || ''}</div>
</div>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>模型治理</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{modelGovernanceSummary}</div>
<div style={settingsModernFieldHintStyle}>批量测活:{modelAvailabilityProbeStatusLabel}</div>
</div>
</div>
<div style={settingsAttentionPanelStyle} aria-label="运行关注事项">
<div style={settingsAttentionHeaderStyle}>
<div>
<div style={settingsModernTitleStyle}>运行关注事项</div>
<div style={settingsModernDescriptionStyle}>
自动汇总需要管理员确认的设置状态;没有异常时保持安静。
</div>
</div>
<span style={getSettingsPillStyle(settingsAttentionItems.length > 0 ? 'warning' : 'primary')}>
{settingsAttentionItems.length > 0 ? `${settingsAttentionItems.length} 项待确认` : '当前配置平稳'}
</span>
</div>
{settingsAttentionItems.length > 0 ? (
<div style={settingsAttentionGridStyle}>
{settingsAttentionItems.map((item) => (
<a key={item.key} href={item.href} style={settingsAttentionItemStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text-primary)' }}>
{item.title}
</span>
<span style={getSettingsPillStyle(item.tone)}>
{item.tone === 'danger' ? '高风险' : item.tone === 'warning' ? '待处理' : '提示'}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
{item.detail}
</div>
</a>
))}
</div>
) : (
<div style={{ ...settingsModernFieldHintStyle, marginTop: 0 }}>
暂无待处理事项。可以继续检查下方分区,或直接保持当前运行配置。
</div>
)}
</div>
<div style={settingsShellStyle}>
<aside style={settingsNavStyle} aria-label="设置分区导航">
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-muted)', padding: '2px 10px 6px' }}>
设置分区
</div>
{settingsSections.map((section) => (
<a key={section.id} href={`#${section.id}`} style={settingsNavLinkStyle}>
<span>{section.label}</span>
<span style={getSettingsPillStyle(section.tone)}>{section.badge}</span>
</a>
))}
</aside>
<div style={settingsContentStyle}>
<div id="settings-access" className="card animate-slide-up stagger-1" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>管理员登录令牌</div>
<div style={settingsModernDescriptionStyle}>
用于管理后台登录。修改后建议同步更新部署侧保存的管理员入口说明。
</div>
</div>
<span style={getSettingsPillStyle(maskedToken ? 'primary' : 'warning')}>
{maskedToken ? '已配置' : '待检查'}
</span>
</div>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 12 }}> <code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 12 }}>
{maskedToken || '****'} {maskedToken || '****'}
</code> </code>
@@ -1341,8 +1617,18 @@ export default function Settings() {
/> />
</div> </div>
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}> <div id="settings-schedule" className="card animate-slide-up stagger-2" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div> <div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>定时任务</div>
<div style={settingsModernDescriptionStyle}>
控制账号签到、余额刷新和日志清理节奏,适合按站点规模调小或调大频率。
</div>
</div>
<span style={getSettingsPillStyle('neutral')}>
{runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours} 小时签到` : 'Cron 签到'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
@@ -1462,10 +1748,17 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-3" style={{ padding: 20 }}> <div id="settings-proxy" className="card animate-slide-up stagger-3" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>系统代理</div> <div style={settingsModernHeaderStyle}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={settingsModernTitleBlockStyle}>
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。 <div style={settingsModernTitleStyle}>系统代理</div>
<div style={settingsModernDescriptionStyle}>
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
</div>
</div>
<span style={getSettingsPillStyle(runtime.systemProxyUrl.trim() ? 'primary' : 'neutral')}>
{systemProxySummary}
</span>
</div> </div>
<input <input
value={runtime.systemProxyUrl} value={runtime.systemProxyUrl}
@@ -1504,10 +1797,17 @@ export default function Settings() {
)} )}
</div> </div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-4" style={settingsModernCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>代理失败判定</div> <div style={settingsModernHeaderStyle}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={settingsModernTitleBlockStyle}>
命中任一关键词或空内容时判定失败,可触发重试。 <div style={settingsModernTitleStyle}>代理失败判定</div>
<div style={settingsModernDescriptionStyle}>
命中任一关键词或空内容时判定失败,可触发重试。
</div>
</div>
<span style={getSettingsPillStyle(runtime.proxyEmptyContentFailEnabled || proxyErrorKeywordsText.trim() ? 'primary' : 'neutral')}>
{runtime.proxyEmptyContentFailEnabled || proxyErrorKeywordsText.trim() ? '已配置' : '默认'}
</span>
</div> </div>
<textarea <textarea
value={proxyErrorKeywordsText} value={proxyErrorKeywordsText}
@@ -1536,7 +1836,7 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="payload-rules"> <div id="settings-payload" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }} data-settings-card="payload-rules">
<div style={settingsModernHeaderStyle}> <div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}> <div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>Payload 规则</div> <div style={settingsModernTitleStyle}>Payload 规则</div>
@@ -1794,7 +2094,7 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport"> <div id="settings-transport" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }} data-settings-card="proxy-transport">
<div style={settingsModernHeaderStyle}> <div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}> <div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div> <div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
@@ -1891,7 +2191,7 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-4" style={settingsModernDangerCardStyle} data-settings-card="model-availability-probe"> <div id="settings-probe" className="card animate-slide-up stagger-4" style={{ ...settingsModernDangerCardStyle, ...settingsAnchorCardStyle }} data-settings-card="model-availability-probe">
<div style={settingsModernHeaderStyle}> <div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}> <div style={settingsModernTitleBlockStyle}>
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div> <div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
@@ -1962,7 +2262,7 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}> <div id="settings-downstream-token" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。 用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
@@ -2055,10 +2355,22 @@ export default function Settings() {
</button> </button>
</div> </div>
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}> <div id="settings-routing" className="card animate-slide-up stagger-5" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div> <div style={settingsModernHeaderStyle}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={settingsModernTitleBlockStyle}>
先选择预设策略,只有需要精调时再展开高级参数。 <div style={settingsModernTitleStyle}>路由策略</div>
<div style={settingsModernDescriptionStyle}>
先选择预设策略,只有需要精调时再展开高级参数。
</div>
</div>
<div style={settingsModernPillRowStyle}>
<span style={getSettingsPillStyle(activeRoutingProfile === 'balanced' ? 'neutral' : 'primary')}>
{activeRoutingProfileLabel}
</span>
<span style={getSettingsPillStyle(runtime.disableCrossProtocolFallback ? 'warning' : 'neutral')}>
{runtime.disableCrossProtocolFallback ? '禁止跨协议' : '允许兼容切换'}
</span>
</div>
</div> </div>
<div style={{ marginBottom: 12, maxWidth: 280 }}> <div style={{ marginBottom: 12, maxWidth: 280 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
@@ -2252,10 +2564,17 @@ export default function Settings() {
</div> </div>
{/* Global Brand Filter */} {/* Global Brand Filter */}
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}> <div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局品牌屏蔽</div> <div style={settingsModernHeaderStyle}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}> <div style={settingsModernTitleBlockStyle}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。 <div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
<div style={settingsModernDescriptionStyle}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
</div>
</div>
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
</span>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{(allBrandNames || []).map((brand) => { {(allBrandNames || []).map((brand) => {
@@ -2301,10 +2620,17 @@ export default function Settings() {
</div> </div>
{/* Global Allowed Models Whitelist */} {/* Global Allowed Models Whitelist */}
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-7" style={settingsModernCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局模型白名单</div> <div style={settingsModernHeaderStyle}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}> <div style={settingsModernTitleBlockStyle}>
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。 <div style={settingsModernTitleStyle}>全局模型白名单</div>
<div style={settingsModernDescriptionStyle}>
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
</div>
</div>
<span style={getSettingsPillStyle(allowedModels.length > 0 ? 'primary' : 'neutral')}>
{allowedModels.length > 0 ? `${allowedModels.length} 个模型` : '全部开放'}
</span>
</div> </div>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
@@ -2411,10 +2737,22 @@ export default function Settings() {
</button> </button>
</div> </div>
<div className="card animate-slide-up stagger-8" style={{ padding: 20 }}> <div id="settings-database" className="card animate-slide-up stagger-8" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div> <div style={settingsModernHeaderStyle}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={settingsModernTitleBlockStyle}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。 <div style={settingsModernTitleStyle}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
<div style={settingsModernDescriptionStyle}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
</div>
</div>
<div style={settingsModernPillRowStyle}>
<span style={getSettingsPillStyle(runtimeDatabaseState?.restartRequired ? 'warning' : 'neutral')}>
{runtimeDatabaseSummary}
</span>
<span style={getSettingsPillStyle(migrationOverwrite ? 'warning' : 'neutral')}>
{migrationOverwrite ? '允许覆盖' : '不覆盖'}
</span>
</div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
@@ -2462,19 +2800,19 @@ export default function Settings() {
<input <input
value={shorthandConnection.host} value={shorthandConnection.host}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))} onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))}
placeholder="Host (required)" placeholder="主机地址(必填)"
style={inputStyle} style={inputStyle}
/> />
<input <input
value={shorthandConnection.user} value={shorthandConnection.user}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, user: e.target.value }))} onChange={(e) => setShorthandConnection((prev) => ({ ...prev, user: e.target.value }))}
placeholder="User (required)" placeholder="用户名(必填)"
style={inputStyle} style={inputStyle}
/> />
<input <input
value={shorthandConnection.password} value={shorthandConnection.password}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, password: e.target.value }))} onChange={(e) => setShorthandConnection((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Password (required)" placeholder="密码(必填)"
type="password" type="password"
style={inputStyle} style={inputStyle}
/> />
@@ -2505,7 +2843,7 @@ export default function Settings() {
</div> </div>
)} )}
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)' }}> <code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)' }}>
{generatedConnectionString || 'Fill host/user/password to generate connection string'} {generatedConnectionString || '填写主机、用户名、密码后自动生成连接串'}
</code> </code>
</div> </div>
)} )}
@@ -2559,12 +2897,15 @@ export default function Settings() {
</div> </div>
{runtimeDatabaseState && ( {runtimeDatabaseState && (
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8, marginBottom: migrationSummary ? 12 : 0 }}> <div style={{ ...settingsModernFieldCardStyle, marginBottom: migrationSummary ? 12 : 0 }}>
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' }){runtimeDatabaseState.active.ssl && ' [SSL]'}</div> <div style={settingsModernFieldLabelStyle}>当前数据库状态</div>
<div> <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '未配置'}){runtimeDatabaseState.active.ssl && '(已启用 SSL)'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
已保存待生效: 已保存待生效:
{runtimeDatabaseState.saved {runtimeDatabaseState.saved
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? ' [SSL]' : ''}` ? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? '(已启用 SSL)' : ''}`
: ' 未保存'} : ' 未保存'}
</div> </div>
{runtimeDatabaseState.restartRequired && ( {runtimeDatabaseState.restartRequired && (
@@ -2582,9 +2923,10 @@ export default function Settings() {
)} )}
</div> </div>
<div id="settings-maintenance" style={settingsModernSectionStyle}>
<UpdateCenterSection /> <UpdateCenterSection />
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-6" style={settingsModernCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={handleClearCache} disabled={clearingCache} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}> <button onClick={handleClearCache} disabled={clearingCache} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
@@ -2596,7 +2938,7 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}> <div className="card animate-slide-up stagger-7" style={settingsModernDangerCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。 重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
@@ -2609,7 +2951,9 @@ export default function Settings() {
</button> </button>
</div> </div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}> </div>
<div id="settings-security" className="card animate-slide-up stagger-7" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。 登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
@@ -2643,6 +2987,7 @@ export default function Settings() {
</div> </div>
</div> </div>
</div> </div>
</div>
<FactoryResetModal <FactoryResetModal
presence={factoryResetPresence} presence={factoryResetPresence}
factoryResetting={factoryResetting} factoryResetting={factoryResetting}
+56 -13
View File
@@ -95,6 +95,16 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
}; };
const DESKTOP_DETAIL_ENTER_MS = 260; const DESKTOP_DETAIL_ENTER_MS = 260;
const DESKTOP_DETAIL_COLLAPSE_MS = 200; const DESKTOP_DETAIL_COLLAPSE_MS = 200;
const ROUTE_CANDIDATE_PRELOAD_DELAY_MS = 1600;
function scheduleRouteIdleWork(callback: () => void, timeout = 1200): () => void {
if (typeof requestIdleCallback === 'function') {
const idleId = requestIdleCallback(callback, { timeout });
return () => cancelIdleCallback(idleId);
}
const timer = globalThis.setTimeout(callback, Math.min(timeout, 300));
return () => globalThis.clearTimeout(timer);
}
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return typeof globalThis.matchMedia === 'function' return typeof globalThis.matchMedia === 'function'
@@ -178,6 +188,28 @@ export function DesktopDetailPanelPresence({
); );
} }
function RouteListSkeleton() {
return (
<div className="route-loading-skeleton" aria-label={tr('正在加载路由')}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={`route-skeleton-${index}`} className="route-loading-card">
<div className="route-loading-card-head">
<div className="skeleton route-loading-icon" />
<div>
<div className="skeleton route-loading-title" />
<div className="skeleton route-loading-subtitle" />
</div>
</div>
<div className="route-loading-lines">
<div className="skeleton" />
<div className="skeleton" />
</div>
</div>
))}
</div>
);
}
export default function TokenRoutes() { export default function TokenRoutes() {
const navigate = useNavigate(); const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]); const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -202,6 +234,7 @@ export default function TokenRoutes() {
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM); const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null); const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [rebuilding, setRebuilding] = useState(false); const [rebuilding, setRebuilding] = useState(false);
const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false); const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false);
const [batchSelectMode, setBatchSelectMode] = useState(false); const [batchSelectMode, setBatchSelectMode] = useState(false);
@@ -381,20 +414,26 @@ export default function TokenRoutes() {
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
(async () => { let cancelCandidatePreload: (() => void) | null = null;
const preloadTimer = globalThis.setTimeout(() => {
cancelCandidatePreload = scheduleRouteIdleWork(() => loadCandidates(), 1800);
}, ROUTE_CANDIDATE_PRELOAD_DELAY_MS);
void resumeRouteDecisionRefreshTask();
void (async () => {
try { try {
await resumeRouteDecisionRefreshTask();
await load(); await load();
} catch { } catch {
toast.error('加载路由配置失败'); toast.error('加载路由配置失败');
} finally {
if (mountedRef.current) setInitialLoading(false);
} }
// Preload candidates in background after first paint
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
scheduleIdle(() => loadCandidates());
})(); })();
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
decisionRefreshWatchSeqRef.current += 1; decisionRefreshWatchSeqRef.current += 1;
globalThis.clearTimeout(preloadTimer);
cancelCandidatePreload?.();
}; };
}, [resumeRouteDecisionRefreshTask, toast]); }, [resumeRouteDecisionRefreshTask, toast]);
@@ -1628,8 +1667,8 @@ export default function TokenRoutes() {
</button> </button>
</div> </div>
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}> <span className="badge badge-info route-count-badge" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
{tr('共')} {filteredRoutes.length} {tr('条路由')} {initialLoading ? tr('正在加载路由') : `${tr('共')} ${filteredRoutes.length} ${tr('条路由')}`}
</span> </span>
</div> </div>
@@ -1747,8 +1786,11 @@ export default function TokenRoutes() {
</div> </div>
)} )}
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}> {initialLoading ? (
{visibleRoutes.map((route) => { <RouteListSkeleton />
) : (
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id); const isExpanded = expandedRouteIds.includes(route.id);
const isDesktopDetailClosing = closingDesktopDetailRouteIds.includes(route.id); const isDesktopDetailClosing = closingDesktopDetailRouteIds.includes(route.id);
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true; const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
@@ -1996,10 +2038,11 @@ export default function TokenRoutes() {
{detailPanel} {detailPanel}
</Fragment> </Fragment>
); );
})} })}
</div> </div>
)}
{shouldShowLoadMore && ( {!initialLoading && shouldShowLoadMore && (
<div <div
ref={loadMoreSentinelRef} ref={loadMoreSentinelRef}
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }} style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
@@ -2008,7 +2051,7 @@ export default function TokenRoutes() {
</div> </div>
)} )}
{filteredRoutes.length === 0 && ( {!initialLoading && filteredRoutes.length === 0 && (
<div className="card"> <div className="card">
<div className="empty-state"> <div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+83 -75
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { commitSession } from '../authSession.js'; import { commitSession } from '../authSession.js';
import LoginShell from './auth/LoginShell.js'; import LoginShell from './auth/LoginShell.js';
import OAuthSlot from './auth/OAuthSlot.js';
export default function UserRegister({ onLogin }: { onLogin?: () => void }) { export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -95,110 +96,117 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
t={(text) => text} t={(text) => text}
variant="user" variant="user"
eyebrow="用户注册" eyebrow="用户注册"
title="创建账号" title="创建 BossAI 账号"
description="注册后即可使用 API 代理服务。" description="完成验证后会自动进入个人工作台,并生成你的默认 API Key。"
error={error} error={error}
footer={( footer={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}> <div className="login-auth-footer-links">
<span> <span>
已有账号? 已有账号?
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}> <Link to="/user/login">
登录 登录
</Link> </Link>
</span> </span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}> <span>
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link> <Link to="/boos">管理员登录</Link>
</span> </span>
</div> </div>
)} )}
> >
<OAuthSlot t={(text) => text} variant="user" mode="register" />
<form onSubmit={handleSubmit} className="login-register-form"> <form onSubmit={handleSubmit} className="login-register-form">
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label> <div className="login-register-grid">
<input <div className="login-field-group">
id="reg-username-input" <label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
type="text" <input
placeholder="你的昵称" id="reg-username-input"
value={username} type="text"
onChange={(e) => { setUsername(e.target.value); setError(''); }} placeholder="你的昵称"
className="login-auth-input" value={username}
autoComplete="username" onChange={(e) => { setUsername(e.target.value); setError(''); }}
required className="login-auth-input"
/> autoComplete="username"
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label> required
<input />
id="reg-email-input" </div>
type="email" <div className="login-field-group">
placeholder="your@email.com" <label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
value={email} <input
onChange={(e) => { setEmail(e.target.value); setError(''); }} id="reg-email-input"
className="login-auth-input" type="email"
autoComplete="email" placeholder="your@email.com"
required value={email}
/> onChange={(e) => { setEmail(e.target.value); setError(''); }}
<label className="login-auth-label" htmlFor="reg-password-input">密码</label> className="login-auth-input"
<input autoComplete="email"
id="reg-password-input" required
type="password" />
placeholder="至少 6 位" </div>
value={password} <div className="login-field-group">
onChange={(e) => { setPassword(e.target.value); setError(''); }} <label className="login-auth-label" htmlFor="reg-password-input">密码</label>
className="login-auth-input" <input
autoComplete="new-password" id="reg-password-input"
required type="password"
/> placeholder="至少 6 位"
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label> value={password}
<input onChange={(e) => { setPassword(e.target.value); setError(''); }}
id="reg-confirm-password-input" className="login-auth-input"
type="password" autoComplete="new-password"
placeholder="再次输入密码" required
value={confirmPassword} />
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }} </div>
className="login-auth-input" <div className="login-field-group">
autoComplete="new-password" <label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
required <input
/> id="reg-confirm-password-input"
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label> type="password"
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}> placeholder="再次输入密码"
<input value={confirmPassword}
id="reg-captcha-input" onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
type="text" className="login-auth-input"
placeholder="输入右侧字符" autoComplete="new-password"
value={captchaCode} required
onChange={(e) => { setCaptchaCode(e.target.value); setError(''); }} />
className="login-auth-input" </div>
autoComplete="off" </div>
inputMode="text" <div className="login-captcha-panel">
maxLength={8} <div className="login-field-group">
required <label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
/> <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
/>
</div>
<button <button
type="button" type="button"
className="login-captcha-card"
onClick={() => void loadCaptcha()} onClick={() => void loadCaptcha()}
disabled={captchaLoading || loading} disabled={captchaLoading || loading}
aria-label="刷新图像验证码" aria-label="刷新图像验证码"
title="点击刷新验证码" 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 ? ( {captchaImage && !captchaLoading ? (
<img <img
src={captchaImage} src={captchaImage}
alt="图像验证码" alt="图像验证码"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
draggable={false} draggable={false}
/> />
) : ( ) : (
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span> <span>加载中...</span>
)} )}
</button> </button>
</div> </div>
<div className="login-register-hint">
注册会受到验证码和频率限制保护,避免批量注册影响服务稳定性。
</div>
<button <button
type="submit" type="submit"
disabled={ disabled={
+22 -7
View File
@@ -331,19 +331,34 @@ export default function Workspace() {
return ( return (
<div className="workspace-page animate-fade-in"> <div className="workspace-page animate-fade-in">
<div className="page-header"> <section className="console-hero workspace-hero">
<div> <div className="console-hero-copy">
<h1 className="page-title">工作空间</h1> <span className="console-kicker">Workspace</span>
<p className="page-subtitle"> <h1>工作空间</h1>
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'},只归档最终产出。工作流上传素材和预置模板资源不会进入这里。 <p>
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'}。这里只保留最终交付文件,工作流上传素材和预置模板资源不会污染资产库。
</p> </p>
</div> </div>
<div className="page-actions"> <div className="console-hero-rail" aria-label="工作空间摘要">
<div>
<span>当前结果</span>
<strong>{total}</strong>
</div>
<div>
<span>本地文件</span>
<strong>{localCount}</strong>
</div>
<div>
<span>已选择</span>
<strong>{selectedIds.length}</strong>
</div>
</div>
<div className="console-hero-actions">
<button type="button" className="btn btn-ghost" onClick={() => void load()} disabled={loading}> <button type="button" className="btn btn-ghost" onClick={() => void load()} disabled={loading}>
{loading ? '刷新中...' : '刷新'} {loading ? '刷新中...' : '刷新'}
</button> </button>
</div> </div>
</div> </section>
<div className="workspace-summary"> <div className="workspace-summary">
<div className="workspace-summary-item"> <div className="workspace-summary-item">
+24 -9
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import MotionDiv from '../../components/AppMotion.js';
export type LoginShellVariant = 'admin' | 'user'; export type LoginShellVariant = 'admin' | 'user';
@@ -16,9 +17,15 @@ interface LoginShellProps {
} }
const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [ const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
{ titleKey: '统一代理网关', descKey: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' }, { titleKey: '统一 API 网关', descKey: 'OpenAI / Claude / 图像 / 视频模型统一接入、统一计费' },
{ titleKey: '自动模型发现', descKey: '上游新增模型自动出现在模型列表,零配置路由生成' }, { titleKey: '工作流生产台', descKey: '连接 Comfy Cloud、RunningHub 与自有 API,把复杂流程产品化' },
{ titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率自动选择最优通道,故障自动转移' }, { titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率选择通道,失败自动切换' },
];
const LOGIN_METRICS: Array<{ valueKey: string; labelKey: string }> = [
{ valueKey: '99.9%', labelKey: '网关可用性目标' },
{ valueKey: '4+', labelKey: '多模态生产入口' },
{ valueKey: '/v1', labelKey: '统一兼容入口' },
]; ];
export default function LoginShell({ export default function LoginShell({
@@ -33,7 +40,7 @@ export default function LoginShell({
}: LoginShellProps) { }: LoginShellProps) {
return ( return (
<div className="login-shell" data-variant={variant}> <div className="login-shell" data-variant={variant}>
<div className="login-surface animate-scale-in"> <MotionDiv className="login-surface animate-scale-in" motionVariant="login" motionKey={variant}>
<section <section
className="login-brand-panel login-brand-panel-light" className="login-brand-panel login-brand-panel-light"
style={variant === 'user' ? { padding: '28px 24px' } : undefined} style={variant === 'user' ? { padding: '28px 24px' } : undefined}
@@ -51,9 +58,9 @@ export default function LoginShell({
className="login-brand-summary" className="login-brand-summary"
style={variant === 'user' ? { textAlign: 'center' } : undefined} style={variant === 'user' ? { textAlign: 'center' } : undefined}
> >
<div className="login-brand-name">BoosAPI</div> <div className="login-brand-name">BossAI</div>
<div className="login-brand-kicker"> <div className="login-brand-kicker">
{variant === 'user' ? t('用户登录') : t('中转站的中转站')} {variant === 'user' ? t('创作者工作台') : t('多模型多模态 AI 应用平台')}
</div> </div>
</div> </div>
</div> </div>
@@ -62,11 +69,19 @@ export default function LoginShell({
<> <>
<div className="login-brand-copy-block"> <div className="login-brand-copy-block">
<p className="login-brand-copy"> <p className="login-brand-copy">
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')} {t('聚合对话、生图、视频、数字人与工作流编排,把 API 中转、模型路由和内容生产放进一套可运营的平台。')}
</p> </p>
</div> </div>
<div className="login-compat-line"> <div className="login-compat-line">
{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')} {t('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游')}
</div>
<div className="login-metric-grid" aria-label={t('平台能力指标')}>
{LOGIN_METRICS.map((metric) => (
<div key={metric.labelKey} className="login-metric-card">
<strong>{t(metric.valueKey)}</strong>
<span>{t(metric.labelKey)}</span>
</div>
))}
</div> </div>
<div className="login-capability-list"> <div className="login-capability-list">
{ADMIN_CAPABILITY_KEYS.map((feature, index) => ( {ADMIN_CAPABILITY_KEYS.map((feature, index) => (
@@ -98,7 +113,7 @@ export default function LoginShell({
{footer && <div className="login-auth-footer">{footer}</div>} {footer && <div className="login-auth-footer">{footer}</div>}
</div> </div>
</section> </section>
</div> </MotionDiv>
</div> </div>
); );
} }
+34 -16
View File
@@ -3,6 +3,7 @@ import React from 'react';
export interface OAuthSlotProps { export interface OAuthSlotProps {
t: (text: string) => string; t: (text: string) => string;
variant?: 'user'; variant?: 'user';
mode?: 'login' | 'register';
} }
/** /**
@@ -15,27 +16,44 @@ export interface OAuthSlotProps {
* This component intentionally performs no network I/O, no navigation, and * This component intentionally performs no network I/O, no navigation, and
* does not touch `authSession`. Tests assert that property. * does not touch `authSession`. Tests assert that property.
*/ */
export default function OAuthSlot({ t }: OAuthSlotProps) { const OAUTH_PROVIDER_KEYS = [
{ key: 'google', label: 'Google' },
{ key: 'github', label: 'GitHub' },
{ key: 'wechat', label: '微信' },
];
export default function OAuthSlot({ t, mode = 'login' }: OAuthSlotProps) {
const title = mode === 'register' ? t('第三方账号快速注册') : t('第三方登录');
const desc = mode === 'register'
? t('OAuth 接入后可自动创建账号并绑定同一用户身份。')
: t('即将支持配置后的 OAuth 提供商。');
return ( return (
<div <div
data-testid="oauth-slot" data-testid="oauth-slot"
className="login-oauth-slot" className="login-oauth-slot"
style={{ data-mode={mode}
marginTop: 16,
padding: '12px 0',
borderTop: '1px dashed var(--color-border)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: 'var(--color-text-muted)',
}}
> >
<span>{t('第三方登录')}</span> <div className="login-oauth-divider">
<span style={{ fontSize: 11 }}> <span>{title}</span>
{t('即将上线,敬请期待')} </div>
</span> <div className="login-oauth-provider-row" aria-label={title}>
{OAUTH_PROVIDER_KEYS.map((provider) => (
<button
key={provider.key}
type="button"
className="login-oauth-provider"
disabled
aria-disabled="true"
data-provider={provider.key}
title={t('等待管理员配置 OAuth 后启用')}
>
<span className="login-oauth-provider-mark">{provider.label.slice(0, 1)}</span>
<span>{provider.label}</span>
</button>
))}
</div>
<p className="login-oauth-desc">{desc}</p>
</div> </div>
); );
} }
@@ -8,7 +8,7 @@ describe('Dashboard mobile layout', () => {
expect(source).toContain('import { useIsMobile } from "../components/useIsMobile.js";'); expect(source).toContain('import { useIsMobile } from "../components/useIsMobile.js";');
expect(source).toContain('const isMobile = useIsMobile()'); expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr"'); expect(source).toContain('className="dashboard-chart-grid"');
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 300px"'); expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 300px"');
}); });
}); });
@@ -90,7 +90,7 @@ describe('modelTesterSession', () => {
name: 'draft.pdf', name: 'draft.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0x', dataUrl: 'data:application/pdf;base64,JVBERi0x',
fileId: 'file-metapi-draft', fileId: 'file-boosapi-draft',
status: 'uploaded', status: 'uploaded',
errorMessage: null, errorMessage: null,
}, },
@@ -243,7 +243,7 @@ describe('modelTesterSession', () => {
it('creates user conversation messages that preserve uploaded file references', () => { it('creates user conversation messages that preserve uploaded file references', () => {
const message = createConversationUserMessage('请总结附件', [ const message = createConversationUserMessage('请总结附件', [
{ {
fileId: 'file-metapi-123', fileId: 'file-boosapi-123',
filename: 'paper.pdf', filename: 'paper.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
}, },
@@ -254,7 +254,7 @@ describe('modelTesterSession', () => {
expect(message.parts).toEqual([ expect(message.parts).toEqual([
{ {
type: 'input_file', type: 'input_file',
fileId: 'file-metapi-123', fileId: 'file-boosapi-123',
filename: 'paper.pdf', filename: 'paper.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
}, },
@@ -295,7 +295,7 @@ describe('modelTesterSession', () => {
parts: [ parts: [
{ {
type: 'input_file', type: 'input_file',
fileId: 'file-metapi-456', fileId: 'file-boosapi-456',
filename: 'appendix.txt', filename: 'appendix.txt',
mimeType: 'text/plain', mimeType: 'text/plain',
}, },
@@ -304,7 +304,7 @@ describe('modelTesterSession', () => {
expect(files).toEqual([ expect(files).toEqual([
{ {
fileId: 'file-metapi-456', fileId: 'file-boosapi-456',
filename: 'appendix.txt', filename: 'appendix.txt',
mimeType: 'text/plain', mimeType: 'text/plain',
}, },
@@ -322,7 +322,7 @@ describe('modelTesterSession', () => {
const files = await resolveConversationReplayFiles?.( const files = await resolveConversationReplayFiles?.(
[ [
{ {
fileId: 'file-metapi-123', fileId: 'file-boosapi-123',
filename: 'brief.pdf', filename: 'brief.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
}, },
@@ -331,10 +331,10 @@ describe('modelTesterSession', () => {
loader, loader,
); );
expect(loader).toHaveBeenCalledWith('file-metapi-123'); expect(loader).toHaveBeenCalledWith('file-boosapi-123');
expect(files).toEqual([ expect(files).toEqual([
{ {
fileId: 'file-metapi-123', fileId: 'file-boosapi-123',
filename: 'brief.pdf', filename: 'brief.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x', data: 'data:application/pdf;base64,JVBERi0x',
@@ -564,7 +564,7 @@ describe('modelTesterSession', () => {
parts: [ parts: [
{ {
type: 'input_file' as const, type: 'input_file' as const,
fileId: 'file-metapi-123', fileId: 'file-boosapi-123',
filename: 'brief.pdf', filename: 'brief.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0x', data: 'data:application/pdf;base64,JVBERi0x',
@@ -592,7 +592,7 @@ describe('modelTesterSession', () => {
{ {
type: 'file', type: 'file',
file: { file: {
file_id: 'file-metapi-123', file_id: 'file-boosapi-123',
filename: 'brief.pdf', filename: 'brief.pdf',
mime_type: 'application/pdf', mime_type: 'application/pdf',
}, },
@@ -625,7 +625,7 @@ describe('modelTesterSession', () => {
{ type: 'input_text', text: 'reuse native file id' }, { type: 'input_text', text: 'reuse native file id' },
{ {
type: 'input_file', type: 'input_file',
file_id: 'file-metapi-123', file_id: 'file-boosapi-123',
filename: 'brief.pdf', filename: 'brief.pdf',
}, },
], ],
@@ -105,7 +105,7 @@ describe('Settings model availability probe confirmation', () => {
const toggleLabel = root.root.find((node) => ( const toggleLabel = root.root.find((node) => (
node.type === 'label' node.type === 'label'
&& collectText(node).includes('允许 metapi 后台主动批量测活') && collectText(node).includes('允许 BoosAPI 后台主动批量测活')
)); ));
const toggle = toggleLabel.findByType('input'); const toggle = toggleLabel.findByType('input');
expect(toggle.props.checked).toBe(false); expect(toggle.props.checked).toBe(false);
@@ -109,7 +109,7 @@ describe('Settings proxy transport', () => {
const websocketToggleLabel = root.root.find((node) => ( const websocketToggleLabel = root.root.find((node) => (
node.type === 'label' node.type === 'label'
&& collectText(node).includes('允许 metapi 到 Codex 上游使用 WebSocket') && collectText(node).includes('允许 BoosAPI 到 Codex 上游使用 WebSocket')
)); ));
const websocketToggle = websocketToggleLabel.findByType('input'); const websocketToggle = websocketToggleLabel.findByType('input');
expect(websocketToggle.props.checked).toBe(false); expect(websocketToggle.props.checked).toBe(false);
+16 -15
View File
@@ -264,19 +264,19 @@ describe('Sites create redirect', () => {
const platformSelect = selects.at(-1); const platformSelect = selects.at(-1);
expect(platformSelect?.props.options).toEqual( expect(platformSelect?.props.options).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ label: '阿里云 CodingPlan / OpenAI' }), expect.objectContaining({ label: '阿里云 Coding Plan (OpenAI)' }),
expect.objectContaining({ label: '阿里云 CodingPlan / Claude' }), expect.objectContaining({ label: '阿里云 Coding Plan (Claude)' }),
expect.objectContaining({ label: '智谱 Coding Plan / OpenAI' }), expect.objectContaining({ label: '智谱清言 Coding Plan (OpenAI)' }),
expect.objectContaining({ label: '智谱 Coding Plan / Claude' }), expect.objectContaining({ label: '智谱清言 Coding Plan (Claude)' }),
expect.objectContaining({ label: 'DeepSeek / OpenAI' }), expect.objectContaining({ label: 'DeepSeek (OpenAI)' }),
expect.objectContaining({ label: 'DeepSeek / Claude' }), expect.objectContaining({ label: 'DeepSeek (Claude)' }),
expect.objectContaining({ label: 'Moonshot(Kimi) / OpenAI' }), expect.objectContaining({ label: 'Moonshot (OpenAI)' }),
expect.objectContaining({ label: 'Moonshot(Kimi) / Claude' }), expect.objectContaining({ label: 'Moonshot (Claude)' }),
expect.objectContaining({ label: 'MiniMax / OpenAI' }), expect.objectContaining({ label: 'MiniMax (OpenAI)' }),
expect.objectContaining({ label: 'MiniMax / Claude' }), expect.objectContaining({ label: 'MiniMax (Claude)' }),
expect.objectContaining({ label: 'ModelScope / OpenAI' }), expect.objectContaining({ label: 'ModelScope (OpenAI)' }),
expect.objectContaining({ label: 'ModelScope / Claude' }), expect.objectContaining({ label: 'ModelScope (Claude)' }),
expect.objectContaining({ label: '豆包 Coding Plan / OpenAI' }), expect.objectContaining({ label: '豆包 Coding Plan (OpenAI)' }),
]), ]),
); );
} finally { } finally {
@@ -366,11 +366,12 @@ describe('Sites create redirect', () => {
await flushMicrotasks(); await flushMicrotasks();
expect(urlInput.props.value).toBe('https://open.bigmodel.cn/api/coding/paas/v4'); expect(urlInput.props.value).toBe('https://open.bigmodel.cn/api/coding/paas/v4');
expect(JSON.stringify(root.toJSON())).toContain('智谱 Coding Plan / OpenAI'); expect(JSON.stringify(root.toJSON())).toContain('智谱清言 Coding Plan (OpenAI)');
const presetAlerts = root.root.findAll((node) => ( const presetAlerts = root.root.findAll((node) => (
typeof node.props.className === 'string' typeof node.props.className === 'string'
&& node.props.className.includes('alert alert-info') && node.props.className.includes('alert alert-info')
&& collectText(node).includes('已应用官方预设')
)); ));
expect(presetAlerts).toHaveLength(1); expect(presetAlerts).toHaveLength(1);
expect(collectText(presetAlerts[0]!)).toContain('已应用官方预设'); expect(collectText(presetAlerts[0]!)).toContain('已应用官方预设');
@@ -645,7 +646,7 @@ describe('Sites create redirect', () => {
)); ));
expect(platformSelect?.props.value).toBe('preset:deepseek-openai'); expect(platformSelect?.props.value).toBe('preset:deepseek-openai');
expect(collectText(presetAlert)).toContain('已应用官方预设'); expect(collectText(presetAlert)).toContain('已应用官方预设');
expect(collectText(presetAlert)).toContain('DeepSeek / OpenAI'); expect(collectText(presetAlert)).toContain('DeepSeek (OpenAI)');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../../../shared/tokenRoutePatterns.js'; } from '../../../shared/tokenRoutePatterns.js';
export const AUTO_ROUTE_DECISION_LIMIT = 80; export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40; export const ROUTE_RENDER_CHUNK = 24;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:'; export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__'; export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__';
+22
View File
@@ -0,0 +1,22 @@
# BossAI Website
Standalone public website for BossAI.
## Stack
- Next.js 16 App Router
- React 19
- TypeScript
- Tailwind CSS 4
- Custom SVG and CSS motion, no animation runtime dependency
## Commands
```bash
npm install
npm run dev
npm run typecheck
npm run build
```
The Rust/Vite management console stays in the repository root. This folder is the isolated marketing website.
+325
View File
@@ -0,0 +1,325 @@
"use client";
import { type ReactNode, useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { CustomEase } from "gsap/CustomEase";
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(useGSAP, ScrollTrigger, ScrollToPlugin, SplitText, CustomEase);
const bossEase = CustomEase.create("boss-ease", "M0,0 C0.18,0.84 0.32,1 1,1");
type BossAiMotionProps = {
children: ReactNode;
};
export function BossAiMotion({ children }: BossAiMotionProps) {
const scopeRef = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const root = scopeRef.current;
if (!root) {
return;
}
gsap.set("[data-motion]", { autoAlpha: 1 });
gsap.set("[data-motion-line]", { transformOrigin: "center" });
const matchMedia = gsap.matchMedia();
matchMedia.add("(prefers-reduced-motion: reduce)", () => {
gsap.set("[data-motion], [data-motion-dot], [data-motion-orbit], [data-motion-line]", {
autoAlpha: 1,
clearProps: "transform",
});
});
matchMedia.add("(prefers-reduced-motion: no-preference)", () => {
const heroTitle = root.querySelector<HTMLElement>(".hero-copy h1");
const splitTitle = heroTitle
? SplitText.create(heroTitle, {
type: "words",
aria: "auto",
wordsClass: "hero-word",
})
: null;
const intro = gsap.timeline({ defaults: { ease: bossEase } });
intro
.from("[data-motion='nav']", { autoAlpha: 0, y: -18, duration: 0.7 })
.from(".section-kicker", { autoAlpha: 0, y: 18, duration: 0.56 }, "-=0.18")
.from(
splitTitle?.words ?? ".hero-copy h1",
{
autoAlpha: 0,
yPercent: 92,
rotateX: -18,
transformOrigin: "50% 100%",
duration: 0.82,
stagger: { each: 0.035, from: "start" },
},
"-=0.28",
)
.from(".hero-copy p", { autoAlpha: 0, y: 22, duration: 0.7 }, "-=0.5")
.from(".hero-actions > *", { autoAlpha: 0, y: 14, duration: 0.46, stagger: 0.08 }, "-=0.42")
.from(".hero-proof > div", { autoAlpha: 0, y: 16, duration: 0.5, stagger: 0.07 }, "-=0.28")
.from("[data-motion='hero-console']", { autoAlpha: 0, y: 34, rotateX: 5, duration: 0.9 }, "-=0.78")
.from("[data-motion='route-row']", { autoAlpha: 0, x: 24, duration: 0.55, stagger: 0.08 }, "-=0.48")
.from("[data-motion='node']", { autoAlpha: 0, scale: 0.94, duration: 0.45, stagger: 0.08 }, "-=0.3");
gsap.to("[data-motion-orbit]", {
rotate: 360,
duration: 28,
ease: "none",
repeat: -1,
});
gsap.to("[data-motion-dot]", {
scale: 1.18,
autoAlpha: 0.72,
duration: 1.4,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
stagger: 0.24,
});
gsap.to("[data-motion-line]", {
strokeDashoffset: -160,
duration: 5.8,
ease: "none",
repeat: -1,
stagger: 0.28,
});
const heroSection = root.querySelector<HTMLElement>(".hero-section");
const heroConsole = root.querySelector<HTMLElement>(".hero-console");
const routeRows = gsap.utils.toArray<HTMLElement>(".route-row", root);
const consoleTiles = gsap.utils.toArray<HTMLElement>(".console-metrics > div, .mini-grid > div", root);
if (heroSection && heroConsole) {
gsap.set(heroConsole, {
"--scan-y": "12%",
"--scan-opacity": 0.16,
"--grid-opacity": 0.16,
} as gsap.TweenVars);
gsap.to(heroConsole, {
yPercent: -4,
scale: 0.985,
ease: "none",
scrollTrigger: {
trigger: heroSection,
start: "top top",
end: "bottom top",
scrub: 0.85,
},
});
gsap.to(heroConsole, {
"--scan-y": "88%",
"--scan-opacity": 0.38,
"--grid-opacity": 0.34,
ease: "none",
scrollTrigger: {
trigger: heroSection,
start: "top 18%",
end: "bottom 18%",
scrub: 0.7,
},
} as gsap.TweenVars);
gsap.to(routeRows, {
x: (index) => (index % 2 === 0 ? -4 : 4),
y: (index) => -2 - index * 0.9,
autoAlpha: (index) => 1 - index * 0.015,
ease: "none",
stagger: 0.03,
scrollTrigger: {
trigger: heroConsole,
start: "top 76%",
end: "bottom 18%",
scrub: true,
},
});
gsap.to(consoleTiles, {
y: -12,
autoAlpha: 0.9,
ease: "none",
stagger: { each: 0.035, from: "end" },
scrollTrigger: {
trigger: heroConsole,
start: "top 72%",
end: "bottom 24%",
scrub: true,
},
});
}
gsap.utils.toArray<HTMLElement>("[data-motion='section']").forEach((section) => {
gsap.from(section, {
autoAlpha: 0,
y: 42,
duration: 0.82,
ease: bossEase,
scrollTrigger: {
trigger: section,
start: "top 82%",
once: true,
},
});
});
gsap.utils
.toArray<HTMLElement>("[data-motion='card']")
.filter((card) => !card.closest(".hero-section"))
.forEach((card, index) => {
gsap.from(card, {
autoAlpha: 0,
y: 28,
scale: 0.97,
duration: 0.62,
delay: (index % 4) * 0.04,
ease: bossEase,
scrollTrigger: {
trigger: card,
start: "top 88%",
once: true,
},
});
});
const cleanupAnchors = setupSmoothAnchors(root);
const cleanupActiveNav = setupActiveNavigation(root);
const cleanupMagneticCards = window.matchMedia("(hover: hover) and (pointer: fine)").matches
? setupMagneticCards(root)
: () => undefined;
return () => {
cleanupMagneticCards();
cleanupActiveNav();
cleanupAnchors();
splitTitle?.revert();
};
});
return () => matchMedia.revert();
},
{ scope: scopeRef },
);
return (
<div ref={scopeRef} className="motion-root">
{children}
</div>
);
}
function setupSmoothAnchors(root: HTMLElement) {
const anchors = gsap.utils.toArray<HTMLAnchorElement>("a[href^='#']", root);
const cleanups = anchors.map((anchor) => {
const onClick = (event: MouseEvent) => {
const href = anchor.getAttribute("href");
const targetId = href?.slice(1);
const target = targetId ? document.getElementById(targetId) : null;
if (!href || !target) {
return;
}
event.preventDefault();
window.history.pushState(null, "", href);
gsap.to(window, {
duration: 0.92,
ease: bossEase,
overwrite: "auto",
scrollTo: { y: target, offsetY: 22 },
});
};
anchor.addEventListener("click", onClick);
return () => anchor.removeEventListener("click", onClick);
});
return () => cleanups.forEach((cleanup) => cleanup());
}
function setupActiveNavigation(root: HTMLElement) {
const links = gsap.utils.toArray<HTMLAnchorElement>(".nav-links a[href^='#']", root);
const linkById = new Map(
links
.map((link) => [link.getAttribute("href")?.slice(1), link] as const)
.filter((entry): entry is readonly [string, HTMLAnchorElement] => Boolean(entry[0])),
);
const setActive = (id: string) => {
links.forEach((link) => link.classList.toggle("is-active", linkById.get(id) === link));
};
const triggers = gsap.utils
.toArray<HTMLElement>("section[id]", root)
.filter((section) => linkById.has(section.id))
.map((section) =>
ScrollTrigger.create({
trigger: section,
start: "top 42%",
end: "bottom 42%",
onEnter: () => setActive(section.id),
onEnterBack: () => setActive(section.id),
}),
);
return () => {
triggers.forEach((trigger) => trigger.kill());
links.forEach((link) => link.classList.remove("is-active"));
};
}
function setupMagneticCards(root: HTMLElement) {
const cards = gsap.utils.toArray<HTMLElement>("[data-motion='card']", root);
const cleanups = cards.map((card) => {
const onPointerMove = (event: PointerEvent) => {
const rect = card.getBoundingClientRect();
const xRatio = (event.clientX - rect.left) / rect.width - 0.5;
const yRatio = (event.clientY - rect.top) / rect.height - 0.5;
gsap.to(card, {
x: xRatio * 10,
y: yRatio * 10,
rotateX: yRatio * -3,
rotateY: xRatio * 4,
duration: 0.38,
ease: "power3.out",
overwrite: "auto",
});
};
const onPointerLeave = () => {
gsap.to(card, {
x: 0,
y: 0,
rotateX: 0,
rotateY: 0,
duration: 0.58,
ease: bossEase,
overwrite: "auto",
});
};
card.addEventListener("pointermove", onPointerMove);
card.addEventListener("pointerleave", onPointerLeave);
return () => {
card.removeEventListener("pointermove", onPointerMove);
card.removeEventListener("pointerleave", onPointerLeave);
};
});
return () => cleanups.forEach((cleanup) => cleanup());
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geist = Geist({
variable: "--font-geist",
subsets: ["latin"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "BossAI | 聚合多模型多模态的 AI 应用平台",
description:
"BossAI 聚合 API 网关、模型路由、Comfy 工作流、短视频工厂和数字人生产,让团队统一管理多模型多模态 AI 应用。",
applicationName: "BossAI",
icons: {
icon: "/favicon.png",
shortcut: "/favicon-64.png",
},
openGraph: {
title: "BossAI | 聚合多模型多模态的 AI 应用平台",
description:
"统一 API 网关、智能路由、工作流画布和创作应用,让团队管理对话、生图、视频和数字人生产。",
type: "website",
locale: "zh_CN",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
colorScheme: "dark",
themeColor: "#07110f",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={`${geist.variable} ${geistMono.variable}`}>{children}</body>
</html>
);
}
+491
View File
@@ -0,0 +1,491 @@
import Image from "next/image";
import Link from "next/link";
import { BossAiMotion } from "./BossAiMotion";
const platformItems = [
{
title: "统一 API 网关",
eyebrow: "Gateway",
body: "OpenAI、Claude Messages、Gemini、方舟、图像、视频和语音接口统一到一套入口。",
metric: "/v1",
},
{
title: "智能模型路由",
eyebrow: "Routing",
body: "按站点、账号、令牌、模型能力和失败冷却自动调度,保留真实日志与计费口径。",
metric: "Auto",
},
{
title: "Comfy 工作流",
eyebrow: "Workflow",
body: "保留完整版 ComfyUI 前端习惯,接入 Cloud、RunningHub、External 或 BoosAPI 执行器。",
metric: "Canvas",
},
{
title: "创作应用生产",
eyebrow: "Studio",
body: "生图助手、视频助手、数字人、短视频工厂和工作空间复用同一套模型资产。",
metric: "Media",
},
];
const heroStats = [
{ value: "/v1", label: "OpenAI 兼容入口" },
{ value: "/v1/messages", label: "Claude Messages" },
{ value: "Cloud / RH", label: "Comfy API 后端" },
];
const routes = [
{ type: "GPT", model: "gpt-5.5", state: "深度推理", tone: "hot", featured: true },
{ type: "Image", model: "gpt-image-2", state: "图像生成", tone: "lime", featured: true },
{ type: "Claude", model: "claude-opus-4-8", state: "Messages", tone: "white" },
{ type: "Gemini", model: "gemini-3-pro-preview", state: "多模态", tone: "cyan" },
{ type: "Video", model: "doubao-seedance-2-0-260128", state: "视频队列", tone: "blue" },
];
const consoleMetrics = [
{ label: "日志写入", value: "stream" },
{ label: "计费口径", value: "token + media" },
{ label: "失败冷却", value: "auto reroute" },
];
const studioFlow = [
{
step: "01",
title: "接入模型",
body: "把多个中转站、官方 API、Comfy Cloud、RunningHub 或外部 ComfyUI 接成统一能力池。",
},
{
step: "02",
title: "编排生产",
body: "在对话、生图、视频、数字人和短视频工厂里复用模型、模板、提示词和工作流。",
},
{
step: "03",
title: "归档计费",
body: "每次调用进入日志、成本、用户隔离和工作空间,只保留真正可交付的最终产出。",
},
];
const workflowModes = [
["cloud", "Comfy Cloud API 后端"],
["runninghub", "RunningHub API 后端"],
["external", "外部完整版 ComfyUI"],
["boosapi", "Rust API-native 执行器"],
];
const endpointItems = [
{
method: "POST",
path: "/v1/chat/completions",
title: "对话与 Agent",
body: "对接 OpenAI 兼容客户端,统一进入模型路由、日志和计费。",
},
{
method: "POST",
path: "/v1/messages",
title: "Claude Messages",
body: "支持 Claude 格式请求、响应和流式事件转换。",
},
{
method: "POST",
path: "/v1/images/generations",
title: "图像生成",
body: "生图助手、工作流节点和下游 API 共享同一条模型调用链路。",
},
{
method: "POST",
path: "/workflow-ui/api/prompt",
title: "Comfy 工作流",
body: "代理 Cloud、RunningHub、External 或 BoosAPI 执行器,保留前端操作习惯。",
},
];
const operations = [
{
eyebrow: "Identity",
title: "管理员与普通用户隔离",
body: "普通用户只看到自己的工作空间、任务、产出和用量;管理员可以按用户、站点和模型做运营管理。",
},
{
eyebrow: "Routing",
title: "站点、账号、密钥自动路由",
body: "下游密钥绑定可用模型后自动建立路由,失败、冷却、重试和成本记录进入统一日志。",
},
{
eyebrow: "Workspace",
title: "只归档最终产出",
body: "Workflow 上传素材和模板引用不会污染工作空间,图片、视频、音频等最终结果才进入资产库。",
},
];
const productionModules = [
"模型广场",
"使用日志",
"工作空间",
"数字人",
"短视频工厂",
"站点公告",
"下游密钥",
"PostgreSQL",
];
const controlSignals = [
"模型路由",
"Claude Messages",
"图像生成",
"视频生成",
"数字人口播",
"工作空间归档",
];
function FlowGlyph() {
return (
<svg className="flow-glyph" viewBox="0 0 520 360" aria-hidden="true">
<defs>
<linearGradient id="lineGradient" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#D7FF4D" />
<stop offset="55%" stopColor="#8BF6FF" />
<stop offset="100%" stopColor="#FFFFFF" />
</linearGradient>
<filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<path
className="flow-line flow-line-a"
data-motion-line
d="M32 110 C138 42 218 86 294 152 S414 238 496 132"
/>
<path
className="flow-line flow-line-b"
data-motion-line
d="M78 238 C170 314 248 256 314 196 S402 92 486 84"
/>
<path
className="flow-line flow-line-c"
data-motion-line
d="M92 170 C180 194 226 190 292 160 S382 150 452 236"
/>
<g filter="url(#softGlow)">
<circle className="flow-dot dot-a" data-motion-dot cx="32" cy="110" r="6" />
<circle className="flow-dot dot-b" data-motion-dot cx="294" cy="152" r="8" />
<circle className="flow-dot dot-c" data-motion-dot cx="496" cy="132" r="6" />
</g>
<rect className="node node-a" data-motion="node" x="96" y="44" width="120" height="56" rx="16" />
<rect className="node node-b" data-motion="node" x="278" y="128" width="132" height="64" rx="18" />
<rect className="node node-c" data-motion="node" x="352" y="236" width="112" height="54" rx="16" />
</svg>
);
}
function HeroConsole() {
return (
<div className="hero-console" data-motion="hero-console" aria-label="BossAI 路由状态预览">
<div className="console-orbit" data-motion-orbit />
<div className="console-bar">
<span>Model Router</span>
<strong>GPT-5.5 + Image 2 Ready</strong>
</div>
<div className="route-list">
{routes.map((route) => (
<div
className="route-row"
data-featured={route.featured ? "true" : undefined}
data-motion="route-row"
data-tone={route.tone}
key={route.type}
>
<span>{route.type}</span>
<strong>{route.model}</strong>
<em>{route.state}</em>
</div>
))}
</div>
<div className="console-metrics">
{consoleMetrics.map((metric) => (
<div data-motion="card" key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
</div>
))}
</div>
<div className="mini-grid">
<div>
<span>Workspace</span>
<strong>只归档产出</strong>
</div>
<div>
<span>Billing</span>
<strong>日志计费</strong>
</div>
</div>
<FlowGlyph />
</div>
);
}
export default function Home() {
return (
<BossAiMotion>
<main className="site-shell">
<div className="site-noise" aria-hidden="true" />
<header className="site-nav" data-motion="nav">
<Link className="brand-mark" href="/" aria-label="BossAI 首页">
<Image src="/logo.png" width={38} height={38} alt="" priority />
<span>BossAI</span>
</Link>
<nav className="nav-links" aria-label="官网导航">
<a href="#platform">平台能力</a>
<a href="#api">API</a>
<a href="#workflow">工作流</a>
<a href="#operations">运营</a>
<a href="#deploy">部署</a>
</nav>
<div className="nav-actions">
<Link className="ghost-link" href="/user/login">
用户登录
</Link>
<Link className="solid-link" href="/boos">
进入后台
</Link>
</div>
</header>
<section className="hero-section">
<div className="hero-copy" data-motion="hero-copy">
<div className="section-kicker">
<span className="pulse-dot" />
多模型 · 多模态 · 工作流
</div>
<h1>
多模型多模态
<span>AI 应用生产平台</span>
</h1>
<p>
BossAI 把 API 网关、智能路由、Comfy 工作流、数字人和短视频工厂收进一个清晰的运营后台,让团队用一套系统完成接入、生产、计费和交付。
</p>
<div className="hero-actions">
<Link className="primary-cta" href="/user/register">
开始使用
</Link>
<Link className="secondary-cta" href="#workflow">
查看工作流
</Link>
</div>
<div className="hero-proof" aria-label="BossAI 兼容能力">
{heroStats.map((stat) => (
<div data-motion="card" key={stat.label}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
))}
</div>
</div>
<HeroConsole />
</section>
<section id="platform" className="platform-section" data-motion="section">
<div className="section-heading">
<span>Platform</span>
<h2>一套后台,接住模型接入、创作生产和商业运营</h2>
</div>
<div className="capability-grid">
{platformItems.map((item, index) => (
<article
className="capability-card"
data-motion="card"
key={item.title}
style={{ ["--index" as string]: index }}
>
<div className="card-top">
<span>{item.eyebrow}</span>
<strong>{item.metric}</strong>
</div>
<h3>{item.title}</h3>
<p>{item.body}</p>
</article>
))}
</div>
</section>
<section className="studio-section" data-motion="section">
<div className="studio-copy">
<span>Operating System</span>
<h2>从模型供应到内容产出,形成一条能运营的生产线</h2>
<p>
BossAI 不只是转发请求。它把模型、用户、模板、工作流、任务队列、产出文件和成本记录串成一个可观察、可复用、可商业化的闭环。
</p>
</div>
<div className="studio-flow">
{studioFlow.map((item) => (
<article data-motion="card" key={item.step}>
<em>{item.step}</em>
<strong>{item.title}</strong>
<p>{item.body}</p>
</article>
))}
</div>
</section>
<section id="api" className="api-section" data-motion="section">
<div className="section-heading split">
<div>
<span>API Surface</span>
<h2>协议兼容不是旁路,而是整个平台的入口</h2>
</div>
<p>
对话、生图、视频和 Claude Messages 都从同一个网关进入路由、日志、计费和用户隔离。前端应用、工作流节点和外部客户端使用同一套模型资产,不再各自维护一套上游。
</p>
</div>
<div className="endpoint-grid">
{endpointItems.map((endpoint) => (
<article className="endpoint-card" data-motion="card" key={endpoint.path}>
<div className="endpoint-line">
<span>{endpoint.method}</span>
<code>{endpoint.path}</code>
</div>
<h3>{endpoint.title}</h3>
<p>{endpoint.body}</p>
</article>
))}
</div>
</section>
<section id="workflow" className="workflow-section" data-motion="section">
<div className="workflow-panel">
<div className="section-heading">
<span>Workflow</span>
<h2>使用完整版 ComfyUI 前端,后端按你的模式切换</h2>
</div>
<p>
Workflow 保留用户熟悉的 ComfyUI 前端体验。BossAI 可以代理官方 Cloud、RunningHub、外部 ComfyUI,也能执行 API-native 节点,让模板、模型和产出都进入同一套后台。
</p>
<div className="mode-list">
{workflowModes.map(([key, label]) => (
<div data-motion="card" key={key}>
<code>{key}</code>
<span>{label}</span>
</div>
))}
</div>
</div>
<div className="node-board" aria-label="工作流节点预览">
<div className="node-card wide" data-motion="card">
<span>Prompt</span>
<strong>文案、角色、镜头、商品信息</strong>
</div>
<div className="node-card" data-motion="card">
<span>Image</span>
<strong>生图</strong>
</div>
<div className="node-card" data-motion="card">
<span>Video</span>
<strong>视频</strong>
</div>
<div className="node-card" data-motion="card">
<span>Voice</span>
<strong>TTS</strong>
</div>
<div className="node-card" data-motion="card">
<span>Archive</span>
<strong>工作空间</strong>
</div>
</div>
</section>
<section id="operations" className="ops-section" data-motion="section">
<div className="ops-copy">
<span>Operations</span>
<h2>运营后台负责成本、权限、路由和产出归档</h2>
<p>
管理员看站点、账号、模型、失败率和成本;普通用户看自己的模型、任务、日志和工作空间。每个角色都有清晰边界。
</p>
</div>
<div className="ops-grid">
{operations.map((item) => (
<article className="ops-card" data-motion="card" key={item.title}>
<span>{item.eyebrow}</span>
<h3>{item.title}</h3>
<p>{item.body}</p>
</article>
))}
</div>
</section>
<section id="factory" className="factory-section" data-motion="section">
<div className="factory-copy">
<span>Creation OS</span>
<h2>从提示词到短视频,从素材到数字人</h2>
<p>
生图助手、视频助手、数字人和短视频工厂共享 BossAI 模型路由。产出文件自动归档,用户之间隔离,管理员可查看团队级用量与成本。
</p>
</div>
<div className="module-strip">
{productionModules.map((module) => (
<span data-motion="card" key={module}>
{module}
</span>
))}
</div>
</section>
<section className="control-section" data-motion="section">
<div className="control-panel">
<span>Control Plane</span>
<h2>让每一次模型调用都能被追踪、解释和计费</h2>
</div>
<div className="control-grid">
{controlSignals.map((item) => (
<div data-motion="card" key={item}>
<span />
<strong>{item}</strong>
</div>
))}
</div>
</section>
<section id="deploy" className="deploy-section" data-motion="section">
<div>
<span>Deploy</span>
<h2>远程模型优先,本机不必有 GPU</h2>
<p>
当图像、视频、语音走上游 API、Comfy Cloud 或 RunningHub 时,BossAI 服务器只负责网关、鉴权、数据库、日志、计费和 Workflow UI 代理。
</p>
</div>
<div className="deploy-stack">
<div data-motion="card">
<small>Backend</small>
<strong>Rust 二进制</strong>
</div>
<div data-motion="card">
<small>Database</small>
<strong>SQLite / PostgreSQL</strong>
</div>
<div data-motion="card">
<small>Website</small>
<strong>Next.js 独立部署</strong>
</div>
</div>
</section>
<section className="final-section" data-motion="section">
<h2>把模型、工作流和创作应用收进一个可运营的平台</h2>
<p>适合自建 AI 中转、团队模型管理、多模态内容生产、数字人商品讲解和商业化用户面板。</p>
<div className="hero-actions">
<Link className="primary-cta" href="/user/register">
开始使用
</Link>
<Link className="secondary-cta" href="/boos">
进入后台
</Link>
</div>
</section>
</main>
</BossAiMotion>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+14
View File
@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
poweredByHeader: false,
images: {
unoptimized: true,
},
turbopack: {
root: __dirname,
},
};
export default nextConfig;
+1835
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "bossai-website",
"version": "0.1.0",
"private": true,
"description": "Standalone BossAI public website built with Next.js App Router.",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"gsap": "^3.15.0",
"next": "16.2.7",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.0",
"@types/node": "^22.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"tailwindcss": "4.3.0",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=22.0.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}