Compare commits
25 Commits
5ce5ea1687
...
023384b14b
| Author | SHA1 | Date | |
|---|---|---|---|
| 023384b14b | |||
| d315f0ffe2 | |||
| f0385c9c8c | |||
| 339f91d014 | |||
| 0d231ec467 | |||
| be1fc1df48 | |||
| dd874bae30 | |||
| 45aa741d9c | |||
| 49c398db5e | |||
| 4d748dd938 | |||
| b40c522749 | |||
| d467e27fd8 | |||
| 5ef017c54e | |||
| 03046152fd | |||
| d75daf72d6 | |||
| d39100b56d | |||
| 2e4c5801b6 | |||
| 870b2a2da3 | |||
| 4448567368 | |||
| fae0f607aa | |||
| e4630c4cb6 | |||
| e72e323313 | |||
| 2b836b3e1e | |||
| 598b2f48d6 | |||
| 3f22d7b1ab |
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,8 @@ boosapi
|
|||||||
config.toml
|
config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
|
||||||
|
|
||||||
启动前检查配置:
|
启动前检查配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
];
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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'
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
})))
|
})))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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?;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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__';
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -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;
|
||||||
Generated
+1835
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user