Compare commits

85 Commits

Author SHA1 Message Date
boos aaba6f152e perf: optimize proxy streaming and database access 2026-06-07 00:00:24 +08:00
boos f3d2d50f9a fix: make website boot animation resilient 2026-06-06 22:38:31 +08:00
boos 16162095f7 style: polish admin operations pages 2026-06-06 22:19:42 +08:00
boos 6629d9d0a3 fix: stabilize console topbar layout 2026-06-06 22:09:44 +08:00
boos 3c6f061709 fix: improve assistant thinking and image progress 2026-06-06 22:02:45 +08:00
boos d7d14e077e fix: stabilize website boot loader 2026-06-06 21:54:25 +08:00
boos 3b3292b934 feat: add admin operations war room 2026-06-06 21:50:23 +08:00
boos f7916a42e5 feat: enhance website flow and user workbench 2026-06-06 21:44:58 +08:00
boos f725de28bd feat: polish website and console dashboards 2026-06-06 21:15:33 +08:00
boos 97113964c8 fix: remove sidebar identity card 2026-06-06 21:01:20 +08:00
boos b429695cba fix: stabilize topbar layout 2026-06-06 20:57:33 +08:00
boos d6bf7352a0 feat: support local avatar uploads 2026-06-06 20:52:34 +08:00
boos d800ed1f9f fix: keep admin sidebar actions visible 2026-06-06 20:44:34 +08:00
boos 4855db3fc8 feat: polish user auth entrance 2026-06-06 20:38:18 +08:00
boos 6cd0a8caca fix: stream real agent reasoning 2026-06-06 20:26:31 +08:00
boos 44dc89a0dd refactor: share console page header 2026-06-06 20:18:28 +08:00
boos 823ac3c25d fix: show only generated outputs in workspace 2026-06-06 20:03:02 +08:00
boos 5ecea182bc feat: polish console shell and notification settings 2026-06-06 19:56:56 +08:00
boos cd843465b2 feat: show conversation content in usage logs 2026-06-06 19:36:42 +08:00
boos ef0f91e683 perf: speed up app loading 2026-06-06 19:21:59 +08:00
boos 9c1e5ae293 fix: keep video assistant reasoning label stable 2026-06-06 19:02:41 +08:00
boos 00b3365f5a feat: refine website gateway os hero 2026-06-06 18:59:10 +08:00
boos d5babf5f3f fix: support website color schemes 2026-06-06 18:45:12 +08:00
boos 4b757b8250 feat: add light website intro motion 2026-06-06 18:29:10 +08:00
boos def8d7a204 chore: speed up local build workflow 2026-06-06 18:10:16 +08:00
boos 25c6fdbd6c revert website high contrast redesign 2026-06-06 17:58:10 +08:00
boos 47202e6848 redesign website with high contrast motion 2026-06-06 17:54:38 +08:00
boos 1b2880739a polish auth entry surfaces 2026-06-06 17:42:14 +08:00
boos 188e457e7f polish topbar user experience 2026-06-06 17:31:41 +08:00
boos 13756adc9d polish about page experience 2026-06-06 17:25:31 +08:00
boos f026582008 fix: persist runtime proxy token 2026-06-06 17:11:51 +08:00
boos b3eb86d5ea chore: remove update center 2026-06-06 16:55:15 +08:00
boos 301d086cb6 fix: show runtime logs in program logs 2026-06-06 16:40:55 +08:00
boos 023384b14b style: refine website section spacing 2026-06-06 16:18:11 +08:00
boos d315f0ffe2 feat: polish website model radar 2026-06-06 16:14:05 +08:00
boos f0385c9c8c chore: install vercel agent skills 2026-06-06 16:07:34 +08:00
boos 339f91d014 feat: sync upstream analytics schema support 2026-06-06 16:07:13 +08:00
boos 0d231ec467 feat: enhance website model radar motion 2026-06-06 15:46:51 +08:00
boos be1fc1df48 feat: optimize routes and registration UX 2026-06-06 15:37:37 +08:00
boos dd874bae30 feat: refresh BossAI console frontend 2026-06-06 15:25:04 +08:00
boos 45aa741d9c feat: refine BossAI console and website design 2026-06-06 15:09:53 +08:00
boos 49c398db5e feat: polish BossAI website motion 2026-06-06 14:56:33 +08:00
boos 4d748dd938 feat: refine BossAI website product story 2026-06-06 06:27:28 +08:00
boos b40c522749 feat: add gsap motion to BossAI website 2026-06-06 06:18:40 +08:00
boos d467e27fd8 fix: guard embedded frontend assets 2026-06-06 06:11:15 +08:00
boos 5ef017c54e feat: serve website at root path 2026-06-06 05:59:48 +08:00
boos 03046152fd feat: isolate BossAI website with Next.js 2026-06-06 05:30:10 +08:00
boos d75daf72d6 feat: add premium public landing page 2026-06-06 05:06:37 +08:00
boos d39100b56d feat: optimize money printer factory 2026-06-06 04:56:02 +08:00
boos 2e4c5801b6 chore: tighten repository hygiene 2026-06-06 04:40:25 +08:00
boos 870b2a2da3 feat: surface admin settings attention states 2026-06-06 03:57:15 +08:00
boos 4448567368 feat: optimize admin settings page 2026-06-06 03:49:30 +08:00
boos fae0f607aa test: restore project verification suite 2026-06-06 03:35:29 +08:00
boos e4630c4cb6 fix: harden workflow smoke issues 2026-06-06 03:23:39 +08:00
boos e72e323313 feat: refine money printer comfy template mode 2026-06-06 03:09:08 +08:00
boos 2b836b3e1e Add built-in Cloud video template fallback 2026-06-06 02:58:16 +08:00
boos 598b2f48d6 Handle Comfy Cloud video job status 2026-06-05 22:01:03 +08:00
boos 3f22d7b1ab Make short video factory use Comfy templates 2026-06-05 21:35:12 +08:00
boos 5ce5ea1687 Route Comfy workflows through video API 2026-06-03 11:39:14 +08:00
boos f8741b544e Filter MoneyPrinter Comfy video templates 2026-06-03 11:29:55 +08:00
boos c316a78511 Default MoneyPrinter videos to BoosAPI route 2026-06-03 11:03:04 +08:00
boos 68ae9acb27 Clarify bundled Picture Agent template errors 2026-06-03 11:01:47 +08:00
boos 13c7d8aa4e Remove stale Picture Agent remote template state 2026-06-03 10:59:11 +08:00
boos fb5fc9f1ae Use bundled Linux Do picture templates 2026-06-03 10:55:43 +08:00
boos 5765ea48b0 Clarify Picture Agent template refresh state 2026-06-02 17:38:54 +08:00
boos 55bd3f41f8 Follow Picture Agent gallery data URL 2026-06-02 17:30:15 +08:00
boos 938597b766 Fix Picture Agent loading fallback 2026-06-02 17:23:36 +08:00
boos fe0e3faa22 Generate MoneyPrinter Comfy prompts from form 2026-06-02 17:18:23 +08:00
boos d0f26118ff Normalize MoneyPrinter Comfy workflow templates 2026-06-02 17:11:15 +08:00
boos c98d9a2c6d Load Comfy templates for MoneyPrinter 2026-06-02 17:00:46 +08:00
boos 1293ebae68 Use Comfy API for MoneyPrinter video generation 2026-06-02 16:47:48 +08:00
boos b29c7ffa7a Add native MoneyPrinter short video factory 2026-06-02 15:26:42 +08:00
boos 056e9fb651 Improve digital human template UI 2026-06-02 12:17:05 +08:00
boos fa5a2419d8 Add Comfy digital human jobs 2026-06-02 11:07:31 +08:00
boos 7b34153235 Proxy external template requests 2026-06-01 11:21:56 +08:00
boos 04792bd3f7 Improve offline template loading 2026-06-01 11:02:36 +08:00
boos bb31118792 Reference local Workflow UI repo directly 2026-05-28 17:19:32 +08:00
boos 5027767ca2 Use local Workflow UI source for BossAI deploy 2026-05-28 17:16:12 +08:00
boos fd930757f9 Move BossAI deploy worktree out of tmp 2026-05-28 17:03:08 +08:00
boos eb6ed11cff Add BossAI Debian deploy script 2026-05-28 16:59:51 +08:00
boos 931bcf3fb9 Update BossAI workflow and workspace experience 2026-05-28 10:32:20 +08:00
boos e0bd9b4194 Embed workflow UI and isolate workspace assets 2026-05-26 17:00:10 +08:00
boos 0f9fe4a81d chore: prune legacy node backend and mix workflow models 2026-05-26 15:18:33 +08:00
boos 6869504119 chore: add optional bun frontend build 2026-05-25 22:05:03 +08:00
boos 3f9f9f7665 docs: remove readme screenshots 2026-05-25 21:43:46 +08:00
164 changed files with 48538 additions and 11329 deletions
+6 -5
View File
@@ -1,18 +1,19 @@
# Cargo build acceleration. # Cargo build acceleration.
# #
# The biggest single speedup is the `mold` linker (~3-5x faster link # The biggest single speedup is the `mold` linker (~3-5x faster link
# time on debug builds). It isn't installed by default on this host # time on debug builds). `build-and-run.sh` enables mold/lld automatically
# (no apt-get, see build-and-run.sh notes), so the mold block below is # when available, so the block below is kept as an optional manual setup for
# commented out — uncomment it after running: # direct `cargo build` / `cargo run` usage:
# #
# sudo apt-get install -y mold # sudo apt-get install -y mold
# #
# (or build it from source: https://github.com/rui314/mold) # (or build it from source: https://github.com/rui314/mold)
# #
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a # Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units). # meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
# Uncomment once mold is on PATH: # Optional: uncomment once mold is on PATH if you want direct cargo commands
# to use it without going through build-and-run.sh.
# [target.x86_64-unknown-linux-gnu] # [target.x86_64-unknown-linux-gnu]
# linker = "clang" # linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"] # rustflags = ["-C", "link-arg=-fuse-ld=mold"]
+16
View File
@@ -2,3 +2,19 @@ comfy_source/venv
comfy_source/.git comfy_source/.git
comfy_source/__pycache__ comfy_source/__pycache__
**/__pycache__ **/__pycache__
target/
node_modules/
dist/
release/
data/
logs/
tmp/
.agents/
.cache/
.codex/
.claude/
.comfyui/
boosapi
bossai
boosapi.db
docker/*.tar
+7 -1
View File
@@ -1,13 +1,15 @@
/target /target
**/*.rs.bk **/*.rs.bk
Cargo.lock
.env .env
.env.* .env.*
data/ data/
node_modules/ node_modules/
website/.next/
website/out/
dist/ dist/
release/ release/
*.log *.log
*.tsbuildinfo
logs/ logs/
tmp/ tmp/
!tmp/.gitkeep !tmp/.gitkeep
@@ -19,5 +21,9 @@ docker/.env
.comfyui/ .comfyui/
.claude/ .claude/
.codex/ .codex/
.agents/
.cache/
openspec/ openspec/
boosapi boosapi
bossai
docker/*.tar
Generated
+4174
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
# Async runtime # Async runtime
async-stream = "0.3" async-stream = "0.3"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
# Database # Database
sqlx = { version = "0.8", features = [ sqlx = { version = "0.8", features = [
+73
View File
@@ -66,6 +66,31 @@
## 3. 生产环境 (Production) ## 3. 生产环境 (Production)
### 单二进制生产包
推荐用项目脚本生成生产包:
```bash
./build-and-run.sh --production
```
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置:
```bash
./boosapi --check-config
./boosapi --migrate-only
./boosapi --config config.toml
```
### 推荐:Docker Compose 部署 ### 推荐:Docker Compose 部署
1. **准备 docker-compose.yml**: 1. **准备 docker-compose.yml**:
@@ -161,8 +186,54 @@
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) | | `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) | | `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) | | `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) | | `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
### Workflow 后端配置
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
外部完整版 ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native 执行器:
```toml
[comfyui]
backend = "boosapi"
```
说明:
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
--- ---
## 5. 运维操作 (Ops) ## 5. 运维操作 (Ops)
@@ -179,6 +250,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
cp data/boosapi.db data/boosapi.db.bak cp data/boosapi.db data/boosapi.db.bak
``` ```
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
### 查看日志 ### 查看日志
如果使用 Systemd: 如果使用 Systemd:
```bash ```bash
+160 -20
View File
@@ -9,7 +9,7 @@
<br> <br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。 汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br> <br>
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。 <strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
<br> <br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。 <strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p> </p>
@@ -57,16 +57,6 @@
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 | | 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 | | 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
## 🖼️ 界面预览
![Workflow Studio 总览](docs/screenshots/workflow-overview.png)
![Workflow Studio 画布执行](docs/screenshots/workflow-canvas.png)
![Workflow 中的 BoosAPI 模型](docs/screenshots/workflow-boosapi-models.png)
---
## ✨ 核心功能 ## ✨ 核心功能
### 🎨 Workflow Studio 与视频助手(Rust) ### 🎨 Workflow Studio 与视频助手(Rust)
@@ -75,7 +65,8 @@
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片 - **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图 - **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音 - **TTS 语音合成**:对话台词自动转语音
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI - **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*` - **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖 - **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面 - 侧边栏「视频助手」入口,与聚合网关同一界面
@@ -186,6 +177,80 @@ BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。 如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
## 🎛️ Workflow API 后端模式
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
| backend | 用途 |
|---------|------|
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
| `external` | 代理你自己部署的完整版 ComfyUI |
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
配置示例:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
```toml
[comfyui]
backend = "boosapi"
```
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
2. 模板图里最好保存节点映射:
- `prompt`:提示词节点和字段
- `negativePrompt`:反向提示词节点和字段,可选
- `ratio` / `resolution` / `duration`:画幅、分辨率和时长节点字段,可选
3. 在模型路由里新增一个模型,例如 `comfy-wan-video`。
4. 让该路由的上游地址或实际模型映射指向模板:
- 上游地址:`comfy://<workflow_public_id>`
- 或模型映射:`comfy:<workflow_public_id>`
之后用户可以直接调用:
```bash
curl http://127.0.0.1:4000/v1/videos \
-H "Authorization: Bearer <BoosAPI downstream key>" \
-H "Content-Type: application/json" \
-d '{
"model": "comfy-wan-video",
"prompt": "cinematic product video, smooth camera movement",
"duration": 5,
"ratio": "9:16",
"resolution": "1080p"
}'
```
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;生产构建会把可用资产嵌入 Rust 二进制。
## 🚀 快速开始 ## 🚀 快速开始
### Docker Compose(推荐) ### Docker Compose(推荐)
@@ -260,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/
@@ -269,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)
``` ```
@@ -306,6 +372,11 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
# 前端静态目录(默认 dist/web) # 前端静态目录(默认 dist/web)
BOOSAPI_WEB_DIR=dist/web BOOSAPI_WEB_DIR=dist/web
# Workflow 后端
BOOSAPI_COMFYUI__BACKEND=cloud
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
``` ```
## 🏗️ 技术栈 ## 🏗️ 技术栈
@@ -319,9 +390,10 @@ BOOSAPI_WEB_DIR=dist/web
| 认证鉴权 | **JWT** + API Key 双模式 | | 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor | | 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 | | 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| Workflow Studio | API-native 工作流入口,后续承接节点画布 | | 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 |
@@ -329,6 +401,8 @@ BOOSAPI_WEB_DIR=dist/web
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。 > **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
--- ---
## 🛠️ 本地开发 ## 🛠️ 本地开发
@@ -336,11 +410,20 @@ BOOSAPI_WEB_DIR=dist/web
### 后端(Rust) ### 后端(Rust)
```bash ```bash
# 编译并运行 # 最快验证:只检查 Rust 后端,不构建前端、不启动服务
cargo run --release ./build-and-run.sh --check
# 仅检查编译 # 日常启动:复用前端缓存,后端 dev profile 编译后启动
cargo check ./build-and-run.sh
# 只重启/编译后端,不碰 Vite/Next
./build-and-run.sh --backend-only
# 接近生产性能但比 release 快
./build-and-run.sh --fast-release
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
./build-and-run.sh --production
# 运行测试 # 运行测试
cargo test cargo test
@@ -351,9 +434,15 @@ export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1 export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
export BOOSAPI_UPSTREAM_KEY=sk-boos4721 export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
``` ```
构建加速说明:
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
### 前端(React SPA) ### 前端(React SPA)
前端使用 Vite 构建,产物由 Rust 后端静态 serve: 前端使用 Vite 构建,产物由 Rust 后端静态 serve:
@@ -370,9 +459,60 @@ npm run build
# 输出到 dist/web/,Rust 后端自动 serve # 输出到 dist/web/,Rust 后端自动 serve
``` ```
可选使用 Bun 加速主前端构建:
```bash
# 一次性使用 Bun 构建主前端
./build-and-run.sh --bun
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
```
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。 > 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。 > 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js)
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
```bash
# 首次安装
npm --prefix website install
# 本地开发官网
npm run website:dev
# 类型检查与生产构建
npm run website:typecheck
npm run website:build
```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建
```bash
./build-and-run.sh --production
```
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
仓库卫生说明:
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
### Docker 部署 ### Docker 部署
```bash ```bash
+140 -151
View File
@@ -2,206 +2,195 @@
# BoosAPI # BoosAPI
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point** **AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
<p> <p>
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
<br> <br>
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization. into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
<br> <br>
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box. Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
<br>
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
</p> </p>
<p align="center"> <p align="center">
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat"> <img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"> <img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat"> <img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</p> </p>
<p align="center"> <p align="center">
<a href="README.md">中文</a> | <a href="README.md">中文</a> |
<a href="README_EN.md"><strong>English</strong></a> <a href="README_EN.md"><strong>English</strong></a> |
<a href="DEPLOY.md">Deployment</a>
</p> </p>
</div> </div>
--- ---
## 📖 Introduction ## Introduction
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming. BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes. Supported upstreams:
### Supported Upstreams - **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API ## Key Features
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
| Pain Point | BoosAPI Solution | ### Unified API Gateway
|------------|-----------------|
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
| 📊 Scattered balances | **Central dashboard** with alerts |
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
| 🤷 What models are available? | **Auto model discovery**, zero config |
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
--- - OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
- Admin and user dashboards with API key management and model visibility controls
## ✨ Features ### Workflow Studio
### 🎨 Built-in ComfyUI Chat Assistant Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline | backend | Purpose |
- **Multi-view generation**: front/back/left/right directional image generation |---------|---------|
- **Scene/video generation**: text description directly generates video content | `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
- **SSE real-time streaming**: generation progress feedback | `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway | `external` | Proxy a full ComfyUI instance you deploy yourself |
- Auto-connects to ComfyUI API (`:8188`), zero configuration required | `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
### 🌐 Unified Proxy Gateway Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
- Compatible with **OpenAI** and **Claude** downstream formats ### Assistants
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
- SSE streaming with automatic format conversion
### 🧠 Smart Routing Engine - Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
- Volcengine video task templates for create-task and query-task flows
- Auto-discovers models from all upstream stations ## Hardware Guide
- Four-tier cost signals: measured → configured → catalog → fallback
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
- Auto cooldown on failure (default 10 min), seamless channel switching
### 📡 Multi-Platform Management | Mode | CPU | Memory | Disk | GPU |
|------|-----|--------|------|-----|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
| Platform | Adapter | Recommended SaaS gateway server:
|----------|---------|
| New API | `new-api` |
| One API | `one-api` |
| OneHub | `onehub` |
| DoneHub | `done-hub` |
| Veloera | `veloera` |
| AnyRouter | `anyrouter` |
| Sub2API | `sub2api` |
### 👥 Account & Token Management ```txt
4 CPU cores
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled) 8GB RAM
- Encrypted credential storage, auto-renewal, cascading disable 50GB SSD
No GPU required
### 💰 Balance Management
- Scheduled refresh (default hourly), income/expense trends
- Fallback estimation when API is unavailable
### 👥 SaaS Multi-Tenant
- **Organizations**: Create orgs, each with independent members and quotas
- **Member Invitations**: Email-based invites with admin/member roles
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
- **Role Control**: Owner / Admin / Member with granular permissions
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
- **Invite Links**: Token-based, 48h expiry
### 🔔 Alerts & Notifications
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
### 🔌 REST API Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/organizations` | GET/POST | List / Create org |
| `/api/organizations/plans` | GET | List available plans |
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
| `/api/organizations/:id/members` | GET | List members |
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
| `/api/invitations/:token` | GET | Public invite lookup |
| `/api/invitations/:token/accept` | POST | Accept invitation |
| `/api/users/register` | POST | Register new user |
| `/api/users/login` | POST | Login (returns JWT) |
| `/api/users/me` | GET/PATCH | Profile / Update |
| `/api/user-api-keys` | GET/POST | List / Create API key |
---
## 🚀 Quick Start
### Docker Compose (Recommended)
```bash
mkdir boosapi && cd boosapi
cat > docker-compose.yml << 'EOF'
services:
boosapi:
image: 1467078763/boosapi:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
CHECKIN_CRON: "0 8 * * *"
BALANCE_REFRESH_CRON: "0 * * * *"
PORT: ${PORT:-4000}
DATA_DIR: /app/data
TZ: ${TZ:-Asia/Shanghai}
restart: unless-stopped
EOF
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d
``` ```
Open `http://localhost:4000` and log in with `AUTH_TOKEN`. ## Quick Start
--- ```bash
cp config.toml.example config.toml
./build-and-run.sh
```
## 🏗️ Tech Stack For a production bundle with embedded web assets:
```bash
./build-and-run.sh --production
```
The production output is intended to run with only:
```txt
boosapi
config.toml
```
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
## Workflow Backend Configuration
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
External ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native executor:
```toml
[comfyui]
backend = "boosapi"
```
## Environment Variables
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
```bash
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
```
## Tech Stack
| Layer | Tech | | Layer | Tech |
|-------|------| |-------|------|
| Backend | Fastify | | Backend | Rust, Axum 0.8, Tokio |
| Frontend | React 18 + Vite | | Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
| Language | TypeScript | | Proxy engine | Token Router + Proxy Conductor |
| Styling | Tailwind CSS v4 | | Protocols | OpenAI / Claude / Gemini compatible transformers |
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM | | Frontend | React 19 + Vite |
| Visualization | VChart | | Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
| Container | Docker + Docker Compose | | Static serving | Rust binary serves `dist/web` and Workflow UI assets |
| Test | Vitest | | Container | Docker / Docker Compose |
--- Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
## 🛠️ Local Development ## Local Development
```bash ```bash
npm install cargo check
npm run db:migrate cargo run
npm run dev pnpm install
pnpm run dev:web
``` ```
Build checks:
```bash ```bash
npm run build # Build web + server cargo check
npm test # Run tests pnpm run typecheck:web
npx vite build
``` ```
--- ## License
## 📜 License
[MIT](LICENSE) [MIT](LICENSE)
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+368 -22
View File
@@ -17,10 +17,17 @@ fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}" BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
FRONTEND_ONLY=0 FRONTEND_ONLY=0
BACKEND_ONLY=0 BACKEND_ONLY=0
CHECK_ONLY=0
NO_RUN=0 NO_RUN=0
FORCE_FRONTEND=0 FORCE_FRONTEND=0
SKIP_FRONTEND=0 SKIP_FRONTEND=0
PRODUCTION_BUNDLE=0
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}" CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
BUILD_TIMINGS=()
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -31,18 +38,33 @@ usage() {
--release 使用 release 模式编译后端(更慢但性能好) --release 使用 release 模式编译后端(更慢但性能好)
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产) --fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
--profile <name> 指定构建 profile:dev / release / fast-release --profile <name> 指定构建 profile:dev / release / fast-release
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
--prod 同 --production
--production-dir <dir>
指定生产产物目录(默认:dist/production)
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
--backend-check 同 --check
--frontend-only 只构建前端 --frontend-only 只构建前端
--backend-only 只构建后端 --backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存 --force-frontend 强制重建前端,忽略缓存
--skip-frontend 跳过前端构建(沿用现有 dist/web) --skip-frontend 跳过前端构建(沿用现有 dist/web)
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
--no-run 构建后不启动服务 --no-run 构建后不启动服务
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release) BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空) BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
-h, --help 显示帮助 -h, --help 显示帮助
示例: 示例:
./build-and-run.sh --release ./build-and-run.sh --release
./build-and-run.sh --fast-release ./build-and-run.sh --fast-release
./build-and-run.sh --check
./build-and-run.sh --bun
./build-and-run.sh --production
./build-and-run.sh --production --production-dir /opt/boosapi-release
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
EOF EOF
} }
@@ -52,6 +74,21 @@ while [ $# -gt 0 ]; do
--dev) BUILD_PROFILE="dev" ;; --dev) BUILD_PROFILE="dev" ;;
--release) BUILD_PROFILE="release" ;; --release) BUILD_PROFILE="release" ;;
--fast-release) BUILD_PROFILE="fast-release" ;; --fast-release) BUILD_PROFILE="fast-release" ;;
--production|--prod)
PRODUCTION_BUNDLE=1
BUILD_PROFILE="release"
NO_RUN=1
;;
--production-dir)
shift
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
PRODUCTION_DIR="$1"
;;
--check|--backend-check)
CHECK_ONLY=1
BACKEND_ONLY=1
NO_RUN=1
;;
--profile) --profile)
shift shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release" [ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
@@ -61,6 +98,7 @@ while [ $# -gt 0 ]; do
--backend-only) BACKEND_ONLY=1 ;; --backend-only) BACKEND_ONLY=1 ;;
--force-frontend) FORCE_FRONTEND=1 ;; --force-frontend) FORCE_FRONTEND=1 ;;
--skip-frontend) SKIP_FRONTEND=1 ;; --skip-frontend) SKIP_FRONTEND=1 ;;
--bun) FRONTEND_PM="bun" ;;
--no-run) NO_RUN=1 ;; --no-run) NO_RUN=1 ;;
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;; --rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
@@ -73,12 +111,122 @@ if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--frontend-only 和 --backend-only 不能同时使用" fail "--frontend-only 和 --backend-only 不能同时使用"
fi fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
fail "--production 不能和 --frontend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
fail "--production 不能和 --check 同时使用"
fi
case "$BUILD_PROFILE" in case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;; dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;; release|fast-release) ;;
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;; *) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
esac esac
case "$FAST_LINKER" in
auto|off|none|mold|lld) ;;
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
esac
timer_start() {
date +%s
}
record_timing() {
local label="$1"
local started="$2"
local ended
ended="$(date +%s)"
BUILD_TIMINGS+=("${label}: $((ended - started))s")
}
print_timing_summary() {
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
log "构建耗时:"
local item
for item in "${BUILD_TIMINGS[@]}"; do
log " - $item"
done
}
append_rustflags() {
local flags="$1"
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
fi
}
build_cargo_args() {
CARGO_BASE_ARGS=()
CARGO_FEATURE_ARGS=()
if [ -n "$CARGO_JOBS" ]; then
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
fi
if [ -n "$CARGO_FEATURES" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
fi
}
configure_fast_linker() {
local mode="$FAST_LINKER"
case "$(uname -s 2>/dev/null || true)" in
Linux) ;;
*)
[ "$mode" = "auto" ] && return 0
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
;;
esac
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
return 0
fi
if [ "$mode" = "auto" ]; then
if command -v mold >/dev/null 2>&1; then
mode="mold"
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
mode="lld"
else
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
return 0
fi
fi
case "$mode" in
mold)
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=mold"
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
lld)
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
fi
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=lld"
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
esac
}
server_port() { server_port() {
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
printf '%s\n' "$BOOSAPI_SERVER__PORT" printf '%s\n' "$BOOSAPI_SERVER__PORT"
@@ -161,25 +309,73 @@ stop_previous_server() {
fi fi
} }
build_frontend() { ensure_pnpm() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then if command -v pnpm >/dev/null 2>&1; then
log "跳过前端构建(--skip-frontend)" return 0
fi
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
log "未找到 pnpm,尝试通过 corepack 自动启用..."
corepack enable
corepack prepare pnpm@latest --activate
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
}
install_frontend_dependencies() {
local frontend_pm="$1"
if [ "$frontend_pm" = "bun" ]; then
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (bun install)..."
bun install
fi
return 0 return 0
fi fi
if ! command -v npx >/dev/null 2>&1; then command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
fail "未找到 npx,请先安装 Node.js" ensure_pnpm
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (pnpm install)..."
pnpm install --frozen-lockfile
fi fi
}
build_frontend() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)"
record_timing "前端检查" "$started"
return 0
fi
local frontend_pm="$FRONTEND_PM"
case "$frontend_pm" in
auto)
if command -v bun >/dev/null 2>&1; then
frontend_pm="bun"
else
frontend_pm="npm"
fi
;;
npm|bun) ;;
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
esac
install_frontend_dependencies "$frontend_pm"
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint" local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
local fingerprint local fingerprint
fingerprint="$( fingerprint="$(
{ {
printf 'build-script-v2\n' printf 'build-script-v2\n'
printf 'frontend_pm=%s\n' "$frontend_pm"
printf 'node=' printf 'node='
node --version 2>/dev/null || true node --version 2>/dev/null || true
printf 'npx=' printf 'npx='
npx --version 2>/dev/null || true npx --version 2>/dev/null || true
printf 'bun='
bun --version 2>/dev/null || true
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print [ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum done | sort | xargs -r sha256sum
@@ -191,49 +387,179 @@ build_frontend() {
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \ && [ -f "$ROOT_DIR/dist/web/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then && [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "前端无变化,跳过 Vite 构建 → dist/web/" ok "前端无变化,跳过 Vite 构建 → dist/web/"
record_timing "前端检查" "$started"
return 0 return 0
fi fi
log "构建前端 (Vite)..." log "构建前端 (Vite via ${frontend_pm})..."
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在) # 直接调用 vite build;桌面端构建链路已移除。
npx vite build if [ "$frontend_pm" = "bun" ]; then
bunx --bun vite build
else
pnpm exec vite build
fi
mkdir -p "$ROOT_DIR/dist/web" mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file" printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/" ok "前端构建完成 → dist/web/"
record_timing "前端构建" "$started"
}
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
record_timing "官网检查" "$started"
return 0
fi
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
record_timing "官网检查" "$started"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
record_timing "官网构建" "$started"
}
ensure_workflow_ui_for_embedding() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)"
record_timing "Workflow UI 检查" "$started"
return 0
fi
if [ -f "$ROOT_DIR/dist/workflow-ui-external/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui-cloud/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
record_timing "Workflow UI 检查" "$started"
return 0
fi
command -v git >/dev/null 2>&1 || fail "未找到 git,无法构建 Workflow UI"
command -v node >/dev/null 2>&1 || fail "未找到 node,无法构建 Workflow UI"
ensure_pnpm
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
pnpm run workflow-ui:install
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
record_timing "Workflow UI 构建" "$started"
} }
build_backend() { build_backend() {
local started
started="$(timer_start)"
if ! command -v cargo >/dev/null 2>&1; then if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链" fail "未找到 cargo,请先安装 Rust 工具链"
fi fi
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。 configure_fast_linker
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。 build_cargo_args
if ! command -v mold >/dev/null 2>&1; then
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
fi
CARGO_FEATURE_ARGS=() if [ "$CHECK_ONLY" -eq 1 ]; then
if [ -n "$CARGO_FEATURES" ]; then if [ "$BUILD_PROFILE" = "release" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES") log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
else
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
fi
ok "后端检查完成"
record_timing "后端检查" "$started"
return 0
fi fi
if [ "$BUILD_PROFILE" = "release" ]; then if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..." log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build --release "${CARGO_FEATURE_ARGS[@]}" cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/release/boosapi" BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi" ok "后端构建完成 → target/release/boosapi"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..." log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}" cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi" BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi" ok "后端构建完成 → target/fast-release/boosapi"
else else
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..." log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_FEATURE_ARGS[@]}" cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/debug/boosapi" BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi" ok "后端构建完成 → target/debug/boosapi"
fi fi
record_timing "后端构建" "$started"
}
build_production_bundle() {
local started
started="$(timer_start)"
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
rm -rf "$PRODUCTION_DIR"
mkdir -p "$PRODUCTION_DIR"
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
chmod +x "$PRODUCTION_DIR/boosapi"
if command -v strip >/dev/null 2>&1; then
log "压缩生产二进制符号表 (strip)..."
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
fi
ok "生产产物已生成 → $PRODUCTION_DIR"
log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
record_timing "生产打包" "$started"
} }
cleanup_started_server() { cleanup_started_server() {
@@ -248,21 +574,41 @@ cleanup_started_server() {
if [ "$FRONTEND_ONLY" -eq 1 ]; then if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend build_frontend
build_public_website
print_timing_summary
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
ensure_workflow_ui_for_embedding
fi
fi fi
build_backend build_backend
if [ "$CHECK_ONLY" -eq 1 ]; then
print_timing_summary
log "跳过启动(--check)"
exit 0
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
build_production_bundle
print_timing_summary
exit 0
fi
if [ "$NO_RUN" -eq 1 ]; then if [ "$NO_RUN" -eq 1 ]; then
print_timing_summary
log "跳过启动(--no-run)" log "跳过启动(--no-run)"
exit 0 exit 0
fi fi
print_timing_summary
stop_previous_server stop_previous_server
log "启动服务..." log "启动服务..."
+9 -9
View File
@@ -28,19 +28,19 @@ proxy_log_retention_days = 30
[comfyui] [comfyui]
enabled = false enabled = false
# boosapi = use embedded Workflow API bridge; external/cloud = proxy Workflow
# API and WebSocket to a full ComfyUI-compatible backend.
backend = "cloud"
docker_image = "boosapi-comfyui:multi-tenant"
container_name = "boosapi-comfyui-workflow"
python_bin = "python3" python_bin = "python3"
port = 8188 port = 8188
extra_args = [] extra_args = []
max_restarts = 5 max_restarts = 5
# backend modes:
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
# - "external": proxy to your own full ComfyUI server.
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
backend = "cloud"
# docker_image = "boosapi-comfyui:multi-tenant"
# container_name = "boosapi-comfyui-workflow"
# base_url = "https://www.runninghub.cn/proxy/94d1330ea62d4ae980ffb018ecdf82a2"
base_url = "https://cloud.comfy.org" base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f" api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
# For Comfy Cloud:
# backend = "cloud"
# base_url = "https://cloud.comfy.org"
# api_key = "your-comfy-cloud-api-key"
agent_model = "gpt-5.5" agent_model = "gpt-5.5"
+4
View File
@@ -20,6 +20,10 @@ RUN find /workspace/ComfyUI -mindepth 1 \
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes && mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
RUN bash /tmp/install-runninghub-template-nodes.sh \
&& rm -f /tmp/install-runninghub-template-nodes.sh
RUN python - <<'PY' RUN python - <<'PY'
from pathlib import Path from pathlib import Path
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

-42
View File
@@ -1,42 +0,0 @@
appId: me.cita777.boosapi.desktop
productName: Metapi
artifactName: ${name}-${version}-${os}-${arch}.${ext}
directories:
output: release
electronDist: node_modules/electron/dist
icon: build/desktop-icon.png
asar: false
files:
- dist/**/*
- drizzle/**/*
- package.json
mac:
target:
- dmg
- zip
category: public.app-category.developer-tools
hardenedRuntime: false
gatekeeperAssess: false
entitlementsInherit: null
win:
target:
- nsis
- zip
linux:
target:
- AppImage
- deb
- rpm
maintainer: cita-777 <cita-777@users.noreply.github.com>
publish:
provider: github
owner: cita-777
repo: boosapi
releaseType: release
publishAutoUpdate: true
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
afterPack: scripts/desktop/afterPack.mjs
afterSign: scripts/desktop/afterSign.mjs
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
@@ -0,0 +1,51 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (
json_extract(metadata, '$.workspaceAsset.role'),
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (
json_extract(metadata, '$.workflowUi.type'),
created_at DESC
);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
@@ -0,0 +1,45 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count BIGINT NOT NULL DEFAULT 0,
local_file_count BIGINT NOT NULL DEFAULT 0,
total_size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
+17 -72
View File
@@ -2,12 +2,12 @@
"name": "boosapi", "name": "boosapi",
"version": "1.3.0", "version": "1.3.0",
"description": "Meta-layer management and unified proxy for AI API aggregation platforms", "description": "Meta-layer management and unified proxy for AI API aggregation platforms",
"main": "dist/desktop/main.js", "main": "dist/web/index.html",
"keywords": [ "keywords": [
"ai", "ai",
"api-gateway", "api-gateway",
"proxy", "proxy",
"fastify", "rust",
"openai", "openai",
"claude", "claude",
"aggregator" "aggregator"
@@ -22,98 +22,45 @@
}, },
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=25.0.0" "node": ">=22.0.0"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:server": "tsx scripts/dev/run-server.ts --watch", "dev": "vite",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
"build:web": "vite build", "build:web": "vite build",
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts", "build": "npm run build:web",
"build:desktop": "tsc -p tsconfig.desktop.json",
"build": "npm run build:web && npm run build:server && npm run build:desktop",
"typecheck:web": "tsc -p tsconfig.web.json", "typecheck:web": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json", "typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json", "typecheck": "npm run typecheck:web && npm run typecheck:web:test",
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json", "website:dev": "npm --prefix website run dev",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop", "website:build": "npm --prefix website run build",
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never", "website:start": "npm --prefix website run start",
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64", "website:typecheck": "npm --prefix website run typecheck",
"package:desktop": "npm run dist:desktop",
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"start": "node dist/server/index.js", "workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"start:deploy-helper": "node dist/server/update-helper/index.js", "workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"start:desktop": "electron dist/desktop/main.js", "workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"comfyui:install": "node scripts/comfyui/install.mjs", "test": "vitest run --root src/web",
"comfyui:start": "node scripts/comfyui/start.mjs", "test:watch": "vitest --root src/web"
"comfyui:stop": "node scripts/comfyui/stop.mjs",
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/server/db/migrate.ts",
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
"schema:generate": "npm run db:generate && npm run schema:contract",
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
"smoke:db": "tsx scripts/dev/db-smoke.ts",
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
"test": "vitest run --root .",
"test:watch": "vitest --root ."
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@visactor/react-vchart": "^2.0.21", "@visactor/react-vchart": "^2.0.21",
"better-sqlite3": "^12.8.0", "gsap": "^3.15.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"fastify": "^5.8.4",
"get-port": "^7.2.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"minimatch": "^10.2.5",
"minimist": "^1.2.8",
"mysql2": "^3.20.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.4",
"pg": "^8.16.3",
"rate-limiter-flexible": "^11.0.0",
"socks": "^2.8.7",
"undici": "^6.24.1",
"ws": "^8.20.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@electron/notarize": "^3.1.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.10",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"mermaid": "^11.14.0", "mermaid": "^11.14.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -122,13 +69,11 @@
"react-test-renderer": "^18.3.1", "react-test-renderer": "^18.3.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitepress": "^1.6.4", "vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17", "vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^2.1.8", "vitest": "^2.1.8"
"wait-on": "^9.0.4"
}, },
"overrides": { "overrides": {
"minimist": "^1.2.8" "minimist": "^1.2.8"
+82 -3768
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from 'fs/promises';
const DEFAULT_PACKS = [
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
];
const args = new Set(process.argv.slice(2));
const configPath = valueArg('--config') || 'config.toml';
const baseUrl = trimSlash(
process.env.BOOSAPI_COMFYUI__BASE_URL
|| process.env.COMFYUI_CLOUD_BASE_URL
|| (await readComfyConfig(configPath)).base_url
|| 'https://cloud.comfy.org'
);
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|| process.env.COMFYUI_CLOUD_API_KEY
|| (await readComfyConfig(configPath)).api_key
|| '';
const dryRun = args.has('--dry-run');
if (!baseUrl) {
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
}
if (!apiKey) {
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
}
console.log(`Cloud backend: ${baseUrl}`);
console.log(`Packs: ${DEFAULT_PACKS.length}`);
if (dryRun) {
for (const [id, repository] of DEFAULT_PACKS) {
console.log(`[dry-run] ${id} <- ${repository}`);
}
process.exit(0);
}
await request('GET', '/api/manager/queue/start').catch((error) => {
console.warn(`manager queue start warning: ${error.message}`);
});
const results = [];
for (const [id, repository] of DEFAULT_PACKS) {
const payload = {
id,
version: 'nightly',
selected_version: 'nightly',
repository,
mode: 'remote',
channel: 'default',
skip_post_install: false,
};
try {
await request('POST', '/api/manager/queue/install', payload);
console.log(`queued: ${id}`);
results.push({ id, ok: true });
} catch (error) {
console.error(`failed: ${id}: ${error.message}`);
results.push({ id, ok: false, error: error.message });
}
}
await request('GET', '/api/manager/queue/status')
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
.catch((error) => console.warn(`queue status warning: ${error.message}`));
const failed = results.filter((item) => !item.ok);
if (failed.length > 0) {
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
process.exit(1);
}
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
async function request(method, path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-API-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const data = text ? safeJson(text) : null;
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
}
return data;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { body: text };
}
}
async function readComfyConfig(path) {
const result = {};
let section = '';
let text = '';
try {
text = await fs.readFile(path, 'utf8');
} catch {
return result;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1];
continue;
}
if (section !== 'comfyui') continue;
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
if (match) result[match[1]] = match[2];
}
return result;
}
function trimSlash(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function valueArg(name) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : '';
}
function fail(message) {
console.error(message);
process.exit(2);
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
if [ ! -d "$COMFYUI_DIR" ]; then
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
exit 1
fi
if [ ! -x "$PYTHON_BIN" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
else
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
exit 1
fi
fi
mkdir -p "$CUSTOM_NODES_DIR"
install_node() {
local name="$1"
local repo="$2"
local ref="${3:-}"
local dir="$CUSTOM_NODES_DIR/$name"
if [ -d "$dir/.git" ]; then
echo "==> Updating $name"
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
else
echo "==> Installing $name"
git clone --depth 1 "$repo" "$dir"
fi
if [ -n "$ref" ]; then
git -C "$dir" checkout --quiet "$ref"
fi
if [ -f "$dir/requirements.txt" ]; then
echo "==> Installing Python requirements for $name"
if command -v uv >/dev/null 2>&1; then
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
else
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
fi
fi
if [ -f "$dir/install.py" ]; then
echo "==> Running install.py for $name"
"$PYTHON_BIN" "$dir/install.py" || true
fi
}
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
# for local/docker/external ComfyUI instances.
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
# Nodes used by common RunningHub digital-human / video templates.
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
cat <<'EOF'
RunningHub template nodes installed.
Notes:
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
node requires them at runtime.
- The screenshot also references a "soundflow" package. I did not find a stable
public repository for that exact package name; install it through
ComfyUI-Manager or add its repository manually when you have the source URL.
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
nodes. Put those models into the matching ComfyUI models folders or use a
backend where RunningHub/Cloud already provides them.
EOF
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Build the BossAI production binary, package Alpine/musl runtime libraries,
# and deploy the runnable bundle to a Debian server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
BOSSAI_REF="${BOSSAI_REF:-main}"
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
SKIP_BUILD=0
SKIP_VERIFY=0
SKIP_SOURCE_UPDATE=0
usage() {
cat <<'EOF'
Usage: scripts/deploy-bossai-debian.sh [options]
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
needed by the Alpine-built binary, upload it to a Debian host, extract it,
and run a config check.
Options:
--host <host> Debian host/IP. Default: 129.211.30.104
--user <user> SSH user. Default: root
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
--remote <name> Git remote for BossAI. Default: bossai
--ref <ref> BossAI branch/ref to build. Default: main
--skip-build Reuse dist/bossai-production from a previous build
--skip-source-update Do not fetch/reset the BossAI source worktree
--skip-verify Skip remote ./run-boosapi.sh --check-config
-h, --help Show this help
Environment overrides:
BOSSAI_DEBIAN_HOST
BOSSAI_DEBIAN_USER
BOSSAI_DEBIAN_DIR
BOSSAI_DEBIAN_SSH_KEY
BOSSAI_SOURCE_DIR
BOSSAI_REMOTE
BOSSAI_REF
BOSSAI_WORKFLOW_UI_SOURCE
BOSSAI_PRODUCTION_DIR
BOSSAI_DEBIAN_BUNDLE_DIR
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
Remote start command after deploy:
cd /root/BossAI && ./run-boosapi.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host)
shift
[ $# -gt 0 ] || fail "--host requires a value"
REMOTE_HOST="$1"
;;
--user)
shift
[ $# -gt 0 ] || fail "--user requires a value"
REMOTE_USER="$1"
;;
--dir)
shift
[ $# -gt 0 ] || fail "--dir requires a value"
REMOTE_DIR="$1"
;;
--key)
shift
[ $# -gt 0 ] || fail "--key requires a value"
SSH_KEY="$1"
;;
--source-dir)
shift
[ $# -gt 0 ] || fail "--source-dir requires a value"
BOSSAI_SOURCE_DIR="$1"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
;;
--remote)
shift
[ $# -gt 0 ] || fail "--remote requires a value"
BOSSAI_REMOTE="$1"
;;
--ref)
shift
[ $# -gt 0 ] || fail "--ref requires a value"
BOSSAI_REF="$1"
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-source-update)
SKIP_SOURCE_UPDATE=1
;;
--skip-verify)
SKIP_VERIFY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_file() {
local path="$1"
[ -f "$path" ] || fail "Missing required file: $path"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
ensure_bossai_source() {
require_command git
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
return 0
fi
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
git -C "$BOSSAI_SOURCE_DIR" clean -fd
else
rm -rf "$BOSSAI_SOURCE_DIR"
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
fi
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
}
prepare_workflow_ui_source() {
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
return 0
fi
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
return 0
fi
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
}
runtime_lib() {
local path="$1"
require_file "$path"
cp "$path" "$BUNDLE_DIR/lib/"
}
write_runner() {
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
#!/usr/bin/env sh
set -eu
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
EOF
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
}
package_bundle() {
require_file "$PRODUCTION_DIR/boosapi"
require_file "$PRODUCTION_DIR/config.toml"
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
mkdir -p "$BUNDLE_DIR/lib"
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
chmod +x "$BUNDLE_DIR/boosapi"
runtime_lib /lib/ld-musl-x86_64.so.1
runtime_lib /usr/lib/libssl.so.3
runtime_lib /usr/lib/libcrypto.so.3
runtime_lib /usr/lib/libgcc_s.so.1
write_runner
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
ok "Bundle ready: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
ls -lh "$BUNDLE_ARCHIVE"
}
deploy_bundle() {
require_file "$SSH_KEY"
require_command ssh
require_command scp
local remote="${REMOTE_USER}@${REMOTE_HOST}"
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
log "Preparing remote directory: $remote:$REMOTE_DIR"
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
log "Uploading bundle..."
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
log "Extracting bundle on Debian host..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
if [ "$SKIP_VERIFY" -eq 0 ]; then
log "Checking remote config..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
fi
ok "Deployed BossAI to $remote:$REMOTE_DIR"
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
}
if [ "$SKIP_BUILD" -eq 0 ]; then
ensure_bossai_source
prepare_workflow_ui_source
log "Building BossAI production bundle..."
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
--production \
--production-dir "$PRODUCTION_DIR"
else
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
log "Skipping build; using existing $PRODUCTION_DIR"
fi
package_bundle
deploy_bundle
+73 -18
View File
@@ -4,10 +4,34 @@ import { spawn } from 'child_process';
const ROOT = process.cwd(); const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || ''; const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1'; const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
const DEFAULT_CLOUD_REF = 'v1.46.2';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend'); const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend'); const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DIST = path.join(ROOT, 'dist', 'workflow-ui'); const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
const variants = {
external: {
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_EXTERNAL_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
description: 'External/RunningHub compatible frontend',
},
cloud: {
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_CLOUD_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
description: 'Comfy Cloud frontend',
},
};
function run(command, args, options = {}) { function run(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -25,23 +49,54 @@ function run(command, args, options = {}) {
}); });
} }
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false); function selectedVariants() {
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR; if (VARIANT === 'all') return Object.entries(variants);
if (REPO) { if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
}
async function buildVariant(name, config) {
const ref = config.ref;
const dist = config.dist;
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true }); await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
await fs.rm(CACHE_DIR, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
} else if (!hasSubmodule) { if (REPO) {
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
} else {
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
if (!hasSubmodule) {
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']); await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
} }
const hasPnpmLock = await fs.access(path.join(DIR, 'pnpm-lock.yaml')).then(() => true, () => false); await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
if (hasPnpmLock) { await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR }); await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: DIR }); await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
} else { }
await run('npm', ['ci'], { cwd: DIR });
await run('npm', ['run', 'build'], { cwd: DIR }); const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: dir });
} else {
await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], { cwd: dir });
}
await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') {
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
}
}
for (const [name, config] of selectedVariants()) {
await buildVariant(name, config);
} }
await fs.rm(DIST, { recursive: true, force: true });
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
console.log(`workflow-ui installed to ${DIST}`);
+2471
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -47,6 +47,8 @@ pub struct ProxyConfig {
pub first_byte_timeout_secs: u64, pub first_byte_timeout_secs: u64,
pub max_retries: u32, pub max_retries: u32,
pub proxy_log_retention_days: u32, pub proxy_log_retention_days: u32,
#[serde(default)]
pub template_proxy_url: Option<String>,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@@ -76,6 +78,7 @@ pub enum ComfyUIBackend {
Boosapi, Boosapi,
External, External,
Cloud, Cloud,
Runninghub,
Docker, Docker,
} }
@@ -109,13 +112,20 @@ impl Default for ComfyUIConfig {
impl ComfyUIConfig { impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool { pub fn uses_native_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::External | ComfyUIBackend::Cloud) matches!(
self.backend,
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
)
} }
pub fn uses_cloud_backend(&self) -> bool { pub fn uses_cloud_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Cloud) matches!(self.backend, ComfyUIBackend::Cloud)
} }
pub fn uses_runninghub_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Runninghub)
}
pub fn uses_docker_backend(&self) -> bool { pub fn uses_docker_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Docker) matches!(self.backend, ComfyUIBackend::Docker)
} }
+18
View File
@@ -8,6 +8,7 @@ use sqlx::{
pub mod models; pub mod models;
pub mod queries; pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres; pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -45,6 +46,23 @@ pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Re
} }
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(max_connections) .max_connections(max_connections)
.after_connect(|conn, _meta| {
Box::pin(async move {
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA busy_timeout = 5000")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&mut *conn)
.await?;
Ok(())
})
})
.connect(database_url) .connect(database_url)
.await?; .await?;
Ok(pool) Ok(pool)
+382
View File
@@ -0,0 +1,382 @@
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"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
],
)
.await?;
run_compat_ddl(pool).await?;
Ok(())
}
async fn ensure_columns(
pool: &SqlitePool,
table: &str,
columns: &[(&str, &str)],
) -> anyhow::Result<()> {
let existing = table_columns(pool, table).await?;
if existing.is_empty() {
return Ok(());
}
for (name, definition) in columns {
if existing.contains(*name) {
continue;
}
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("add column {table}.{name}"))?;
}
Ok(())
}
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
let sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&sql).fetch_all(pool).await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.collect())
}
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
for statement in SQLITE_COMPAT_DDL {
sqlx::query(statement)
.execute(pool)
.await
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
}
Ok(())
}
const SQLITE_COMPAT_DDL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary)",
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC)",
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id)",
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at)",
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index)",
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at)",
"CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled)",
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until)",
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at)",
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_spend >= 0
AND total_latency_ms >= 0 AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model)",
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id)",
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model)",
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id)",
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id)",
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id)",
];
+194
View File
@@ -299,6 +299,200 @@ const TABLES: &[TableSpec] = &[
col!("read_at", text), col!("read_at", text),
], ],
}, },
TableSpec {
name: "site_api_endpoints",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("url", text),
col!("enabled", bool),
col!("sort_order", i64),
col!("cooldown_until", text),
col!("last_selected_at", text),
col!("last_failed_at", text),
col!("last_failure_reason", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_units",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("provider", text),
col!("name", text),
col!("strategy", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_unit_members",
columns: &[
col!("id", i64),
col!("unit_id", i64),
col!("account_id", i64),
col!("sort_order", i64),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_traces",
columns: &[
col!("id", i64),
col!("downstream_path", text),
col!("client_kind", text),
col!("session_id", text),
col!("trace_hint", text),
col!("requested_model", text),
col!("downstream_api_key_id", i64),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("sticky_session_key", text),
col!("sticky_hit_channel_id", i64),
col!("selected_channel_id", i64),
col!("selected_route_id", i64),
col!("selected_account_id", i64),
col!("selected_site_id", i64),
col!("selected_site_platform", text),
col!("endpoint_candidates_json", text),
col!("endpoint_runtime_state_json", text),
col!("decision_summary_json", text),
col!("final_status", text),
col!("final_http_status", i64),
col!("final_upstream_path", text),
col!("final_response_headers_json", text),
col!("final_response_body_json", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_attempts",
columns: &[
col!("id", i64),
col!("trace_id", i64),
col!("attempt_index", i64),
col!("endpoint", text),
col!("request_path", text),
col!("target_url", text),
col!("runtime_executor", text),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("response_status", i64),
col!("response_headers_json", text),
col!("response_body_json", text),
col!("raw_error_text", text),
col!("recover_applied", bool),
col!("downgrade_decision", bool),
col!("downgrade_reason", text),
col!("memory_write_json", text),
col!("created_at", text),
],
},
TableSpec {
name: "admin_snapshots",
columns: &[
col!("id", i64),
col!("namespace", text),
col!("snapshot_key", text),
col!("payload", text),
col!("generated_at", text),
col!("expires_at", text),
col!("stale_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "analytics_projection_checkpoints",
columns: &[
col!("projector_key", text),
col!("time_zone", text),
col!("last_proxy_log_id", i64),
col!("watermark_created_at", text),
col!("recompute_from_id", i64),
col!("recompute_requested_at", text),
col!("recompute_reason", text),
col!("recompute_started_at", text),
col!("recompute_completed_at", text),
col!("last_projected_at", text),
col!("last_successful_at", text),
col!("last_error", text),
col!("lease_owner", text),
col!("lease_token", text),
col!("lease_expires_at", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "model_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("model", text),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_hour_usage",
columns: &[
col!("id", i64),
col!("bucket_start_utc", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
]; ];
pub async fn migrate( pub async fn migrate(
+24 -5
View File
@@ -6,18 +6,21 @@ pub mod proxy;
pub mod routes; pub mod routes;
pub mod services; pub mod services;
pub mod shared; pub mod shared;
pub mod template_http;
pub mod transformers; pub mod transformers;
pub mod workflow; pub mod workflow;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use std::{sync::Arc, time::Duration};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub config: config::AppConfig, pub config: config::AppConfig,
pub db: Arc<SqlitePool>, pub db: Arc<SqlitePool>,
pub http: reqwest::Client,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>, pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>, pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>, pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>, pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
} }
@@ -27,6 +30,7 @@ impl AppState {
config: config::AppConfig, config: config::AppConfig,
db: SqlitePool, db: SqlitePool,
builtin_keys: services::builtin_keys::BuiltinKeys, builtin_keys: services::builtin_keys::BuiltinKeys,
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
) -> Self { ) -> Self {
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" { let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
"127.0.0.1".to_string() "127.0.0.1".to_string()
@@ -38,21 +42,36 @@ impl AppState {
let comfyui = services::comfyui_process::ComfyUIProcess::new( let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(), config.comfyui.clone(),
boosapi_url.clone(), boosapi_url.clone(),
config.auth.proxy_token.clone(), runtime_auth.proxy_token(),
comfyui_agent_secret.clone(), comfyui_agent_secret.clone(),
); );
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
.connect_timeout(Duration::from_secs(15))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(64)
.tcp_nodelay(true)
.build()
.unwrap_or_else(|err| {
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
reqwest::Client::new()
});
Self { Self {
config, config,
db: Arc::new(db), db: Arc::new(db),
http,
comfyui, comfyui,
builtin_keys: Arc::new(builtin_keys), builtin_keys: Arc::new(builtin_keys),
register_captcha: Arc::new( runtime_auth: Arc::new(runtime_auth),
services::register_captcha::RegisterCaptchaStore::new(), register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
),
registration_limiter: Arc::new( registration_limiter: Arc::new(
services::registration_limiter::RegistrationLimiter::new(), services::registration_limiter::RegistrationLimiter::new(),
), ),
} }
} }
pub fn runtime_proxy_token(&self) -> String {
self.runtime_auth.proxy_token()
}
} }
+20 -4
View File
@@ -1,11 +1,16 @@
use anyhow::Context; use anyhow::Context;
use axum::serve; use axum::serve;
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState}; use boosapi::{
config::AppConfig,
db,
services::{builtin_keys, runtime_auth},
AppState,
};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::ffi::OsString; use std::ffi::OsString;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter; use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
@@ -104,7 +109,11 @@ async fn main() -> anyhow::Result<()> {
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level)); .unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
tracing_subscriber::fmt().with_env_filter(filter).init(); tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
.init();
tracing::info!("Loaded configuration from {}", args.config); tracing::info!("Loaded configuration from {}", args.config);
@@ -116,6 +125,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 {
@@ -134,9 +144,15 @@ async fn main() -> anyhow::Result<()> {
let builtin = builtin_keys::BuiltinKeys { let builtin = builtin_keys::BuiltinKeys {
comfyui_agent: comfyui_agent_key, comfyui_agent: comfyui_agent_key,
}; };
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
&pool,
config.auth.proxy_token.clone(),
)
.await
.context("failed to load runtime auth settings")?;
// Build app state // Build app state
let state = AppState::new(config.clone(), pool, builtin); let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
if let Err(err) = state.comfyui.start().await { if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start"); tracing::warn!(%err, "ComfyUI embedded process did not start");
} }
+494 -48
View File
@@ -14,9 +14,7 @@ use crate::transformers::{
canonical::CanonicalRequest, canonical::CanonicalRequest,
}; };
use crate::AppState; use crate::AppState;
use reqwest::Client; use serde_json::{json, Value};
use serde_json::Value;
use std::time::Duration;
/// Resolved upstream info for a given model request. /// Resolved upstream info for a given model request.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -26,15 +24,6 @@ pub struct UpstreamRoute {
pub model: String, pub model: String,
} }
/// Create a shared reqwest client with sensible defaults.
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(300))
.pool_max_idle_per_host(32)
.build()
.expect("Failed to build HTTP client")
}
fn parse_upstream_json( fn parse_upstream_json(
text: &str, text: &str,
upstream_url: &str, upstream_url: &str,
@@ -174,11 +163,11 @@ fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
/// Execute a non-streaming chat completions request. /// Execute a non-streaming chat completions request.
pub async fn execute_chat_completions( pub async fn execute_chat_completions(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/chat/completions"); let upstream_url = upstream_url(&route.base_url, "/chat/completions");
let resp = client let resp = client
@@ -210,19 +199,29 @@ pub async fn execute_chat_completions(
/// Execute a streaming chat completions request. /// Execute a streaming chat completions request.
/// Returns a vector of SSE data chunks as strings. /// Returns a vector of SSE data chunks as strings.
pub async fn execute_chat_completions_stream( pub async fn execute_chat_completions_stream(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
execute_streaming_request(route, "/chat/completions", body).await execute_streaming_request(state, route, "/chat/completions", body).await
}
/// Execute a streaming chat completions request and return the live upstream response.
pub async fn execute_chat_completions_stream_response(
state: AppState,
route: UpstreamRoute,
body: Value,
) -> AppResult<reqwest::Response> {
execute_streaming_response(state, route, "/chat/completions", body).await
} }
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream. /// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
pub async fn execute_anthropic_messages( pub async fn execute_anthropic_messages(
state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
request: CanonicalRequest, request: CanonicalRequest,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/')); let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default(); let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility); let body = format_messages_request_with_compat(&request, &compatibility);
@@ -260,10 +259,21 @@ pub async fn execute_anthropic_messages(
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames. /// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
pub async fn execute_anthropic_messages_stream( pub async fn execute_anthropic_messages_stream(
state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
request: CanonicalRequest, request: CanonicalRequest,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let client = http_client(); let response = execute_anthropic_messages_stream_response(state, route, request).await?;
collect_anthropic_stream_frames(response).await
}
/// Execute a streaming Anthropic Messages request and return the live upstream response.
pub async fn execute_anthropic_messages_stream_response(
state: AppState,
route: UpstreamRoute,
request: CanonicalRequest,
) -> AppResult<reqwest::Response> {
let client = state.http.clone();
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/')); let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
let compatibility = AnthropicMessagesCompatibility::default(); let compatibility = AnthropicMessagesCompatibility::default();
let body = format_messages_request_with_compat(&request, &compatibility); let body = format_messages_request_with_compat(&request, &compatibility);
@@ -292,6 +302,10 @@ pub async fn execute_anthropic_messages_stream(
))); )));
} }
Ok(resp)
}
async fn collect_anthropic_stream_frames(resp: reqwest::Response) -> AppResult<Vec<String>> {
let mut frames = Vec::new(); let mut frames = Vec::new();
let mut stream = resp.bytes_stream(); let mut stream = resp.bytes_stream();
use futures::StreamExt; use futures::StreamExt;
@@ -406,32 +420,12 @@ fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
/// Execute a streaming request against any upstream path (e.g. /responses). /// Execute a streaming request against any upstream path (e.g. /responses).
/// Returns SSE `data: ...` lines verbatim. /// Returns SSE `data: ...` lines verbatim.
pub async fn execute_streaming_request( pub async fn execute_streaming_request(
state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
path_suffix: &str, path_suffix: &str,
body: Value, body: Value,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let client = http_client(); let resp = execute_streaming_response(state, route, path_suffix, body).await?;
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.post(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
let mut chunks = Vec::new(); let mut chunks = Vec::new();
let mut stream = resp.bytes_stream(); let mut stream = resp.bytes_stream();
@@ -455,14 +449,50 @@ pub async fn execute_streaming_request(
Ok(chunks) Ok(chunks)
} }
/// Execute a streaming request against any upstream path and return the live response.
///
/// The caller owns draining `bytes_stream()` so bytes can be flushed to the
/// downstream client as soon as the upstream sends them.
pub async fn execute_streaming_response(
state: AppState,
route: UpstreamRoute,
path_suffix: &str,
body: Value,
) -> AppResult<reqwest::Response> {
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client
.post(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp
.text()
.await
.unwrap_or_else(|_| "unknown error".to_string());
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
Ok(resp)
}
/// Generic non-streaming proxy request to any upstream endpoint path. /// Generic non-streaming proxy request to any upstream endpoint path.
pub async fn execute_proxy_request( pub async fn execute_proxy_request(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
path_suffix: &str, path_suffix: &str,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix); let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client let resp = client
@@ -492,8 +522,12 @@ pub async fn execute_proxy_request(
} }
/// Generic GET proxy request to an upstream endpoint path. /// Generic GET proxy request to an upstream endpoint path.
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> { pub async fn execute_proxy_get(
let client = http_client(); state: AppState,
route: UpstreamRoute,
path_suffix: &str,
) -> AppResult<Value> {
let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, path_suffix); let upstream_url = upstream_url(&route.base_url, path_suffix);
let resp = client let resp = client
@@ -518,6 +552,23 @@ pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppRe
parse_upstream_json(&text, &upstream_url, status) parse_upstream_json(&text, &upstream_url, status)
} }
pub async fn execute_video_task_status(
state: AppState,
route: UpstreamRoute,
id: &str,
) -> AppResult<Value> {
if comfy_video_template_id(&route).is_some() {
return comfy_history(&state, id).await;
}
let suffix = if is_volcengine_ark_base(&route.base_url) {
format!("/contents/generations/tasks/{id}")
} else {
format!("/videos/{id}")
};
execute_proxy_get(state, route, &suffix).await
}
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native /// Execute an OpenAI-shaped video task request, with Volcengine Ark native
/// task endpoints as a first-class compatibility target. /// task endpoints as a first-class compatibility target.
pub async fn execute_video_generation( pub async fn execute_video_generation(
@@ -525,6 +576,10 @@ pub async fn execute_video_generation(
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
if let Some(template_id) = comfy_video_template_id(&route) {
return execute_comfy_video_generation(state, route, body, &template_id).await;
}
if is_volcengine_ark_base(&route.base_url) { if is_volcengine_ark_base(&route.base_url) {
let body = volcengine_video_create_body(body, &route.model)?; let body = volcengine_video_create_body(body, &route.model)?;
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await; return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
@@ -533,6 +588,76 @@ pub async fn execute_video_generation(
execute_proxy_request(state, route, "/videos", body).await execute_proxy_request(state, route, "/videos", body).await
} }
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
route.base_url
.strip_prefix("comfy://")
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
.or_else(|| route.model.strip_prefix("comfy://"))
.or_else(|| route.model.strip_prefix("comfy:"))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
async fn execute_comfy_video_generation(
state: AppState,
route: UpstreamRoute,
body: Value,
template_id: &str,
) -> AppResult<Value> {
if !state.config.comfyui.uses_native_backend() {
return Err(AppError::BadRequest(
"Comfy 视频模型需要配置 comfyui.backend = cloud、external 或 runninghub".into(),
));
}
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
.await
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
return Err(AppError::BadRequest(
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
));
}
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
mapping = infer_comfy_mapping(&prompt);
}
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, &body)?;
let client_id = format!("boosapi-videos-{}", uuid::Uuid::new_v4().simple());
let response = comfy_json(
&state,
reqwest::Method::POST,
"/prompt",
Some(json!({
"client_id": client_id,
"prompt": prompt,
})),
)
.await?;
let prompt_id = scalar_string(
response
.get("prompt_id")
.or_else(|| response.get("promptId"))
.or_else(|| response.pointer("/data/prompt_id"))
.or_else(|| response.pointer("/data/promptId")),
)
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
Ok(json!({
"id": prompt_id,
"object": "video.generation.task",
"model": route.model,
"status": "queued",
"backend": "comfy",
"template_id": template_id,
"prompt": video_prompt,
"response": response,
}))
}
fn is_volcengine_ark_base(base_url: &str) -> bool { fn is_volcengine_ark_base(base_url: &str) -> bool {
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3") base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
} }
@@ -602,13 +727,334 @@ fn scalar_to_string(value: &Value) -> Option<String> {
} }
} }
fn scalar_string(value: Option<&Value>) -> Option<String> {
scalar_to_string(value?)
}
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
graph.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
.or_else(|| graph_to_comfy_prompt(graph))
.or_else(|| graph.is_object().then(|| graph.clone()))
}
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
graph.pointer("/metadata/moneyPrinter/nodeMapping")
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
.or_else(|| graph.pointer("/video/nodeMapping"))
.or_else(|| graph.pointer("/metadata/digitalHuman/nodeMapping"))
.or_else(|| graph.pointer("/digitalHuman/nodeMapping"))
.cloned()
}
fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
let nodes = graph.get("nodes")?.as_array()?;
let mut prompt = serde_json::Map::new();
for node in nodes {
let id = scalar_string(node.get("id"))?;
let class_type = node
.get("type")
.or_else(|| node.get("class_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if class_type.trim().is_empty() {
continue;
}
let inputs = node
.get("params")
.or_else(|| node.get("inputs"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
.and_then(|node| node.get_mut("inputs"))
.and_then(Value::as_object_mut)
{
inputs.insert(to_field.to_string(), json!([from_id, 0]));
}
}
Some(Value::Object(prompt))
}
fn default_comfy_mapping() -> Value {
json!({
"prompt": { "nodeId": "", "field": "prompt" },
"negativePrompt": { "nodeId": "", "field": "text" },
"ratio": { "nodeId": "", "field": "ratio" },
"resolution": { "nodeId": "", "field": "resolution" },
"duration": { "nodeId": "", "field": "duration" }
})
}
fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
keys.iter().find_map(|key| mapping.get(*key))
}
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
} else {
scalar_string(Some(value))
}
})
}
fn mapping_field(mapping: &Value, keys: &[&str], default_field: &str) -> String {
mapping_value(mapping, keys)
.and_then(|value| {
if value.is_object() {
scalar_string(value.get("field").or_else(|| value.get("fieldName")))
} else {
None
}
})
.unwrap_or_else(|| default_field.to_string())
}
fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
.map(|(id, _)| id.clone())
}
fn infer_comfy_mapping(prompt: &Value) -> Value {
let Some(object) = prompt.as_object() else {
return default_comfy_mapping();
};
let prompt_node = object
.iter()
.find(|(_, node)| {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let inputs = node
.get("inputs")
.and_then(Value::as_object)
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
.unwrap_or_default();
class_type.contains("text")
|| class_type.contains("prompt")
|| class_type.contains("wan")
|| class_type.contains("ltx")
|| class_type.contains("video")
|| inputs.contains("prompt")
|| inputs.contains("positive")
|| inputs.contains("text")
})
.map(|(id, _)| id.clone())
.unwrap_or_default();
let video_node = first_node_by_class(object, "Wan2ImageToVideoApi")
.or_else(|| first_node_by_class(object, "Wan2TextToVideoApi"))
.or_else(|| first_node_by_class(object, "TextGenerateLTX2Prompt"))
.or_else(|| first_node_by_class(object, "KSampler"))
.unwrap_or_else(|| prompt_node.clone());
json!({
"prompt": { "nodeId": prompt_node, "field": "text" },
"ratio": { "nodeId": video_node, "field": "ratio" },
"resolution": { "nodeId": video_node, "field": "resolution" },
"duration": { "nodeId": video_node, "field": "duration" }
})
}
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
if node_id.trim().is_empty() {
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
};
inputs.insert(field.to_string(), value);
Ok(())
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
let video_prompt = scalar_string(body.get("prompt"))
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型缺少提示词节点映射".into()))?;
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
json!(negative),
)?;
}
for (keys, request_keys, default_field) in [
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
] {
if let Some(node_id) = mapping_node_id(mapping, keys) {
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
}
}
}
Ok(video_prompt)
}
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
match prompt {
Value::Object(object) => {
object
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
fn comfy_native_path(state: &AppState, path: &str) -> String {
if state.config.comfyui.uses_cloud_backend() {
format!(
"/api{}",
path.strip_prefix('/')
.map(|value| format!("/{value}"))
.unwrap_or_else(|| path.to_string())
)
} else {
path.to_string()
}
}
fn comfy_native_url(state: &AppState, path: &str) -> String {
format!(
"{}{}",
state.config.comfyui.target_base_url(),
comfy_native_path(state, path)
)
}
async fn comfy_json(
state: &AppState,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let client = state.http.clone();
let mut request = client.request(method, comfy_native_url(state, path));
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Comfy backend unreachable: {err}")))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(value)
}
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
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!({
"id": prompt_id,
"object": "video.generation.task",
"backend": "comfy",
"status": if failed.is_some() {
"failed"
} else if comfy_output_objects(&history) {
"succeeded"
} else {
"running"
},
"error": failed,
"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 {
match value {
Value::Object(object) => {
["videos", "gifs", "images"]
.iter()
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|| object.values().any(comfy_output_objects)
}
Value::Array(items) => items.iter().any(comfy_output_objects),
_ => false,
}
}
/// Execute an image generation request. /// Execute an image generation request.
pub async fn execute_image_generation( pub async fn execute_image_generation(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/images/generations"); let upstream_url = upstream_url(&route.base_url, "/images/generations");
let resp = client let resp = client
@@ -661,13 +1107,13 @@ pub struct MultipartField {
/// the route-resolved upstream model and skip any client-side fields /// the route-resolved upstream model and skip any client-side fields
/// that don't belong on the wire. /// that don't belong on the wire.
pub async fn execute_image_edit( pub async fn execute_image_edit(
_state: AppState, state: AppState,
route: UpstreamRoute, route: UpstreamRoute,
fields: Vec<MultipartField>, fields: Vec<MultipartField>,
) -> AppResult<Value> { ) -> AppResult<Value> {
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
let client = http_client(); let client = state.http.clone();
let upstream_url = upstream_url(&route.base_url, "/images/edits"); let upstream_url = upstream_url(&route.base_url, "/images/edits");
let mut form = Form::new(); let mut form = Form::new();
+127 -53
View File
@@ -2,16 +2,52 @@ use super::types::{RouteDecision, RouteMatch};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics}; use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
use crate::proxy::executors::UpstreamRoute; use crate::proxy::executors::UpstreamRoute;
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use regex::Regex; use regex::Regex;
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use std::sync::Arc; use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
const ROUTE_CACHE_TTL: Duration = Duration::from_secs(2);
static ROUTE_ROWS_CACHE: Lazy<RwLock<Option<TimedCache<Vec<RouteRow>>>>> =
Lazy::new(|| RwLock::new(None));
static CHANNEL_ROWS_CACHE: Lazy<RwLock<HashMap<i64, TimedCache<Vec<ChannelRow>>>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
#[derive(Debug, Clone)]
struct TimedCache<T> {
loaded_at: Instant,
value: T,
}
impl<T> TimedCache<T> {
fn fresh(&self) -> bool {
self.loaded_at.elapsed() <= ROUTE_CACHE_TTL
}
}
pub fn invalidate_route_cache() {
*ROUTE_ROWS_CACHE.write() = None;
CHANNEL_ROWS_CACHE.write().clear();
}
fn invalidate_channel_cache_for_channel(channel_id: i64) {
CHANNEL_ROWS_CACHE
.write()
.retain(|_, cache| !cache.value.iter().any(|row| row.channel_id == channel_id));
}
#[derive(Clone)] #[derive(Clone)]
pub struct TokenRouter { pub struct TokenRouter {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
} }
#[derive(Debug, FromRow)] #[derive(Debug, Clone, FromRow)]
struct RouteRow { struct RouteRow {
id: i64, id: i64,
model_pattern: String, model_pattern: String,
@@ -19,7 +55,7 @@ struct RouteRow {
route_mode: Option<String>, route_mode: Option<String>,
} }
#[derive(Debug, FromRow)] #[derive(Debug, Clone, FromRow)]
struct ChannelRow { struct ChannelRow {
channel_id: i64, channel_id: i64,
route_id: i64, route_id: i64,
@@ -91,14 +127,7 @@ impl TokenRouter {
} }
async fn match_route(&self, model: &str) -> AppResult<Option<(RouteRow, RouteMatch)>> { async fn match_route(&self, model: &str) -> AppResult<Option<(RouteRow, RouteMatch)>> {
let rows: Vec<RouteRow> = sqlx::query_as( let rows = self.route_rows().await?;
"SELECT id, model_pattern, model_mapping, route_mode
FROM token_routes
WHERE enabled = 1
ORDER BY id",
)
.fetch_all(&*self.db)
.await?;
let mut best: Option<(RouteRow, RouteMatch, usize, usize)> = None; let mut best: Option<(RouteRow, RouteMatch, usize, usize)> = None;
for row in rows { for row in rows {
@@ -131,48 +160,7 @@ impl TokenRouter {
requested_model: &str, requested_model: &str,
exclude_channel_ids: &[i64], exclude_channel_ids: &[i64],
) -> AppResult<Option<SelectedChannel>> { ) -> AppResult<Option<SelectedChannel>> {
let rows: Vec<ChannelRow> = sqlx::query_as( let rows = self.channel_rows(route.id).await?;
"SELECT
rc.id AS channel_id,
rc.route_id AS route_id,
rc.account_id AS account_id,
a.site_id AS site_id,
COALESCE((
SELECT sae.url
FROM site_api_endpoints sae
WHERE sae.site_id = s.id
AND COALESCE(sae.enabled, 1) = 1
AND (sae.cooldown_until IS NULL OR sae.cooldown_until <= datetime('now'))
ORDER BY COALESCE(sae.sort_order, 0), sae.id
LIMIT 1
), s.url) AS base_url,
s.api_key AS site_api_key,
a.access_token AS account_access_token,
a.api_token AS account_api_token,
a.unit_cost AS account_unit_cost,
at.name AS token_name,
at.token AS token_value,
rc.source_model AS source_model
FROM route_channels rc
JOIN accounts a ON a.id = rc.account_id
JOIN sites s ON s.id = a.site_id
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
WHERE rc.route_id = ?
AND rc.enabled = 1
AND a.status = 'active'
AND s.status = 'active'
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
ORDER BY
COALESCE(rc.priority, 0) DESC,
rc.consecutive_fail_count ASC,
COALESCE(rc.weight, 10) DESC,
rc.last_selected_at IS NOT NULL,
rc.id ASC",
)
.bind(route.id)
.fetch_all(&*self.db)
.await?;
let row = rows let row = rows
.into_iter() .into_iter()
@@ -214,6 +202,89 @@ impl TokenRouter {
})) }))
} }
async fn route_rows(&self) -> AppResult<Vec<RouteRow>> {
if let Some(cache) = ROUTE_ROWS_CACHE.read().as_ref() {
if cache.fresh() {
return Ok(cache.value.clone());
}
}
let rows: Vec<RouteRow> = sqlx::query_as(
"SELECT id, model_pattern, model_mapping, route_mode
FROM token_routes
WHERE enabled = 1
ORDER BY id",
)
.fetch_all(&*self.db)
.await?;
*ROUTE_ROWS_CACHE.write() = Some(TimedCache {
loaded_at: Instant::now(),
value: rows.clone(),
});
Ok(rows)
}
async fn channel_rows(&self, route_id: i64) -> AppResult<Vec<ChannelRow>> {
if let Some(cache) = CHANNEL_ROWS_CACHE.read().get(&route_id) {
if cache.fresh() {
return Ok(cache.value.clone());
}
}
let rows: Vec<ChannelRow> = sqlx::query_as(
"SELECT
rc.id AS channel_id,
rc.route_id AS route_id,
rc.account_id AS account_id,
a.site_id AS site_id,
COALESCE((
SELECT sae.url
FROM site_api_endpoints sae
WHERE sae.site_id = s.id
AND COALESCE(sae.enabled, 1) = 1
AND (sae.cooldown_until IS NULL OR sae.cooldown_until <= datetime('now'))
ORDER BY COALESCE(sae.sort_order, 0), sae.id
LIMIT 1
), s.url) AS base_url,
s.api_key AS site_api_key,
a.access_token AS account_access_token,
a.api_token AS account_api_token,
a.unit_cost AS account_unit_cost,
at.name AS token_name,
at.token AS token_value,
rc.source_model AS source_model
FROM route_channels rc
JOIN accounts a ON a.id = rc.account_id
JOIN sites s ON s.id = a.site_id
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
WHERE rc.route_id = ?
AND rc.enabled = 1
AND a.status = 'active'
AND s.status = 'active'
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
ORDER BY
COALESCE(rc.priority, 0) DESC,
rc.consecutive_fail_count ASC,
COALESCE(rc.weight, 10) DESC,
rc.last_selected_at IS NOT NULL,
rc.id ASC",
)
.bind(route_id)
.fetch_all(&*self.db)
.await?;
CHANNEL_ROWS_CACHE.write().insert(
route_id,
TimedCache {
loaded_at: Instant::now(),
value: rows.clone(),
},
);
Ok(rows)
}
pub async fn record_success(&self, channel_id: i64, metrics: SuccessMetrics) -> AppResult<()> { pub async fn record_success(&self, channel_id: i64, metrics: SuccessMetrics) -> AppResult<()> {
sqlx::query( sqlx::query(
"UPDATE route_channels "UPDATE route_channels
@@ -232,6 +303,7 @@ impl TokenRouter {
.bind(channel_id) .bind(channel_id)
.execute(&*self.db) .execute(&*self.db)
.await?; .await?;
invalidate_channel_cache_for_channel(channel_id);
Ok(()) Ok(())
} }
@@ -254,6 +326,7 @@ impl TokenRouter {
.bind(channel_id) .bind(channel_id)
.execute(&*self.db) .execute(&*self.db)
.await?; .await?;
invalidate_channel_cache_for_channel(channel_id);
Ok(()) Ok(())
} }
} }
@@ -359,6 +432,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'
)", )",
) )
File diff suppressed because it is too large Load Diff
+2
View File
@@ -409,6 +409,7 @@ mod tests {
first_byte_timeout_secs: 30, first_byte_timeout_secs: 30,
max_retries: 3, max_retries: 3,
proxy_log_retention_days: 7, proxy_log_retention_days: 7,
template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
}, },
@@ -416,6 +417,7 @@ mod tests {
BuiltinKeys { BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()), comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
}, },
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
) )
} }
+15 -2
View File
@@ -217,6 +217,7 @@ const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
fn is_hidden_setting_key(key: &str) -> bool { fn is_hidden_setting_key(key: &str) -> bool {
key.starts_with(HIDDEN_SETTINGS_PREFIX) key.starts_with(HIDDEN_SETTINGS_PREFIX)
|| key == crate::services::runtime_auth::PROXY_TOKEN_SETTING_KEY
} }
/// GET /api/settings — return all settings as a key-value map. /// GET /api/settings — return all settings as a key-value map.
@@ -313,6 +314,12 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
)
.execute(&pool)
.await
.unwrap();
AppState::new( AppState::new(
AppConfig { AppConfig {
@@ -341,6 +348,7 @@ mod tests {
first_byte_timeout_secs: 30, first_byte_timeout_secs: 30,
max_retries: 3, max_retries: 3,
proxy_log_retention_days: 7, proxy_log_retention_days: 7,
template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
}, },
@@ -348,11 +356,12 @@ mod tests {
BuiltinKeys { BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-secret".into()), comfyui_agent: (1, "sk-comfyui-secret".into()),
}, },
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
) )
} }
/// 5.2 — `GET /api/settings` returns regular settings but never any /// 5.2 — `GET /api/settings` returns regular settings but never protected
/// `builtin_key.*` rows. /// secret rows.
#[tokio::test] #[tokio::test]
async fn list_settings_filters_builtin_key_prefix() { async fn list_settings_filters_builtin_key_prefix() {
let state = settings_state().await; let state = settings_state().await;
@@ -363,6 +372,10 @@ mod tests {
!data.contains_key("builtin_key.comfyui_agent.secret"), !data.contains_key("builtin_key.comfyui_agent.secret"),
"builtin_key.* rows must not appear in /api/settings response" "builtin_key.* rows must not appear in /api/settings response"
); );
assert!(
!data.contains_key("runtime.auth.proxy_token"),
"runtime proxy token must not appear in /api/settings response"
);
} }
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*` /// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
+68 -4
View File
@@ -140,6 +140,10 @@ pub async fn create_site(
return Err(AppError::BadRequest("platform is required".into())); return Err(AppError::BadRequest("platform is required".into()));
} }
let site_url = normalize_site_url(&payload.url)?;
let platform = payload.platform.trim().to_string();
ensure_site_unique(&state, &platform, &site_url, None).await?;
let status = payload.status.unwrap_or_else(|| "active".to_string()); let status = payload.status.unwrap_or_else(|| "active".to_string());
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
@@ -154,8 +158,8 @@ pub async fn create_site(
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&payload.name) .bind(&payload.name)
.bind(&payload.url) .bind(&site_url)
.bind(&payload.platform) .bind(&platform)
.bind(&status) .bind(&status)
.bind(&payload.api_key) .bind(&payload.api_key)
.bind(&payload.proxy_url) .bind(&payload.proxy_url)
@@ -217,6 +221,27 @@ pub async fn update_site(
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?; .ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
if payload
.platform
.as_deref()
.is_some_and(|platform| platform.trim().is_empty())
{
return Err(AppError::BadRequest("platform is required".into()));
}
let next_url = match payload.url.as_deref() {
Some(url) => normalize_site_url(url)?,
None => existing.url.clone(),
};
let next_platform = payload
.platform
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(existing.platform.as_str())
.to_string();
ensure_site_unique(&state, &next_platform, &next_url, Some(id)).await?;
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
sqlx::query( sqlx::query(
@@ -230,8 +255,8 @@ pub async fn update_site(
WHERE id = ?", WHERE id = ?",
) )
.bind(payload.name.unwrap_or(existing.name)) .bind(payload.name.unwrap_or(existing.name))
.bind(payload.url.unwrap_or(existing.url)) .bind(next_url)
.bind(payload.platform.unwrap_or(existing.platform)) .bind(next_platform)
.bind(payload.status.unwrap_or(existing.status)) .bind(payload.status.unwrap_or(existing.status))
.bind(payload.api_key.or(existing.api_key)) .bind(payload.api_key.or(existing.api_key))
.bind(payload.proxy_url.or(existing.proxy_url)) .bind(payload.proxy_url.or(existing.proxy_url))
@@ -466,6 +491,45 @@ fn normalize_site_url(raw: &str) -> AppResult<String> {
Ok(trimmed) Ok(trimmed)
} }
async fn ensure_site_unique(
state: &AppState,
platform: &str,
url: &str,
except_id: Option<i64>,
) -> AppResult<()> {
let existing: Option<(i64, String)> = if let Some(id) = except_id {
sqlx::query_as(
"SELECT id, name FROM sites WHERE platform = ? AND url = ? AND id != ? LIMIT 1",
)
.bind(platform)
.bind(url)
.bind(id)
.fetch_optional(&*state.db)
.await?
} else {
sqlx::query_as("SELECT id, name FROM sites WHERE platform = ? AND url = ? LIMIT 1")
.bind(platform)
.bind(url)
.fetch_optional(&*state.db)
.await?
};
if let Some((id, name)) = existing {
let display_name = name.trim();
let display_name = if display_name.is_empty() {
"未命名站点"
} else {
display_name
};
return Err(AppError::BadRequest(format!(
"该平台下已存在相同 URL 的站点:{}(ID {})",
display_name, id
)));
}
Ok(())
}
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) { fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
let lower = url.to_ascii_lowercase(); let lower = url.to_ascii_lowercase();
+5 -2
View File
@@ -49,8 +49,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
// Downstream API key // Downstream API key
if auth.starts_with("sk-") { if auth.starts_with("sk-") {
let proxy_token = state.runtime_proxy_token();
let result = let result =
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token)) authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await .await
.map_err(AppError::Unauthorized)?; .map_err(AppError::Unauthorized)?;
@@ -200,7 +201,9 @@ async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target
Ok(request) => request, Ok(request) => request,
Err(err) => { Err(err) => {
let _ = client_socket let _ = client_socket
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into())) .send(Message::Text(
format!("invalid ComfyUI websocket URL: {err}").into(),
))
.await; .await;
return; return;
} }
+133 -20
View File
@@ -243,11 +243,12 @@ async fn resolve_agent_auth(
) )
})?; })?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token { let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(AgentAuthContext { return Ok(AgentAuthContext {
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: proxy_token,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}); });
} }
@@ -282,7 +283,7 @@ async fn resolve_agent_auth(
Err(_) if claims.role == "admin" => Ok(AgentAuthContext { Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: state.runtime_proxy_token(),
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(( Err(message) => Err((
@@ -368,6 +369,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,
}))) })))
@@ -680,6 +683,77 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
.is_ok() .is_ok()
} }
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
}
fn extract_reasoning_delta(parsed: &Value) -> Option<String> {
if let Some(delta) = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
{
if let Some(text) = string_field(
delta,
&[
"reasoning_content",
"reasoning",
"thinking",
"thinking_delta",
"reasoning_delta",
],
) {
return Some(text);
}
if let Some(text) = delta
.get("reasoning")
.and_then(|value| value.get("content").or_else(|| value.get("text")))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
{
return Some(text);
}
}
let event_type = parsed
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match event_type {
"response.reasoning.delta"
| "response.reasoning_text.delta"
| "response.reasoning_summary_text.delta" => {
string_field(parsed, &["delta", "text", "content"])
}
"content_block_delta" => {
let delta = parsed.get("delta")?;
match delta
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"thinking_delta" => string_field(delta, &["thinking", "text", "delta"]),
"signature_delta" => None,
other if other.contains("thinking") || other.contains("reasoning") => {
string_field(delta, &["thinking", "text", "content", "delta"])
}
_ => None,
}
}
other if other.contains("reasoning") || other.contains("thinking") => string_field(
parsed,
&["delta", "text", "content", "thinking", "reasoning"],
),
_ => None,
}
}
fn attachment_summary(attachment: &AgentAttachment) -> String { fn attachment_summary(attachment: &AgentAttachment) -> String {
let mime = attachment let mime = attachment
.mime_type .mime_type
@@ -701,7 +775,8 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("file"); .unwrap_or("file");
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]"); let header =
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
if let Some(text) = attachment if let Some(text) = attachment
.text .text
@@ -935,22 +1010,7 @@ async fn chat_handler(
continue; continue;
}; };
let delta = parsed if let Some(reasoning) = extract_reasoning_delta(&parsed) {
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.cloned()
.unwrap_or(Value::Null);
let reasoning = delta
.get("reasoning_content")
.or_else(|| delta.get("reasoning"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !reasoning.is_empty() {
if !send_sse( if !send_sse(
&tx, &tx,
json!({ json!({
@@ -962,6 +1022,14 @@ async fn chat_handler(
} }
} }
let delta = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.cloned()
.unwrap_or(Value::Null);
let content = delta let content = delta
.get("content") .get("content")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -1234,6 +1302,7 @@ mod tests {
first_byte_timeout_secs: 30, first_byte_timeout_secs: 30,
max_retries: 3, max_retries: 3,
proxy_log_retention_days: 7, proxy_log_retention_days: 7,
template_proxy_url: None,
}, },
comfyui: Default::default(), comfyui: Default::default(),
}, },
@@ -1241,6 +1310,7 @@ mod tests {
BuiltinKeys { BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()), comfyui_agent: (1, "sk-comfyui-test".into()),
}, },
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
) )
} }
@@ -1260,6 +1330,49 @@ mod tests {
result.expect("status_handler returned error").0 result.expect("status_handler returned error").0
} }
#[test]
fn extracts_chat_reasoning_delta() {
let parsed = json!({
"choices": [{
"delta": {
"reasoning_content": "正在拆解镜头..."
}
}]
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("正在拆解镜头...")
);
}
#[test]
fn extracts_responses_reasoning_delta() {
let parsed = json!({
"type": "response.reasoning_summary_text.delta",
"delta": "先分析角色和场景。"
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("先分析角色和场景。")
);
}
#[test]
fn extracts_anthropic_thinking_delta() {
let parsed = json!({
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "thinking_delta",
"thinking": "用户需要一个视频分镜。"
}
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("用户需要一个视频分镜。")
);
}
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot: /// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
/// no env → local; whitespace/empty env → still local (treated as unset); /// no env → local; whitespace/empty env → still local (treated as unset);
/// real env → fallback_remote with trimmed `upstreamBase`. /// real env → fallback_remote with trimmed `upstreamBase`.
File diff suppressed because it is too large Load Diff
+83 -10
View File
@@ -1,14 +1,15 @@
// Events routes — system event notifications // Events routes — system event notifications
use crate::{db::models::event::Event, error::AppResult, AppState}; use crate::{db::models::event::Event, error::AppResult, services::runtime_logs, AppState};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use tracing::{debug, info};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ListEventsQuery { pub struct ListEventsQuery {
@@ -32,17 +33,15 @@ async fn list_events(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<ListEventsQuery>, Query(q): Query<ListEventsQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
info!("Listing events"); debug!("Listing events");
let limit = q.limit.unwrap_or(30).clamp(1, 500); let limit = q.limit.unwrap_or(30).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0); let offset = q.offset.unwrap_or(0).max(0);
let mut sql = "SELECT * FROM events WHERE 1=1".to_string(); let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
let mut bind_idx = 0;
let mut params: Vec<String> = vec![]; let mut params: Vec<String> = vec![];
if let Some(ref t) = q.filter_type { if let Some(ref t) = q.filter_type {
bind_idx += 1; sql.push_str(" AND type = ?");
sql.push_str(&format!(" AND type = ?{}", bind_idx));
params.push(t.clone()); params.push(t.clone());
} }
if let Some(ref r) = q.read { if let Some(ref r) = q.read {
@@ -53,16 +52,41 @@ async fn list_events(
} }
} }
sql.push_str(" ORDER BY created_at DESC"); sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
let mut query = sqlx::query_as::<_, Event>(&sql); let mut query = sqlx::query_as::<_, Event>(&sql);
for p in &params { for p in &params {
query = query.bind(p); query = query.bind(p);
} }
let events: Vec<Event> = query.fetch_all(&*state.db).await?; let events: Vec<Event> = query.fetch_all(&*state.db).await?;
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
Ok(Json(json!({ "data": events }))) if q.filter_type
.as_deref()
.is_none_or(|filter_type| filter_type == "runtime")
{
rows.extend(
runtime_logs::list()
.into_iter()
.filter(|entry| q.read.as_deref() != Some("false") || !entry.is_read)
.map(EventListItem::from),
);
}
rows.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.cmp(&b.id))
});
let start = offset as usize;
let end = start.saturating_add(limit as usize).min(rows.len());
let page = if start < rows.len() {
rows[start..end].to_vec()
} else {
Vec::new()
};
Ok(Json(json!({ "data": page })))
} }
/// GET /api/events/count — unread count /// GET /api/events/count — unread count
@@ -98,5 +122,54 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM events") sqlx::query("DELETE FROM events")
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
runtime_logs::clear();
Ok(Json(json!({ "success": true }))) Ok(Json(json!({ "success": true })))
} }
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct EventListItem {
id: i64,
#[serde(rename = "type")]
event_type: String,
title: String,
message: Option<String>,
level: String,
#[serde(rename = "read")]
is_read: bool,
related_id: Option<i64>,
related_type: Option<String>,
created_at: Option<String>,
}
impl From<Event> for EventListItem {
fn from(event: Event) -> Self {
Self {
id: event.id,
event_type: event.event_type,
title: event.title,
message: event.message,
level: event.level.unwrap_or_else(|| "info".to_string()),
is_read: event.is_read.unwrap_or(false),
related_id: event.related_id,
related_type: event.related_type,
created_at: event.created_at,
}
}
}
impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
fn from(entry: runtime_logs::RuntimeLogEntry) -> Self {
Self {
id: entry.id,
event_type: entry.event_type,
title: entry.title,
message: entry.message,
level: entry.level,
is_read: entry.is_read,
related_id: entry.related_id,
related_type: entry.related_type,
created_at: Some(entry.created_at),
}
}
}
+102 -28
View File
@@ -10,9 +10,14 @@ use include_dir::{include_dir, Dir};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::convert::Infallible; use std::convert::Infallible;
use tower::service_fn; use tower::service_fn;
use tower_http::compression::CompressionLayer;
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;
@@ -23,6 +28,7 @@ mod digital_human;
mod events; mod events;
mod health; mod health;
mod monitor; mod monitor;
mod money_printer;
mod oauth; mod oauth;
mod organizations; mod organizations;
mod picture_agent; mod picture_agent;
@@ -31,7 +37,6 @@ mod search;
mod site_announcements; mod site_announcements;
pub(crate) mod stats; pub(crate) mod stats;
mod tasks; mod tasks;
mod update_center;
mod user; mod user;
mod user_api_keys; mod user_api_keys;
#[path = "video_agent_legacy.rs"] #[path = "video_agent_legacy.rs"]
@@ -46,6 +51,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(admin::routes(state.clone())) .merge(admin::routes(state.clone()))
.merge(proxy::routes()) .merge(proxy::routes())
.merge(picture_agent::routes()) .merge(picture_agent::routes())
.merge(money_printer::routes())
.merge(comfyui_agent::routes()) .merge(comfyui_agent::routes())
.merge(digital_human::routes()) .merge(digital_human::routes())
.merge(comfyui::routes()) .merge(comfyui::routes())
@@ -66,7 +72,6 @@ pub fn build_router(state: AppState) -> Router {
.merge(workflow_ui::routes()) .merge(workflow_ui::routes())
.merge(stats::routes()) .merge(stats::routes())
.merge(tasks::routes()) .merge(tasks::routes())
.merge(update_center::routes())
.merge(user::routes()) .merge(user::routes())
.merge(user_api_keys::routes()) .merge(user_api_keys::routes())
.merge(workspace::routes()) .merge(workspace::routes())
@@ -74,40 +79,42 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state); .with_state(state);
let router = Router::new().merge(api_routes); let router = Router::new().merge(api_routes);
match std::env::var("BOOSAPI_WEB_DIR") let router = match std::env::var("BOOSAPI_WEB_DIR")
.ok() .ok()
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.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)
} }
None => router.fallback(embedded_static_handler), None => router.fallback(embedded_static_handler),
} };
router.layer(CompressionLayer::new())
} }
fn api_not_found_response(path: &str) -> Response { fn api_not_found_response(path: &str) -> Response {
@@ -120,6 +127,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) {
@@ -127,23 +177,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) = static_dir.get_file("index.html") {
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") { if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
return embedded_file_response("index.html", file.contents()); 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 {
@@ -155,14 +237,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()
File diff suppressed because it is too large Load Diff
+38 -36
View File
@@ -1,27 +1,28 @@
use crate::{ use crate::{
AppState,
auth::jwt::validate_jwt, auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask, db::models::background_task::BackgroundTask,
db::models::downstream_api_key::DownstreamApiKey, db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState,
}; };
use axum::{ use axum::{
Json, Router,
body::Body, body::Body,
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::{header, HeaderMap, Response, StatusCode}, http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::{get, post},
Json, Router,
}; };
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::time::Duration; use std::time::Duration;
const TEMPLATE_GALLERY_URL: &str = const TEMPLATE_GALLERY_PAGE_URL: &str =
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json"; "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
const LINUX_DO_TEMPLATE_JSON: &str =
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json");
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation"; const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
const TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300; const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
const PICTURE_GENERATION_RETRY_SECS: u64 = 180; const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
const STALE_RUNNING_JOB_MINUTES: i64 = 12; const STALE_RUNNING_JOB_MINUTES: i64 = 12;
@@ -122,11 +123,12 @@ async fn resolve_picture_agent_auth(
let token = extract_bearer_token(headers) let token = extract_bearer_token(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?; .ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token { let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(PictureAgentAuth { return Ok(PictureAgentAuth {
user_id: None, user_id: None,
role: "admin".to_string(), role: "admin".to_string(),
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: proxy_token,
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}); });
} }
@@ -152,7 +154,7 @@ async fn resolve_picture_agent_auth(
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth { Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
user_id: Some(user_id), user_id: Some(user_id),
role: claims.role, role: claims.role,
outbound_key: state.config.auth.proxy_token.clone(), outbound_key: state.runtime_proxy_token(),
key_name: "global-proxy".to_string(), key_name: "global-proxy".to_string(),
}), }),
Err(message) => Err(AppError::Forbidden(message)), Err(message) => Err(AppError::Forbidden(message)),
@@ -285,6 +287,7 @@ mod tests {
assert!(!is_picture_model_pattern("deepseek-chat")); assert!(!is_picture_model_pattern("deepseek-chat"));
assert!(!is_picture_model_pattern("qwen-plus")); assert!(!is_picture_model_pattern("qwen-plus"));
} }
} }
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String { fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
@@ -344,18 +347,30 @@ async fn templates_handler(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let _auth = resolve_picture_agent_auth(&headers, &state).await?; let _auth = resolve_picture_agent_auth(&headers, &state).await?;
let limit = query.limit.unwrap_or(144).clamp(1, 240); let limit = query.limit.unwrap_or(144).clamp(1, 240);
let items = template_client() let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
.get(TEMPLATE_GALLERY_URL) Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
.send() Err(err) => {
.await tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {err}")))? (Vec::new(), json!({ "ok": false, "message": err }))
.error_for_status() }
.map_err(|err| AppError::UpstreamError(format!("template gallery returned {err}")))? };
.json::<Vec<GalleryItem>>()
.await
.map_err(|err| AppError::UpstreamError(format!("failed to parse templates: {err}")))?;
let templates: Vec<Value> = items Ok(Json(json!({
"success": true,
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
"source": local_status,
"templates": templates,
})))
}
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
if limit == 0 {
return Ok(Vec::new());
}
let items = serde_json::from_str::<Vec<GalleryItem>>(raw)
.map_err(|err| format!("failed to parse local Linux Do templates: {err}"))?;
Ok(items
.into_iter() .into_iter()
.filter_map(|item| { .filter_map(|item| {
let prompt = item.prompt?.trim().to_string(); let prompt = item.prompt?.trim().to_string();
@@ -383,27 +398,14 @@ async fn templates_handler(
"prompt": prompt, "prompt": prompt,
"source": { "source": {
"name": "Linux Do GPT Image Gallery", "name": "Linux Do GPT Image Gallery",
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html", "url": TEMPLATE_GALLERY_PAGE_URL,
"postNumber": post_number, "postNumber": post_number,
"username": item.username, "username": item.username,
}, },
})) }))
}) })
.take(limit) .take(limit)
.collect(); .collect())
Ok(Json(json!({
"success": true,
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
"templates": templates,
})))
}
fn template_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| Client::new())
} }
fn picture_generation_client() -> Client { fn picture_generation_client() -> Client {
+711 -334
View File
File diff suppressed because it is too large Load Diff
+482 -46
View File
@@ -8,6 +8,8 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::Row;
use std::collections::{BTreeMap, HashMap};
use tracing::info; use tracing::info;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -23,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))
@@ -43,6 +54,45 @@ async fn dashboard(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<std::collections::HashMap<String, String>>, Query(q): Query<std::collections::HashMap<String, String>>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
match q.get("view").map(String::as_str) {
Some("summary") => return Ok(Json(build_dashboard_summary(&state).await?)),
Some("insights") => {
let site_availability = build_site_availability(&state).await?;
let model_analysis = build_model_analysis(&state, 7).await?;
return Ok(Json(json!({
"generatedAt": chrono::Utc::now().to_rfc3339(),
"insights": [],
"siteAvailability": site_availability,
"modelAnalysis": model_analysis,
})));
}
_ => {}
}
let summary = build_dashboard_summary(&state).await?;
let site_availability = build_site_availability(&state).await?;
let model_analysis = build_model_analysis(&state, 7).await?;
Ok(Json(json!({
"generatedAt": summary["generatedAt"].clone(),
"totalBalance": summary["totalBalance"].clone(),
"totalUsed": summary["totalUsed"].clone(),
"todaySpend": summary["todaySpend"].clone(),
"todayReward": summary["todayReward"].clone(),
"activeAccounts": summary["activeAccounts"].clone(),
"totalAccounts": summary["totalAccounts"].clone(),
"todayCheckin": summary["todayCheckin"].clone(),
"proxy24h": summary["proxy24h"].clone(),
"performance": summary["performance"].clone(),
"siteAvailability": site_availability,
"modelAnalysis": model_analysis,
"summary": summary["summary"].clone(),
"insights": [],
"stats": summary["stats"].clone(),
})))
}
async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites") let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
@@ -123,9 +173,8 @@ async fn dashboard(
) )
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
let site_availability = build_site_availability(&state).await?;
let payload = json!({ Ok(json!({
"generatedAt": chrono::Utc::now().to_rfc3339(), "generatedAt": chrono::Utc::now().to_rfc3339(),
"totalBalance": total_balance, "totalBalance": total_balance,
"totalUsed": total_used, "totalUsed": total_used,
@@ -147,7 +196,6 @@ async fn dashboard(
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64, "requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64, "tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
}, },
"siteAvailability": site_availability,
"summary": { "summary": {
"sites": sites, "sites": sites,
"accounts": accounts, "accounts": accounts,
@@ -165,30 +213,134 @@ async fn dashboard(
"successRequests": success_24h, "successRequests": success_24h,
"totalCost": total_cost_24h, "totalCost": total_cost_24h,
}, },
}); }))
match q.get("view").map(String::as_str) {
Some("summary") => Ok(Json(json!({
"generatedAt": payload["generatedAt"],
"summary": payload["summary"],
"stats": payload["stats"],
"totalBalance": payload["totalBalance"],
"totalUsed": payload["totalUsed"],
"todaySpend": payload["todaySpend"],
"todayReward": payload["todayReward"],
"activeAccounts": payload["activeAccounts"],
"totalAccounts": payload["totalAccounts"],
"todayCheckin": payload["todayCheckin"],
"proxy24h": payload["proxy24h"],
"performance": payload["performance"],
}))),
Some("insights") => Ok(Json(json!({
"generatedAt": payload["generatedAt"],
"insights": payload["insights"],
"siteAvailability": payload["siteAvailability"],
}))),
_ => Ok(Json(payload)),
} }
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
let days = days.max(1);
let rows = sqlx::query(
r#"
SELECT
COALESCE(NULLIF(model_actual, ''), NULLIF(model_requested, ''), 'unknown') AS model,
date(created_at) AS day,
COUNT(*) AS calls,
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_calls,
COALESCE(SUM(CASE WHEN status != 'success' OR status IS NULL THEN 1 ELSE 0 END), 0) AS failed_calls,
COALESCE(SUM(estimated_cost), 0.0) AS spend,
COALESCE(SUM(total_tokens), 0) AS tokens,
AVG(latency_ms) AS avg_latency_ms
FROM proxy_logs
WHERE created_at >= datetime('now', ?)
GROUP BY model, day
ORDER BY day ASC
"#,
)
.bind(format!("-{} days", days - 1))
.fetch_all(&*state.db)
.await?;
let mut by_model: HashMap<String, (i64, i64, i64, f64, i64, f64, i64)> = HashMap::new();
let mut by_day: BTreeMap<String, f64> = BTreeMap::new();
for row in rows {
let model: String = row
.try_get("model")
.unwrap_or_else(|_| "unknown".to_string());
let day: String = row.try_get("day").unwrap_or_default();
let calls: i64 = row.try_get("calls").unwrap_or_default();
let success_calls: i64 = row.try_get("success_calls").unwrap_or_default();
let failed_calls: i64 = row.try_get("failed_calls").unwrap_or_default();
let spend: f64 = row.try_get("spend").unwrap_or_default();
let tokens: i64 = row.try_get("tokens").unwrap_or_default();
let avg_latency: Option<f64> = row.try_get("avg_latency_ms").ok().flatten();
let entry = by_model.entry(model).or_insert((0, 0, 0, 0.0, 0, 0.0, 0));
entry.0 += calls;
entry.1 += success_calls;
entry.2 += failed_calls;
entry.3 += spend;
entry.4 += tokens;
if let Some(latency) = avg_latency {
entry.5 += latency * calls.max(1) as f64;
entry.6 += calls.max(1);
}
if !day.is_empty() {
*by_day.entry(day).or_insert(0.0) += spend;
}
}
let mut models: Vec<(String, (i64, i64, i64, f64, i64, f64, i64))> =
by_model.into_iter().collect();
models.sort_by(|a, b| b.1 .3.total_cmp(&a.1 .3).then_with(|| b.1 .0.cmp(&a.1 .0)));
let total_calls: i64 = models.iter().map(|(_, item)| item.0).sum();
let total_spend: f64 = models.iter().map(|(_, item)| item.3).sum();
let total_tokens: i64 = models.iter().map(|(_, item)| item.4).sum();
let spend_distribution: Vec<Value> = models
.iter()
.map(|(model, item)| {
json!({
"model": model,
"calls": item.0,
"spend": (item.3 * 1_000_000.0).round() / 1_000_000.0,
})
})
.collect();
let calls_distribution: Vec<Value> = models
.iter()
.map(|(model, item)| {
let share = if total_calls > 0 {
item.0 as f64 * 100.0 / total_calls as f64
} else {
0.0
};
json!({
"model": model,
"calls": item.0,
"share": share,
})
})
.collect();
let call_ranking: Vec<Value> = models
.iter()
.map(|(model, item)| {
let success_rate = if item.0 > 0 {
item.1 as f64 * 100.0 / item.0 as f64
} else {
0.0
};
let avg_latency = if item.6 > 0 {
(item.5 / item.6 as f64).round() as i64
} else {
0
};
json!({
"model": model,
"calls": item.0,
"successRate": success_rate,
"avgLatencyMs": avg_latency,
"spend": (item.3 * 1_000_000.0).round() / 1_000_000.0,
"tokens": item.4,
})
})
.collect();
let spend_trend: Vec<Value> = by_day
.into_iter()
.map(|(day, spend)| json!({ "day": day, "spend": spend }))
.collect();
Ok(json!({
"totals": {
"spend": (total_spend * 1_000_000.0).round() / 1_000_000.0,
"calls": total_calls,
"tokens": total_tokens,
},
"spendDistribution": spend_distribution,
"spendTrend": spend_trend,
"callsDistribution": calls_distribution,
"callRanking": call_ranking,
}))
} }
async fn build_site_availability(state: &AppState) -> AppResult<Vec<Value>> { async fn build_site_availability(state: &AppState) -> AppResult<Vec<Value>> {
@@ -275,6 +427,7 @@ async fn proxy_logs(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
@@ -354,6 +507,9 @@ pub struct ProxyLogRow {
client_app_id: Option<String>, client_app_id: Option<String>,
client_app_name: Option<String>, client_app_name: Option<String>,
client_confidence: Option<String>, client_confidence: Option<String>,
content_summary: Option<String>,
request_content: Option<String>,
response_content: Option<String>,
created_at: Option<String>, created_at: Option<String>,
} }
@@ -370,6 +526,7 @@ async fn proxy_log_detail(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
@@ -391,14 +548,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=...
@@ -489,28 +847,70 @@ async fn site_distribution(
.to_string(); .to_string();
// Sum per-site usage // Sum per-site usage
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as( let mut rows: Vec<(i64, String, Option<String>, i64, f64, i64, i64, f64)> = sqlx::query_as(
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0) "SELECT
s.id,
s.name,
s.platform,
COALESCE(SUM(m.total_calls), 0),
COALESCE(SUM(m.total_spend), 0.0),
COALESCE(SUM(m.total_tokens), 0),
COUNT(DISTINCT a.id),
COALESCE(SUM(a.balance), 0.0)
FROM sites s FROM sites s
LEFT JOIN accounts a ON a.site_id = s.id
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ? LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
GROUP BY s.id, s.name GROUP BY s.id, s.name, s.platform
ORDER BY SUM(m.total_spend) DESC" ORDER BY SUM(m.total_spend) DESC",
) )
.bind(&since_day) .bind(&since_day)
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
if rows
.iter()
.all(|(_, _, _, calls, spend, tokens, _, _)| *calls == 0 && *spend == 0.0 && *tokens == 0)
{
rows = sqlx::query_as(
"SELECT
s.id,
s.name,
s.platform,
COUNT(l.id),
COALESCE(SUM(l.estimated_cost), 0.0),
COALESCE(SUM(l.total_tokens), 0),
COUNT(DISTINCT a.id),
COALESCE(SUM(DISTINCT a.balance), 0.0)
FROM sites s
LEFT JOIN accounts a ON a.site_id = s.id
LEFT JOIN proxy_logs l
ON l.account_id = a.id
AND date(l.created_at) >= ?
GROUP BY s.id, s.name, s.platform
ORDER BY COALESCE(SUM(l.estimated_cost), 0.0) DESC, COUNT(l.id) DESC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
}
let distribution: Vec<Value> = rows let distribution: Vec<Value> = rows
.into_iter() .into_iter()
.map(|(id, name, calls, spend, tokens)| { .map(
|(id, name, platform, calls, spend, tokens, account_count, balance)| {
json!({ json!({
"siteId": id, "siteId": id,
"siteName": name, "siteName": name,
"platform": platform,
"calls": calls, "calls": calls,
"spend": (spend * 1_000_000.0).round() / 1_000_000.0, "spend": (spend * 1_000_000.0).round() / 1_000_000.0,
"totalSpend": (spend * 1_000_000.0).round() / 1_000_000.0,
"totalBalance": balance,
"accountCount": account_count,
"tokens": tokens, "tokens": tokens,
}) })
}) },
)
.collect(); .collect();
Ok(Json(json!({ "distribution": distribution }))) Ok(Json(json!({ "distribution": distribution })))
@@ -528,25 +928,61 @@ async fn site_trend(
.format("%Y-%m-%d") .format("%Y-%m-%d")
.to_string(); .to_string();
let rows: Vec<(String, i64, f64, i64)> = sqlx::query_as( let mut rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
"SELECT local_day, COALESCE(SUM(total_calls), 0), COALESCE(SUM(total_spend), 0.0), COALESCE(SUM(total_tokens), 0) "SELECT
FROM model_day_usage m.local_day,
WHERE local_day >= ? s.name,
GROUP BY local_day COALESCE(SUM(m.total_calls), 0),
ORDER BY local_day ASC", COALESCE(SUM(m.total_spend), 0.0),
COALESCE(SUM(m.total_tokens), 0)
FROM model_day_usage m
JOIN sites s ON s.id = m.site_id
WHERE m.local_day >= ?
GROUP BY m.local_day, s.id, s.name
ORDER BY m.local_day ASC, s.name ASC",
) )
.bind(&since_day) .bind(&since_day)
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
let trend: Vec<Value> = rows if rows.is_empty() {
.into_iter() rows = sqlx::query_as(
.map(|(day, calls, spend, tokens)| { "SELECT
date(l.created_at),
COALESCE(s.name, '未知站点'),
COUNT(l.id),
COALESCE(SUM(l.estimated_cost), 0.0),
COALESCE(SUM(l.total_tokens), 0)
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 date(l.created_at) >= ?
GROUP BY date(l.created_at), s.id, s.name
ORDER BY date(l.created_at) ASC, s.name ASC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
}
let mut by_day: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
for (day, site, calls, spend, tokens) in rows {
by_day.entry(day).or_default().insert(
site,
json!({ json!({
"date": day,
"calls": calls, "calls": calls,
"spend": spend, "spend": spend,
"tokens": tokens, "tokens": tokens,
}),
);
}
let trend: Vec<Value> = by_day
.into_iter()
.map(|(day, sites)| {
json!({
"date": day,
"sites": sites,
}) })
}) })
.collect(); .collect();
-121
View File
@@ -1,18 +1,11 @@
// Background tasks routes — list and inspect async operations // Background tasks routes — list and inspect async operations
use std::convert::Infallible;
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState}; use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use futures::stream::Stream;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use tracing::info;
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/tasks", get(list_tasks)) .route("/api/tasks", get(list_tasks))
.route("/api/tasks/{id}", get(get_task)) .route("/api/tasks/{id}", get(get_task))
.route(
"/api/update-center/tasks/{id}/stream",
get(stream_task_logs),
)
} }
/// GET /api/tasks — list background tasks /// GET /api/tasks — list background tasks
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
)), )),
} }
} }
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
async fn stream_task_logs(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
info!("Streaming task logs for: {}", id);
// Check task exists
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {e}"),
)
.into_response()
})?;
let task = match task {
Some(t) => t,
None => {
return Err((
axum::http::StatusCode::NOT_FOUND,
json!({ "success": false, "message": "task not found" }).to_string(),
)
.into_response());
}
};
// Stream task state with polling
let initial_status = task.status.clone();
let is_done = initial_status != "pending" && initial_status != "running";
let state_clone = state.clone();
let stream = async_stream::stream! {
// Yield current task status
yield Ok::<_, Infallible>(Event::default()
.event("status")
.data(json!({ "status": initial_status }).to_string()));
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": 1,
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
"createdAt": task.created_at.as_deref().unwrap_or(""),
}).to_string()));
if is_done {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": initial_status }).to_string()));
return;
}
// Poll for updates
let poll_id = id.clone();
for i in 0..120 { // Poll up to 120 times (2 minutes)
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let current: Option<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&poll_id)
.fetch_optional(&*state_clone.db)
.await
.ok()
.flatten();
match current {
Some(t) => {
let status = t.status;
let updated_at = t.updated_at.unwrap_or_default();
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": i + 2,
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
"createdAt": updated_at,
}).to_string()));
if status != "pending" && status != "running" {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": status }).to_string()));
return;
}
}
None => {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
return;
}
}
}
// Timeout
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "timeout" }).to_string()));
};
Ok(Sse::new(stream))
}
-435
View File
@@ -1,435 +0,0 @@
// Update Center routes — deploy/rollback orchestration via helper API
#![allow(clippy::type_complexity)]
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
const UC_CONFIG_KEY: &str = "update_center_config";
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/update-center/status", get(get_status))
.route("/api/update-center/check", post(check_status))
.route("/api/update-center/config", put(update_config))
.route("/api/update-center/deploy", post(deploy_handler))
.route("/api/update-center/rollback", post(rollback_handler))
}
/// GET /api/update-center/status
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
Ok(Json(json!({
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
"helperBaseUrl": config.get("helperBaseUrl"),
"namespace": config.get("namespace"),
"releaseName": config.get("releaseName"),
"helperTokenConfigured": !helper_token.is_empty(),
"lastDeploy": last_deploy,
"lastRollback": last_rollback,
})))
}
/// POST /api/update-center/check — refresh status cache
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Same as get_status for now
get_status(State(state)).await
}
/// PUT /api/update-center/config
async fn update_config(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let serialized = serde_json::to_string(&body)
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(UC_CONFIG_KEY)
.bind(&serialized)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"config": body,
})))
}
/// POST /api/update-center/deploy
async fn deploy_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let chart_ref = config
.get("chartRef")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let image_repo = config
.get("imageRepository")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty()
|| namespace.is_empty()
|| chart_ref.is_empty()
|| helper_token.is_empty()
{
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_tag = body
.get("targetTag")
.or_else(|| body.get("targetVersion"))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_tag.is_empty() {
return Err(AppError::BadRequest("targetTag is required".into()));
}
// Extract owned values for the spawn
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let chart_ref_owned = chart_ref.to_string();
let image_repo_owned = image_repo.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_tag_owned = target_tag.to_string();
let helper_token_owned = helper_token;
let title = format!("部署 {}", target_tag);
let task_id = uuid::Uuid::new_v4().to_string();
// Create background task
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_DEPLOY)
.bind(&title)
.bind(format!(
"Starting deploy of {} to {}/{}",
target_tag, namespace, chart_ref
))
.bind(DEDUPE_KEY_DEPLOY)
.execute(&*state.db)
.await?;
// Call deploy helper in background (fire-and-forget via tokio)
let client = reqwest::Client::new();
let deploy_url = format!("{}/api/deploy", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"chartRef": chart_ref_owned,
"imageRepository": image_repo_owned,
"targetTag": target_tag_owned,
});
match client
.post(&deploy_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Deploy completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_DEPLOY,
"title": title,
"status": "running",
},
})))
}
/// POST /api/update-center/rollback
async fn rollback_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_revision = body
.get("targetRevision")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_revision.is_empty() {
return Err(AppError::BadRequest("targetRevision is required".into()));
}
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_revision_owned = target_revision.to_string();
let helper_token_owned = helper_token;
let title = format!("回退 {}", target_revision);
let task_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_ROLLBACK)
.bind(&title)
.bind(format!(
"Starting rollback to {} in {}",
target_revision, namespace
))
.bind(DEDUPE_KEY_ROLLBACK)
.execute(&*state.db)
.await?;
let client = reqwest::Client::new();
let rollback_url = format!("{}/api/rollback", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"targetRevision": target_revision_owned,
});
match client
.post(&rollback_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Rollback completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_ROLLBACK,
"title": title,
"status": "running",
},
})))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn get_helper_token() -> String {
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
.unwrap_or_default()
.trim()
.to_string()
}
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(UC_CONFIG_KEY)
.fetch_optional(&*state.db)
.await?;
match row {
Some((v,)) => serde_json::from_str(&v)
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
None => Ok(json!({})),
}
}
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
let row: Option<(
String,
String,
Option<String>,
Option<String>,
String,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT id, status, message, error, created_at, started_at, finished_at
FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(task_type)
.fetch_optional(&*state.db)
.await?;
Ok(row.map(
|(id, status, message, error, created_at, started_at, finished_at)| {
json!({
"id": id,
"type": task_type,
"status": status,
"message": message,
"error": error,
"createdAt": created_at,
"startedAt": started_at,
"finishedAt": finished_at,
})
},
))
}
+160 -16
View File
@@ -22,22 +22,29 @@ pub fn routes() -> Router<AppState> {
.route("/v1/user/api-keys/{id}", delete(delete_api_key)) .route("/v1/user/api-keys/{id}", delete(delete_api_key))
.route("/api/user/logs", get(get_user_logs)) .route("/api/user/logs", get(get_user_logs))
.route("/api/user/summary", get(get_user_summary)) .route("/api/user/summary", get(get_user_summary))
.route("/api/user/dashboard", get(get_user_dashboard))
.route( .route(
"/api/user/models/marketplace", "/api/user/models/marketplace",
get(get_user_models_marketplace), get(get_user_models_marketplace),
) )
} }
async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> { async fn user_picture_log_items_limited(
state: &AppState,
user_id: i64,
limit: i64,
) -> AppResult<Vec<Value>> {
let tasks: Vec<BackgroundTask> = sqlx::query_as( let tasks: Vec<BackgroundTask> = sqlx::query_as(
"SELECT * "SELECT *
FROM background_tasks FROM background_tasks
WHERE task_type = ? WHERE task_type = ?
AND json_extract(result_json, '$.userId') = ? AND json_extract(result_json, '$.userId') = ?
ORDER BY created_at DESC, id DESC", ORDER BY created_at DESC, id DESC
LIMIT ?",
) )
.bind("picture-agent-generation") .bind("picture-agent-generation")
.bind(user_id) .bind(user_id)
.bind(limit.clamp(1, 5000))
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
@@ -56,7 +63,19 @@ async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec
"pending" => "pending", "pending" => "pending",
_ => "running", _ => "running",
}; };
json!({ let content_summary = result
.get("prompt")
.and_then(Value::as_str)
.or_else(|| result.get("input").and_then(Value::as_str))
.or_else(|| result.get("message").and_then(Value::as_str))
.map(|text| text.chars().take(240).collect::<String>());
let request_content = result
.get("prompt")
.and_then(Value::as_str)
.or_else(|| result.get("input").and_then(Value::as_str))
.or_else(|| result.get("message").and_then(Value::as_str))
.map(str::to_string);
let mut item = json!({
"id": task.id, "id": task.id,
"route_id": Value::Null, "route_id": Value::Null,
"channel_id": Value::Null, "channel_id": Value::Null,
@@ -96,12 +115,28 @@ async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec
"progress_total": task.progress_total, "progress_total": task.progress_total,
"status_label": status, "status_label": status,
"result": result, "result": result,
}) });
if let Some(object) = item.as_object_mut() {
object.insert(
"content_summary".to_string(),
content_summary.map(Value::String).unwrap_or(Value::Null),
);
object.insert(
"request_content".to_string(),
request_content.map(Value::String).unwrap_or(Value::Null),
);
object.insert("response_content".to_string(), Value::Null);
}
item
}) })
.collect()) .collect())
} }
async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> { async fn user_usage_log_items_limited(
state: &AppState,
user_id: i64,
limit: i64,
) -> AppResult<Vec<Value>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT SELECT
@@ -117,10 +152,11 @@ async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<V
JOIN downstream_api_keys k ON k.id = l.downstream_key_id JOIN downstream_api_keys k ON k.id = l.downstream_key_id
WHERE k.user_id = ? WHERE k.user_id = ?
ORDER BY l.created_at DESC, l.id DESC ORDER BY l.created_at DESC, l.id DESC
LIMIT 5000 LIMIT ?
"#, "#,
) )
.bind(user_id) .bind(user_id)
.bind(limit.clamp(1, 5000))
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
@@ -169,6 +205,9 @@ async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<V
"client_app_id": "picture-agent", "client_app_id": "picture-agent",
"client_app_name": "Picture Agent", "client_app_name": "Picture Agent",
"client_confidence": "high", "client_confidence": "high",
"content_summary": Value::Null,
"request_content": Value::Null,
"response_content": Value::Null,
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(), "created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
"source": "usage-logs", "source": "usage-logs",
"task_type": "usage-log", "task_type": "usage-log",
@@ -190,6 +229,9 @@ fn user_log_text(item: &Value) -> String {
item.get("site_name").and_then(Value::as_str), item.get("site_name").and_then(Value::as_str),
item.get("site_url").and_then(Value::as_str), item.get("site_url").and_then(Value::as_str),
item.get("error_message").and_then(Value::as_str), item.get("error_message").and_then(Value::as_str),
item.get("content_summary").and_then(Value::as_str),
item.get("request_content").and_then(Value::as_str),
item.get("response_content").and_then(Value::as_str),
item.get("message").and_then(Value::as_str), item.get("message").and_then(Value::as_str),
item.get("title").and_then(Value::as_str), item.get("title").and_then(Value::as_str),
item.get("source").and_then(Value::as_str), item.get("source").and_then(Value::as_str),
@@ -284,6 +326,16 @@ fn sort_user_logs_desc(items: &mut [Value]) {
} }
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> { async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
load_user_log_items_limited(state, user_id, 5000, false).await
}
async fn load_user_log_items_limited(
state: &AppState,
user_id: i64,
limit: i64,
truncate_combined: bool,
) -> AppResult<Vec<Value>> {
let limit = limit.clamp(1, 5000);
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>( let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
r#" r#"
SELECT SELECT
@@ -293,6 +345,7 @@ async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Va
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
@@ -307,22 +360,78 @@ async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Va
LEFT JOIN sites s ON s.id = a.site_id LEFT JOIN sites s ON s.id = a.site_id
WHERE k.user_id = ? WHERE k.user_id = ?
ORDER BY l.created_at DESC ORDER BY l.created_at DESC
LIMIT 5000 LIMIT ?
"#, "#,
) )
.bind(user_id) .bind(user_id)
.bind(limit)
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await? .await?
.into_iter() .into_iter()
.map(|row| serde_json::to_value(row).unwrap_or(json!({}))) .map(|row| serde_json::to_value(row).unwrap_or(json!({})))
.collect(); .collect();
items.extend(user_usage_log_items(state, user_id).await?); items.extend(user_usage_log_items_limited(state, user_id, limit).await?);
items.extend(user_picture_log_items(state, user_id).await?); items.extend(user_picture_log_items_limited(state, user_id, limit).await?);
sort_user_logs_desc(&mut items); sort_user_logs_desc(&mut items);
if truncate_combined {
items.truncate(limit as usize);
}
Ok(items) Ok(items)
} }
async fn load_user_log_summary(state: &AppState, user_id: i64) -> AppResult<(i64, i64, f64)> {
let proxy_summary: (i64, i64, f64) = sqlx::query_as(
r#"
SELECT
COUNT(l.id) AS total,
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success_count,
COALESCE(SUM(l.estimated_cost), 0.0) AS total_cost
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
WHERE k.user_id = ?
"#,
)
.bind(user_id)
.fetch_one(&*state.db)
.await?;
let usage_summary: (i64, f64) = sqlx::query_as(
r#"
SELECT
COUNT(l.id) AS total,
COALESCE(SUM(l.cost), 0.0) AS total_cost
FROM usage_logs l
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
WHERE k.user_id = ?
"#,
)
.bind(user_id)
.fetch_one(&*state.db)
.await?;
let picture_summary: (i64, i64) = sqlx::query_as(
r#"
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END), 0) AS success_count
FROM background_tasks
WHERE task_type = ?
AND json_extract(result_json, '$.userId') = ?
"#,
)
.bind("picture-agent-generation")
.bind(user_id)
.fetch_one(&*state.db)
.await?;
Ok((
proxy_summary.0 + usage_summary.0 + picture_summary.0,
proxy_summary.1 + usage_summary.0 + picture_summary.1,
proxy_summary.2 + usage_summary.1,
))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct UpdateProfilePayload { struct UpdateProfilePayload {
username: Option<String>, username: Option<String>,
@@ -536,13 +645,7 @@ async fn get_user_summary(
headers: HeaderMap, headers: HeaderMap,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?; let user_id = current_user_id(&headers, &state)?;
let items = load_user_log_items(&state, user_id).await?; let (total, success_count, total_cost) = load_user_log_summary(&state, user_id).await?;
let total = items.len() as i64;
let success_count = items
.iter()
.filter(|item| user_log_status(item).as_str() == "success")
.count() as i64;
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
Ok(Json(json!({ Ok(Json(json!({
"totalRequests": total, "totalRequests": total,
@@ -551,6 +654,47 @@ async fn get_user_summary(
}))) })))
} }
async fn get_user_dashboard(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
let keys: Vec<DownstreamApiKey> = sqlx::query_as(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
let (total, success_count, total_cost) = load_user_log_summary(&state, user_id).await?;
let recent_logs = load_user_log_items_limited(&state, user_id, 5, true).await?;
Ok(Json(json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
},
"keys": keys,
"summary": {
"totalRequests": total,
"successRequests": success_count,
"totalCost": total_cost,
},
"recentLogs": recent_logs,
})))
}
async fn get_user_models_marketplace( async fn get_user_models_marketplace(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
+3 -3
View File
@@ -273,7 +273,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
let resp = client let resp = client
.post(url) .post(url)
.bearer_auth(&state.config.auth.proxy_token) .bearer_auth(state.runtime_proxy_token())
.json(&json!({ .json(&json!({
"model": &state.config.comfyui.agent_model, "model": &state.config.comfyui.agent_model,
"messages": [ "messages": [
@@ -319,7 +319,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let resp = client let resp = client
.post(url) .post(url)
.bearer_auth(&state.config.auth.proxy_token) .bearer_auth(state.runtime_proxy_token())
.json(&json!({ .json(&json!({
"model": "gpt-image-2", "model": "gpt-image-2",
"prompt": prompt, "prompt": prompt,
@@ -362,7 +362,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
let resp = client let resp = client
.post(url) .post(url)
.bearer_auth(&state.config.auth.proxy_token) .bearer_auth(state.runtime_proxy_token())
.json(&json!({ .json(&json!({
"model": "tts-1", "model": "tts-1",
"input": text, "input": text,
+2488 -212
View File
File diff suppressed because it is too large Load Diff
+152 -17
View File
@@ -7,12 +7,13 @@ use axum::{
body::Body, body::Body,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{header, HeaderMap, Response, StatusCode}, http::{header, HeaderMap, Response, StatusCode},
routing::get, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::path::PathBuf; use std::{collections::HashSet, path::PathBuf};
use tokio_util::io::ReaderStream;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum WorkspaceCaller { enum WorkspaceCaller {
@@ -33,9 +34,19 @@ struct AssetQuery {
page_size: Option<i64>, page_size: Option<i64>,
} }
#[derive(Debug, Deserialize)]
struct BatchDeleteAssetsRequest {
#[serde(default, alias = "publicIds")]
public_ids: Vec<String>,
}
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/api/workspace/assets", get(list_admin_assets)) .route("/api/workspace/assets", get(list_admin_assets))
.route(
"/api/workspace/assets/batch-delete",
post(batch_delete_admin_assets),
)
.route( .route(
"/api/workspace/assets/{public_id}", "/api/workspace/assets/{public_id}",
get(get_admin_asset).delete(delete_admin_asset), get(get_admin_asset).delete(delete_admin_asset),
@@ -45,6 +56,10 @@ pub fn routes() -> Router<AppState> {
get(get_admin_asset_file), get(get_admin_asset_file),
) )
.route("/api/user/workspace/assets", get(list_user_assets)) .route("/api/user/workspace/assets", get(list_user_assets))
.route(
"/api/user/workspace/assets/batch-delete",
post(batch_delete_user_assets),
)
.route( .route(
"/api/user/workspace/assets/{public_id}", "/api/user/workspace/assets/{public_id}",
get(get_user_asset).delete(delete_user_asset), get(get_user_asset).delete(delete_user_asset),
@@ -76,7 +91,8 @@ fn resolve_caller(
let token = bearer(headers) let token = bearer(headers)
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?; .ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token { let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(WorkspaceCaller::Admin); return Ok(WorkspaceCaller::Admin);
} }
@@ -157,6 +173,20 @@ fn row_to_json(row: sqlx::sqlite::SqliteRow) -> Value {
}) })
} }
const WORKSPACE_OUTPUT_FILTER_SQL: &str = r#"
AND status = 'completed'
AND (
json_extract(metadata, '$.workspaceAsset.role') = 'output'
OR json_extract(metadata, '$.workflowUi.type') = 'output'
OR (
source IN ('picture-agent', 'workflow-studio')
AND json_type(metadata, '$.responseItem') IS NOT NULL
)
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
)
"#;
async fn list_assets( async fn list_assets(
state: AppState, state: AppState,
caller: WorkspaceCaller, caller: WorkspaceCaller,
@@ -167,12 +197,14 @@ async fn list_assets(
WorkspaceCaller::Admin => requested_user_id, WorkspaceCaller::Admin => requested_user_id,
WorkspaceCaller::User { user_id } => Some(user_id), WorkspaceCaller::User { user_id } => Some(user_id),
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let items = sqlx::query( let list_sql = format!(
"SELECT * "SELECT *
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
{WORKSPACE_OUTPUT_FILTER_SQL}
AND (? IS NULL OR source = ?) AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?) AND (? IS NULL OR kind = ?)
AND ( AND (
@@ -183,10 +215,14 @@ async fn list_assets(
OR source LIKE ? OR source LIKE ?
) )
ORDER BY datetime(created_at) DESC, id DESC ORDER BY datetime(created_at) DESC, id DESC
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?"
) );
let items = sqlx::query(&list_sql)
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source) .bind(&source)
.bind(&source) .bind(&source)
.bind(&kind) .bind(&kind)
@@ -202,11 +238,12 @@ async fn list_assets(
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
let total: i64 = sqlx::query_scalar( let count_sql = format!(
"SELECT COUNT(*) "SELECT COUNT(*)
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
{WORKSPACE_OUTPUT_FILTER_SQL}
AND (? IS NULL OR source = ?) AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?) AND (? IS NULL OR kind = ?)
AND ( AND (
@@ -215,10 +252,14 @@ async fn list_assets(
OR prompt LIKE ? OR prompt LIKE ?
OR model LIKE ? OR model LIKE ?
OR source LIKE ? OR source LIKE ?
)", )"
) );
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source) .bind(&source)
.bind(&source) .bind(&source)
.bind(&kind) .bind(&kind)
@@ -249,17 +290,21 @@ async fn get_asset_row(
WorkspaceCaller::Admin => None, WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id), WorkspaceCaller::User { user_id } => Some(user_id),
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let row = sqlx::query( let row = sqlx::query(
"SELECT * "SELECT *
FROM workspace_assets FROM workspace_assets
WHERE public_id = ? WHERE public_id = ?
AND deleted_at IS NULL AND deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
LIMIT 1", LIMIT 1",
) )
.bind(public_id) .bind(&public_id)
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?; .ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
@@ -286,24 +331,90 @@ async fn delete_asset(
WorkspaceCaller::Admin => None, WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id), WorkspaceCaller::User { user_id } => Some(user_id),
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let result = sqlx::query( let result = sqlx::query(
"UPDATE workspace_assets "UPDATE workspace_assets
SET deleted_at = datetime('now'), updated_at = datetime('now') SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE public_id = ? WHERE public_id = ?
AND deleted_at IS NULL AND deleted_at IS NULL
AND (? IS NULL OR user_id = ?)", AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
) )
.bind(public_id) .bind(&public_id)
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(AppError::NotFound("Workspace asset not found".into())); return Err(AppError::NotFound("Workspace asset not found".into()));
} }
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
tracing::warn!(%err, %public_id, "failed to update workspace asset delete projection");
}
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn delete_assets_batch(
state: AppState,
caller: WorkspaceCaller,
public_ids: Vec<String>,
) -> AppResult<Json<Value>> {
let mut seen = HashSet::new();
let public_ids = public_ids
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert(value.clone()))
.take(200)
.collect::<Vec<_>>();
if public_ids.is_empty() {
return Err(AppError::BadRequest("No workspace assets selected".into()));
}
let scoped_user_id = match caller {
WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id),
};
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let mut deleted_ids = Vec::new();
let mut failed_ids = Vec::new();
for public_id in public_ids {
let result = sqlx::query(
"UPDATE workspace_assets
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE public_id = ?
AND deleted_at IS NULL
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
)
.bind(&public_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() > 0 {
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
}
deleted_ids.push(public_id);
} else {
failed_ids.push(public_id);
}
}
Ok(Json(json!({
"success": true,
"deletedCount": deleted_ids.len(),
"deletedIds": deleted_ids,
"failedIds": failed_ids,
})))
}
async fn get_asset_file( async fn get_asset_file(
state: AppState, state: AppState,
caller: WorkspaceCaller, caller: WorkspaceCaller,
@@ -318,7 +429,11 @@ async fn get_asset_file(
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?; .ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
let path = PathBuf::from(file_path); let path = PathBuf::from(file_path);
let bytes = tokio::fs::read(&path) let file = tokio::fs::File::open(&path)
.await
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
let metadata = file
.metadata()
.await .await
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?; .map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
let mime_type = row let mime_type = row
@@ -334,11 +449,13 @@ async fn get_asset_file(
Response::builder() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type) .header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, metadata.len().to_string())
.header(header::ACCEPT_RANGES, "bytes")
.header( .header(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", file_name.replace('"', "")), format!("inline; filename=\"{}\"", file_name.replace('"', "")),
) )
.body(Body::from(bytes)) .body(Body::from_stream(ReaderStream::new(file)))
.map_err(|err| AppError::Internal(anyhow::anyhow!(err))) .map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
} }
@@ -378,6 +495,15 @@ async fn delete_admin_asset(
delete_asset(state, caller, public_id).await delete_asset(state, caller, public_id).await
} }
async fn batch_delete_admin_assets(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAssetsRequest>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, true)?;
delete_assets_batch(state, caller, payload.public_ids).await
}
async fn list_user_assets( async fn list_user_assets(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -413,3 +539,12 @@ async fn delete_user_asset(
let caller = resolve_caller(&headers, &state, false)?; let caller = resolve_caller(&headers, &state, false)?;
delete_asset(state, caller, public_id).await delete_asset(state, caller, public_id).await
} }
async fn batch_delete_user_assets(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAssetsRequest>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, false)?;
delete_assets_batch(state, caller, payload.public_ids).await
}
@@ -1,22 +0,0 @@
export function parseBatchApiKeys(input) {
if (Array.isArray(input)) {
return Array.from(new Set(input
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0)));
}
const raw = String(input || '').trim();
if (!raw)
return [];
return Array.from(new Set(raw
.split(/[\r\n,,;;\s]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
export function buildBatchApiKeyConnectionName(baseName, index, total) {
const normalized = String(baseName || '').trim();
if (!normalized)
return '';
if (total <= 1)
return normalized;
return `${normalized} #${index + 1}`;
}
-779
View File
@@ -1,779 +0,0 @@
const BRAND_DEFINITIONS = [
{
name: 'OpenAI',
icon: 'openai',
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
rules: [
{ keyword: 'gpt', mode: 'startsWith' },
{ keyword: 'chatgpt', mode: 'startsWith' },
{ keyword: 'dall-e', mode: 'startsWith' },
{ keyword: 'whisper', mode: 'startsWith' },
{ keyword: 'text-embedding', mode: 'startsWith' },
{ keyword: 'text-moderation', mode: 'startsWith' },
{ keyword: 'davinci', mode: 'startsWith' },
{ keyword: 'babbage', mode: 'startsWith' },
{ keyword: 'codex-mini', mode: 'startsWith' },
{ keyword: 'o1', mode: 'startsWith' },
{ keyword: 'o3', mode: 'startsWith' },
{ keyword: 'o4', mode: 'startsWith' },
{ keyword: 'tts', mode: 'startsWith' },
],
},
{
name: 'Anthropic',
icon: 'claude-color',
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
rules: [
{ keyword: 'claude', mode: 'includes' },
],
},
{
name: 'Google',
icon: 'gemini-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
rules: [
{ keyword: 'gemini', mode: 'includes' },
{ keyword: 'gemma', mode: 'includes' },
{ keyword: 'google/', mode: 'includes' },
{ keyword: 'palm', mode: 'includes' },
{ keyword: 'paligemma', mode: 'includes' },
{ keyword: 'shieldgemma', mode: 'includes' },
{ keyword: 'recurrentgemma', mode: 'includes' },
{ keyword: 'deplot', mode: 'includes' },
{ keyword: 'codegemma', mode: 'includes' },
{ keyword: 'imagen', mode: 'includes' },
{ keyword: 'learnlm', mode: 'includes' },
{ keyword: 'aqa', mode: 'includes' },
{ keyword: 'veo', mode: 'startsWith' },
{ keyword: 'google/', mode: 'startsWith' },
],
},
{
name: 'DeepSeek',
icon: 'deepseek-color',
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
rules: [
{ keyword: 'deepseek', mode: 'includes' },
{ keyword: 'ds-chat', mode: 'segment' },
],
},
{
name: '通义千问',
icon: 'qwen-color',
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
rules: [
{ keyword: 'qwen', mode: 'includes' },
{ keyword: 'qwq', mode: 'includes' },
{ keyword: 'tongyi', mode: 'includes' },
],
},
{
name: '智谱 AI',
icon: 'zhipu-color',
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
rules: [
{ keyword: 'glm', mode: 'includes' },
{ keyword: 'chatglm', mode: 'includes' },
{ keyword: 'codegeex', mode: 'includes' },
{ keyword: 'cogview', mode: 'includes' },
{ keyword: 'cogvideo', mode: 'includes' },
],
},
{
name: 'Meta',
icon: 'meta-color',
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
rules: [
{ keyword: 'llama', mode: 'includes' },
{ keyword: 'code-llama', mode: 'includes' },
{ keyword: 'codellama', mode: 'includes' },
],
},
{
name: 'Mistral',
icon: 'mistral-color',
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
rules: [
{ keyword: 'mistral', mode: 'includes' },
{ keyword: 'mixtral', mode: 'includes' },
{ keyword: 'codestral', mode: 'includes' },
{ keyword: 'pixtral', mode: 'includes' },
{ keyword: 'ministral', mode: 'includes' },
{ keyword: 'voxtral', mode: 'includes' },
{ keyword: 'magistral', mode: 'includes' },
],
},
{
name: 'Moonshot',
icon: 'moonshot',
color: 'linear-gradient(135deg, #000000, #333333)',
rules: [
{ keyword: 'moonshot', mode: 'includes' },
{ keyword: 'kimi', mode: 'includes' },
],
},
{
name: '零一万物',
icon: 'yi-color',
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
rules: [
{ keyword: 'yi-', mode: 'startsWith' },
{ keyword: 'yi', mode: 'boundary' },
],
},
{
name: '文心一言',
icon: 'wenxin-color',
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
rules: [
{ keyword: 'ernie', mode: 'includes' },
{ keyword: 'eb-', mode: 'includes' },
],
},
{
name: '讯飞星火',
icon: 'spark-color',
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
rules: [
{ keyword: 'spark', mode: 'includes' },
{ keyword: 'generalv', mode: 'includes' },
],
},
{
name: '腾讯混元',
icon: 'hunyuan-color',
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
rules: [
{ keyword: 'hunyuan', mode: 'includes' },
{ keyword: 'tencent-hunyuan', mode: 'includes' },
],
},
{
name: '豆包',
icon: 'doubao-color',
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
rules: [
{ keyword: 'doubao', mode: 'includes' },
],
},
{
name: 'MiniMax',
icon: 'minimax-color',
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
rules: [
{ keyword: 'minimax', mode: 'includes' },
{ keyword: 'abab', mode: 'includes' },
{ keyword: 'mini2.1', mode: 'segment' },
],
},
{
name: 'Cohere',
icon: 'cohere-color',
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
rules: [
{ keyword: 'command', mode: 'includes' },
{ keyword: 'c4ai-', mode: 'includes' },
{ keyword: 'aya', mode: 'includes' },
{ keyword: 'embed-', mode: 'startsWith' },
],
},
{
name: 'Microsoft',
icon: 'microsoft-color',
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
rules: [
{ keyword: 'microsoft/', mode: 'includes' },
{ keyword: 'phi-', mode: 'includes' },
{ keyword: 'kosmos', mode: 'includes' },
{ keyword: 'phi4', mode: 'segment' },
],
},
{
name: 'xAI',
icon: 'xai',
color: 'linear-gradient(135deg, #111, #444)',
rules: [
{ keyword: 'grok', mode: 'includes' },
],
},
{
name: '阶跃星辰',
icon: 'stepfun-color',
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
rules: [
{ keyword: 'stepfun', mode: 'includes' },
{ keyword: 'step-', mode: 'startsWith' },
{ keyword: 'step3', mode: 'startsWith' },
],
},
{
name: '百川智能',
icon: 'baichuan-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'baichuan', mode: 'includes' },
],
},
{
name: 'AI21 Labs',
icon: 'ai21-brand-color',
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
rules: [
{ keyword: 'ai21', mode: 'includes' },
{ keyword: 'jamba', mode: 'startsWith' },
{ keyword: 'jamba', mode: 'includes' },
],
},
{
name: 'AI2',
icon: 'ai2-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'allenai', mode: 'includes' },
{ keyword: 'olmo', mode: 'includes' },
],
},
{
name: 'Amazon Nova',
icon: 'nova',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'amazon/nova', mode: 'startsWith' },
{ keyword: 'amazon.nova', mode: 'includes' },
{ keyword: 'us.amazon.nova', mode: 'includes' },
{ keyword: 'nova-', mode: 'startsWith' },
{ keyword: 'nova-lite', mode: 'startsWith' },
{ keyword: 'nova-pro', mode: 'startsWith' },
{ keyword: 'nova-micro', mode: 'startsWith' },
{ keyword: 'nova-canvas', mode: 'startsWith' },
{ keyword: 'nova-reel', mode: 'startsWith' },
],
},
{
name: 'Stability',
icon: 'stability-color',
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
rules: [
{ keyword: 'flux', mode: 'includes' },
{ keyword: 'stablediffusion', mode: 'includes' },
{ keyword: 'stable-diffusion', mode: 'includes' },
{ keyword: 'sdxl', mode: 'includes' },
{ keyword: 'sd3', mode: 'startsWith' },
],
},
{
name: 'NVIDIA',
icon: 'nvidia-color',
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
rules: [
{ keyword: 'nvidia/', mode: 'includes' },
{ keyword: 'nvclip', mode: 'includes' },
{ keyword: 'nemotron', mode: 'includes' },
{ keyword: 'nemoretriever', mode: 'includes' },
{ keyword: 'neva', mode: 'includes' },
{ keyword: 'riva-translate', mode: 'includes' },
{ keyword: 'cosmos', mode: 'includes' },
{ keyword: 'nv-', mode: 'startsWith' },
],
},
{
name: 'IBM',
icon: 'ibm',
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
rules: [
{ keyword: 'ibm/', mode: 'includes' },
{ keyword: 'granite', mode: 'includes' },
],
},
{
name: 'BAAI',
icon: 'baai',
color: 'linear-gradient(135deg, #111827, #374151)',
rules: [
{ keyword: 'baai/', mode: 'includes' },
{ keyword: 'bge-', mode: 'includes' },
],
},
{
name: 'ByteDance',
icon: 'bytedance-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
rules: [
{ keyword: 'bytedance', mode: 'includes' },
{ keyword: 'seed-oss', mode: 'includes' },
{ keyword: 'kolors', mode: 'includes' },
{ keyword: 'kwai', mode: 'includes' },
{ keyword: 'kwaipilot', mode: 'includes' },
{ keyword: 'wan-', mode: 'startsWith' },
{ keyword: 'kat-', mode: 'startsWith' },
],
},
{
name: 'InternLM',
icon: 'internlm-color',
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
rules: [
{ keyword: 'internlm', mode: 'includes' },
],
},
{
name: 'Midjourney',
icon: 'midjourney',
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
rules: [
{ keyword: 'midjourney', mode: 'includes' },
{ keyword: 'mj_', mode: 'startsWith' },
],
},
{
name: 'DeepL',
icon: 'deepl-color',
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
rules: [
{ keyword: 'deepl-', mode: 'startsWith' },
{ keyword: 'deepl/', mode: 'includes' },
],
},
{
name: 'Jina AI',
icon: 'jina',
color: 'linear-gradient(135deg, #111827, #4b5563)',
rules: [
{ keyword: 'jina', mode: 'includes' },
],
},
{
name: 'Relace',
icon: 'relace',
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
rules: [
{ keyword: 'relace', mode: 'includes' },
],
},
{
name: 'Arcee',
icon: 'arcee-color',
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
rules: [
{ keyword: 'arcee-ai', mode: 'includes' },
{ keyword: 'arcee', mode: 'includes' },
],
},
{
name: 'AionLabs',
icon: 'aionlabs-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'aion-labs', mode: 'includes' },
{ keyword: 'aionlabs', mode: 'includes' },
],
},
{
name: 'DeepCogito',
icon: 'deepcogito-color',
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
rules: [
{ keyword: 'deepcogito', mode: 'includes' },
],
},
{
name: 'Essential AI',
icon: 'essentialai-color',
color: 'linear-gradient(135deg, #0f172a, #334155)',
rules: [
{ keyword: 'essentialai', mode: 'includes' },
],
},
{
name: 'Inception',
icon: 'inception',
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
rules: [
{ keyword: 'inception', mode: 'includes' },
],
},
{
name: 'Inflection',
icon: 'inflection',
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
rules: [
{ keyword: 'inflection', mode: 'includes' },
],
},
{
name: 'Liquid AI',
icon: 'liquid',
color: 'linear-gradient(135deg, #0f172a, #475569)',
rules: [
{ keyword: 'liquid', mode: 'includes' },
{ keyword: 'lfm-', mode: 'startsWith' },
],
},
{
name: 'LongCat',
icon: 'longcat-color',
color: 'linear-gradient(135deg, #f97316, #fb7185)',
rules: [
{ keyword: 'longcat', mode: 'includes' },
],
},
{
name: 'Morph',
icon: 'morph-color',
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
rules: [
{ keyword: 'morph/', mode: 'includes' },
{ keyword: 'morph-', mode: 'startsWith' },
],
},
{
name: 'Nous Research',
icon: 'nousresearch',
color: 'linear-gradient(135deg, #111827, #4b5563)',
rules: [
{ keyword: 'nousresearch', mode: 'includes' },
],
},
{
name: 'Upstage',
icon: 'upstage-color',
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
rules: [
{ keyword: 'upstage', mode: 'includes' },
],
},
{
name: 'Xiaomi MiMo',
icon: 'xiaomimimo',
color: 'linear-gradient(135deg, #f97316, #fb923c)',
rules: [
{ keyword: 'xiaomi/mimo', mode: 'includes' },
{ keyword: 'xiaomimimo', mode: 'includes' },
{ keyword: 'mimo-v', mode: 'startsWith' },
],
},
{
name: 'Z.ai',
icon: 'zai',
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
rules: [
{ keyword: '2zai', mode: 'startsWith' },
{ keyword: 'z-ai', mode: 'startsWith' },
],
},
{
name: 'SenseNova',
icon: 'sensenova-brand-color',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'sensenova', mode: 'includes' },
],
},
{
name: 'Perplexity',
icon: 'perplexity-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'perplexity', mode: 'includes' },
{ keyword: 'pplx-', mode: 'startsWith' },
],
},
{
name: 'OpenRouter',
icon: 'openrouter',
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
rules: [
{ keyword: 'openrouter', mode: 'includes' },
{ keyword: 'openrouter-', mode: 'startsWith' },
],
},
{
name: 'Groq',
icon: 'groq',
color: 'linear-gradient(135deg, #111827, #374151)',
rules: [
{ keyword: 'groq', mode: 'includes' },
],
},
{
name: 'Fireworks',
icon: 'fireworks-color',
color: 'linear-gradient(135deg, #fb7185, #f97316)',
rules: [
{ keyword: 'fireworks-ai', mode: 'includes' },
{ keyword: 'fireworks', mode: 'includes' },
],
},
{
name: 'DeepInfra',
icon: 'deepinfra-color',
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
rules: [
{ keyword: 'deepinfra', mode: 'includes' },
],
},
{
name: 'Together AI',
icon: 'together-brand-color',
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
rules: [
{ keyword: 'together.ai', mode: 'includes' },
{ keyword: 'together', mode: 'includes' },
],
},
{
name: 'Replicate',
icon: 'replicate-brand',
color: 'linear-gradient(135deg, #111827, #6366f1)',
rules: [
{ keyword: 'replicate', mode: 'includes' },
],
},
{
name: 'SambaNova',
icon: 'sambanova-color',
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
rules: [
{ keyword: 'sambanova', mode: 'includes' },
],
},
{
name: 'Cerebras',
icon: 'cerebras-brand-color',
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
rules: [
{ keyword: 'cerebras', mode: 'includes' },
],
},
{
name: 'Ollama',
icon: 'ollama',
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
rules: [
{ keyword: 'ollama', mode: 'includes' },
],
},
{
name: 'ModelScope',
icon: 'modelscope-color',
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
rules: [
{ keyword: 'modelscope', mode: 'includes' },
],
},
{
name: 'SiliconCloud',
icon: 'siliconcloud-color',
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
rules: [
{ keyword: 'siliconcloud', mode: 'includes' },
{ keyword: 'siliconflow', mode: 'includes' },
],
},
{
name: 'Azure AI',
icon: 'azureai-color',
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
rules: [
{ keyword: 'azureai', mode: 'includes' },
{ keyword: 'azure-openai', mode: 'includes' },
{ keyword: 'azure/openai', mode: 'includes' },
],
},
{
name: 'AWS Bedrock',
icon: 'bedrock-color',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'bedrock', mode: 'includes' },
],
},
{
name: 'Vertex AI',
icon: 'vertexai-color',
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
rules: [
{ keyword: 'vertexai', mode: 'includes' },
],
},
{
name: 'Google Cloud',
icon: 'googlecloud-brand-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
rules: [
{ keyword: 'googlecloud', mode: 'includes' },
{ keyword: 'google-cloud', mode: 'includes' },
],
},
{
name: '百度智能云',
icon: 'baiducloud-color',
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
rules: [
{ keyword: 'baiducloud', mode: 'includes' },
{ keyword: 'qianfan', mode: 'includes' },
],
},
{
name: '百炼',
icon: 'bailian-color',
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
rules: [
{ keyword: 'bailian', mode: 'includes' },
{ keyword: 'dashscope', mode: 'includes' },
],
},
{
name: '阿里云',
icon: 'alibabacloud-color',
color: 'linear-gradient(135deg, #f97316, #fb923c)',
rules: [
{ keyword: 'alibabacloud', mode: 'includes' },
],
},
{
name: '火山引擎',
icon: 'volcengine-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
rules: [
{ keyword: 'volcengine', mode: 'includes' },
],
},
{
name: '七牛云',
icon: 'qiniu-color',
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
rules: [
{ keyword: 'qiniu', mode: 'includes' },
],
},
];
function normalizeInput(value) {
return String(value || '').trim().toLowerCase();
}
export function stripCommonWrappers(value) {
return value
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
.replace(/^re:\s*/g, '')
.replace(/^\^+/, '')
.replace(/\$+$/, '')
.trim();
}
export function collectBrandCandidates(modelName) {
const queue = [];
const seen = new Set();
const push = (value) => {
const normalized = normalizeInput(value);
if (!normalized || seen.has(normalized))
return;
seen.add(normalized);
queue.push(normalized);
};
push(modelName);
for (let index = 0; index < queue.length; index += 1) {
const candidate = queue[index];
const cleaned = stripCommonWrappers(candidate);
push(cleaned);
if (cleaned.includes('/')) {
for (const part of cleaned.split('/'))
push(part);
}
if (cleaned.includes(':')) {
for (const part of cleaned.split(':'))
push(part);
}
if (cleaned.includes(',')) {
for (const part of cleaned.split(','))
push(part);
}
}
return queue;
}
function buildMatchContext(modelName) {
const candidates = collectBrandCandidates(modelName);
const raw = candidates[0] || normalizeInput(modelName);
const cleaned = stripCommonWrappers(raw);
const segments = Array.from(new Set(candidates
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
.map((segment) => segment.trim())
.filter(Boolean)));
return {
raw,
cleaned,
segments,
candidates,
};
}
function matchesRule(context, rule) {
switch (rule.mode) {
case 'includes':
return context.raw.includes(rule.keyword)
|| context.cleaned.includes(rule.keyword)
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
case 'startsWith':
return context.raw.startsWith(rule.keyword)
|| context.cleaned.startsWith(rule.keyword)
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
case 'segment':
return context.segments.includes(rule.keyword);
case 'boundary': {
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
return pattern.test(context.raw)
|| pattern.test(context.cleaned)
|| context.candidates.some((candidate) => pattern.test(candidate));
}
default:
return false;
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
brand,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
}));
export function getAllBrands() {
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
}
export function getAllBrandNames() {
return BRAND_DEFINITIONS.map((brand) => brand.name);
}
function toBrandInfo(brand) {
return {
name: brand.name,
icon: brand.icon,
color: brand.color,
};
}
export function getMatchingBrands(modelName) {
const context = buildMatchContext(modelName);
const matches = [];
const seen = new Set();
const add = (brand) => {
if (seen.has(brand.name))
return;
seen.add(brand.name);
matches.push(toBrandInfo(brand));
};
for (const definition of BRAND_DEFINITIONS) {
if (definition.rules.some((rule) => matchesRule(context, rule))) {
add(definition);
}
}
for (const candidate of context.candidates) {
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
if (rule.boundaryRegex.test(candidate)) {
add(rule.brand);
}
}
}
return matches;
}
export function getMatchingBrandNames(modelName) {
return getMatchingBrands(modelName).map((brand) => brand.name);
}
export function getBrand(modelName) {
return getMatchingBrands(modelName)[0] || null;
}
-118
View File
@@ -1,118 +0,0 @@
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
function normalizeString(value) {
return String(value || '').trim();
}
function normalizeDigest(value) {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
function normalizeStableTag(value) {
return normalizeStableVersion(value);
}
function hasSameImageTag(left, right) {
const rawLeft = normalizeString(left);
const rawRight = normalizeString(right);
if (!rawLeft || !rawRight)
return false;
const stableLeft = normalizeStableTag(left);
const stableRight = normalizeStableTag(right);
if (stableLeft && stableRight) {
return stableLeft === stableRight;
}
return rawLeft === rawRight;
}
export function normalizeStableVersion(value) {
const raw = normalizeString(value);
if (!raw)
return '';
const match = raw.match(STABLE_SEMVER_PATTERN);
if (!match)
return '';
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
].join('.');
}
export function compareStableVersions(left, right) {
const normalizedLeft = normalizeStableVersion(left);
const normalizedRight = normalizeStableVersion(right);
if (!normalizedLeft || !normalizedRight)
return null;
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] === rightParts[index])
continue;
return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
export function isSameImageTarget(current, target) {
const currentDigest = normalizeDigest(current?.imageDigest);
const targetDigest = normalizeDigest(target.digest);
if (currentDigest && targetDigest) {
return currentDigest === targetDigest;
}
return hasSameImageTag(current?.imageTag, target.tag);
}
export function buildUpdateReminderCandidateKey(source, candidate) {
const tagName = normalizeString(candidate.tagName);
const digest = normalizeDigest(candidate.digest);
if (!tagName)
return '';
if (source === 'docker-hub-tag' && digest) {
return `${source}:${tagName}@${digest}`;
}
return `${source}:${tagName}`;
}
export function resolveUpdateReminderCandidate(input) {
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
if (githubTag) {
const githubTargetVersion = githubCandidateVersion || githubTag;
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
source: 'github-release',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
tagName: githubTag,
digest: null,
};
}
}
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
if (!dockerTag)
return null;
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
return null;
}
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
if (dockerVersionCompare === -1) {
return {
source: 'docker-hub-tag',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest || null,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
return {
source: 'docker-hub-tag',
kind: 'new-digest',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest,
};
}
return null;
}
-161
View File
@@ -1,161 +0,0 @@
export type UpdateVersionCandidateLike = {
normalizedVersion?: string | null;
displayVersion?: string | null;
tagName?: string | null;
digest?: string | null;
};
export type UpdateHelperRuntimeLike = {
imageTag?: string | null;
imageDigest?: string | null;
};
export type UpdateReminderCandidate = {
source: 'github-release' | 'docker-hub-tag';
kind: 'new-version' | 'new-digest';
candidateKey: string;
displayVersion: string;
tagName: string;
digest: string | null;
};
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
function normalizeString(value?: string | null): string {
return String(value || '').trim();
}
function normalizeDigest(value?: string | null): string {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
function normalizeStableTag(value?: string | null): string {
return normalizeStableVersion(value);
}
function hasSameImageTag(left?: string | null, right?: string | null): boolean {
const rawLeft = normalizeString(left);
const rawRight = normalizeString(right);
if (!rawLeft || !rawRight) return false;
const stableLeft = normalizeStableTag(left);
const stableRight = normalizeStableTag(right);
if (stableLeft && stableRight) {
return stableLeft === stableRight;
}
return rawLeft === rawRight;
}
export function normalizeStableVersion(value?: string | null): string {
const raw = normalizeString(value);
if (!raw) return '';
const match = raw.match(STABLE_SEMVER_PATTERN);
if (!match) return '';
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
].join('.');
}
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
const normalizedLeft = normalizeStableVersion(left);
const normalizedRight = normalizeStableVersion(right);
if (!normalizedLeft || !normalizedRight) return null;
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] === rightParts[index]) continue;
return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
export function isSameImageTarget(
current: UpdateHelperRuntimeLike | null | undefined,
target: { tag?: string | null; digest?: string | null },
): boolean {
const currentDigest = normalizeDigest(current?.imageDigest);
const targetDigest = normalizeDigest(target.digest);
if (currentDigest && targetDigest) {
return currentDigest === targetDigest;
}
return hasSameImageTag(current?.imageTag, target.tag);
}
export function buildUpdateReminderCandidateKey(
source: 'github-release' | 'docker-hub-tag',
candidate: { tagName?: string | null; digest?: string | null },
): string {
const tagName = normalizeString(candidate.tagName);
const digest = normalizeDigest(candidate.digest);
if (!tagName) return '';
if (source === 'docker-hub-tag' && digest) {
return `${source}:${tagName}@${digest}`;
}
return `${source}:${tagName}`;
}
export function resolveUpdateReminderCandidate(input: {
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
githubRelease: UpdateVersionCandidateLike | null | undefined;
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
}): UpdateReminderCandidate | null {
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
if (githubTag) {
const githubTargetVersion = githubCandidateVersion || githubTag;
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
source: 'github-release',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
tagName: githubTag,
digest: null,
};
}
}
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
if (!dockerTag) return null;
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
return null;
}
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
if (dockerVersionCompare === -1) {
return {
source: 'docker-hub-tag',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest || null,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
return {
source: 'docker-hub-tag',
kind: 'new-digest',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest,
};
}
return null;
}
+474 -4
View File
@@ -2,8 +2,9 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
services::shared::{enforce_key_request_limit, increment_key_usage}, services::shared::{enforce_key_request_limit, increment_key_usage},
}; };
use chrono::Timelike;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, Row, SqlitePool};
use std::sync::Arc; use std::sync::Arc;
#[derive(Clone)] #[derive(Clone)]
@@ -108,8 +109,11 @@ impl UsageTracker {
return Err(AppError::Forbidden("API key is disabled".into())); return Err(AppError::Forbidden("API key is disabled".into()));
} }
if let Some(max_cost) = max_cost { if let Some(max_cost) = max_cost {
if used_cost.unwrap_or(0.0) >= max_cost { if max_cost <= 0.0 || used_cost.unwrap_or(0.0) >= max_cost {
return Err(AppError::RateLimited); return Err(AppError::RateLimitedWithRetry {
message: "下游密钥成本额度已用完".into(),
retry_after_secs: 60,
});
} }
} }
Ok(()) Ok(())
@@ -149,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 (
@@ -187,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::*;
@@ -260,7 +642,7 @@ mod tests {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
tracker.check_rate_limit(1).await, tracker.check_rate_limit(1).await,
Err(AppError::RateLimited) Err(AppError::RateLimitedWithRetry { .. })
)); ));
} }
@@ -279,4 +661,92 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[tokio::test]
async fn projects_proxy_logs_to_site_and_model_usage() {
let pool = pool().await;
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
platform TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE proxy_logs (
id INTEGER PRIMARY KEY,
account_id INTEGER,
model_requested TEXT,
model_actual TEXT,
status TEXT,
latency_ms INTEGER,
total_tokens INTEGER,
estimated_cost REAL,
created_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO sites (id, name, platform) VALUES (10, 'Site', 'new-api')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id) VALUES (20, 10)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO proxy_logs
(id, account_id, model_requested, model_actual, status, latency_ms, total_tokens, estimated_cost, created_at)
VALUES
(1, 20, 'gpt-test', NULL, 'success', 120, 1000, 0.12, '2026-06-06 08:12:30')",
)
.execute(&pool)
.await
.unwrap();
let projected = UsageTracker::project_proxy_usage_for_db(&pool).await.unwrap();
assert_eq!(projected, 1);
let model_usage: (i64, i64, f64, i64) = sqlx::query_as(
"SELECT total_calls, total_tokens, total_spend, total_latency_ms
FROM model_day_usage
WHERE site_id = 10 AND model = 'gpt-test'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(model_usage, (1, 1000, 0.12, 120));
let site_usage: (i64, i64, f64) = sqlx::query_as(
"SELECT total_calls, success_calls, total_site_spend
FROM site_day_usage
WHERE site_id = 10 AND local_day = '2026-06-06'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(site_usage, (1, 1, 0.12));
let hour_bucket: String = sqlx::query_scalar(
"SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(hour_bucket, "2026-06-06 08:00:00");
}
} }
+4
View File
@@ -94,6 +94,10 @@ impl ComfyUIProcess {
let base_url = self.internal_base_url(); let base_url = self.internal_base_url();
let mode = if self.config.uses_docker_backend() { let mode = if self.config.uses_docker_backend() {
"docker" "docker"
} else if self.config.uses_cloud_backend() {
"cloud"
} else if self.config.uses_runninghub_backend() {
"runninghub"
} else if self.config.enabled { } else if self.config.enabled {
"embedded" "embedded"
} else { } else {
+5
View File
@@ -79,6 +79,10 @@ impl CronScheduler {
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> { async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?; UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
let projected = UsageTracker::project_proxy_usage_for_db(&db).await?;
if projected > 0 {
tracing::debug!(projected, "usage aggregates projected");
}
sqlx::query( sqlx::query(
"UPDATE downstream_api_keys "UPDATE downstream_api_keys
SET updated_at = datetime('now') SET updated_at = datetime('now')
@@ -123,6 +127,7 @@ impl CronScheduler {
) )
.execute(&*db) .execute(&*db)
.await?; .await?;
crate::proxy::token_router::router::invalidate_route_cache();
Ok(()) Ok(())
} }
} }
+2 -1
View File
@@ -8,6 +8,7 @@ pub mod oauth;
pub mod register_captcha; pub mod register_captcha;
pub mod registration_limiter; pub mod registration_limiter;
pub mod route_rebuild; pub mod route_rebuild;
pub mod runtime_auth;
pub mod runtime_logs;
pub mod shared; pub mod shared;
pub mod update_center;
pub mod workspace; pub mod workspace;
+72 -9
View File
@@ -1,4 +1,4 @@
use crate::error::AppResult; use crate::error::{AppError, AppResult};
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc}; use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
use lettre::{ use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport, message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
@@ -36,13 +36,19 @@ pub struct Notification {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct NotificationConfig { struct NotificationConfig {
telegram_enabled: bool,
telegram_bot_token: Option<String>, telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>, telegram_chat_id: Option<String>,
telegram_thread_id: Option<i64>, telegram_thread_id: Option<i64>,
wecom_enabled: bool,
wecom_webhook: Option<String>, wecom_webhook: Option<String>,
feishu_enabled: bool,
feishu_webhook: Option<String>, feishu_webhook: Option<String>,
webhook_enabled: bool,
webhook_url: Option<String>, webhook_url: Option<String>,
bark_enabled: bool,
bark_url: Option<String>, bark_url: Option<String>,
serverchan_enabled: bool,
serverchan_key: Option<String>, serverchan_key: Option<String>,
smtp: SmtpConfig, smtp: SmtpConfig,
cooldown_sec: i64, cooldown_sec: i64,
@@ -50,8 +56,10 @@ struct NotificationConfig {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct SmtpConfig { struct SmtpConfig {
enabled: bool,
host: Option<String>, host: Option<String>,
port: Option<u16>, port: Option<u16>,
secure: Option<bool>,
user: Option<String>, user: Option<String>,
pass: Option<String>, pass: Option<String>,
from: Option<String>, from: Option<String>,
@@ -100,6 +108,33 @@ impl NotificationService {
Ok(()) Ok(())
} }
pub async fn send_test(&self) -> AppResult<Vec<String>> {
Self::ensure_schema(&self.db).await?;
let config = self.load_config().await?;
let channels = self.enabled_channels(&config);
if channels.is_empty() {
return Err(AppError::BadRequest(
"请至少配置并启用一个通知通道".into(),
));
}
let notif = Notification {
title: "BossAI 通知测试".to_string(),
message: "通知通道配置正常。这条消息来自管理后台的测试发送。".to_string(),
level: NotificationLevel::Info,
channel: None,
signature: None,
};
for channel in &channels {
self.send_channel(channel, &notif, &config)
.await
.map_err(|err| AppError::BadRequest(format!("{channel} 通知测试失败: {err}")))?;
}
Ok(channels)
}
async fn load_config(&self) -> AppResult<NotificationConfig> { async fn load_config(&self) -> AppResult<NotificationConfig> {
let rows: Vec<(String, Option<String>)> = let rows: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'") sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
@@ -112,18 +147,26 @@ impl NotificationService {
.collect(); .collect();
Ok(NotificationConfig { Ok(NotificationConfig {
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
telegram_bot_token: setting(&map, "notify_telegram_bot_token"), telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
telegram_chat_id: setting(&map, "notify_telegram_chat_id"), telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
telegram_thread_id: setting(&map, "notify_telegram_thread_id") telegram_thread_id: setting(&map, "notify_telegram_thread_id")
.and_then(|value| value.parse().ok()), .and_then(|value| value.parse().ok()),
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
wecom_webhook: setting(&map, "notify_wecom_webhook"), wecom_webhook: setting(&map, "notify_wecom_webhook"),
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
feishu_webhook: setting(&map, "notify_feishu_webhook"), feishu_webhook: setting(&map, "notify_feishu_webhook"),
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
webhook_url: setting(&map, "notify_webhook_url"), webhook_url: setting(&map, "notify_webhook_url"),
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
bark_url: setting(&map, "notify_bark_url"), bark_url: setting(&map, "notify_bark_url"),
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
serverchan_key: setting(&map, "notify_serverchan_key"), serverchan_key: setting(&map, "notify_serverchan_key"),
smtp: SmtpConfig { smtp: SmtpConfig {
enabled: setting_bool(&map, "notify_smtp_enabled", true),
host: setting(&map, "notify_smtp_host"), host: setting(&map, "notify_smtp_host"),
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()), port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
secure: setting(&map, "notify_smtp_secure").map(|value| parse_bool(&value)),
user: setting(&map, "notify_smtp_user"), user: setting(&map, "notify_smtp_user"),
pass: setting(&map, "notify_smtp_pass"), pass: setting(&map, "notify_smtp_pass"),
from: setting(&map, "notify_smtp_from"), from: setting(&map, "notify_smtp_from"),
@@ -188,25 +231,28 @@ impl NotificationService {
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> { fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
let mut channels = Vec::new(); let mut channels = Vec::new();
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() { if config.telegram_enabled
&& config.telegram_bot_token.is_some()
&& config.telegram_chat_id.is_some()
{
channels.push("telegram".to_string()); channels.push("telegram".to_string());
} }
if config.wecom_webhook.is_some() { if config.wecom_enabled && config.wecom_webhook.is_some() {
channels.push("wecom".to_string()); channels.push("wecom".to_string());
} }
if config.feishu_webhook.is_some() { if config.feishu_enabled && config.feishu_webhook.is_some() {
channels.push("feishu".to_string()); channels.push("feishu".to_string());
} }
if config.webhook_url.is_some() { if config.webhook_enabled && config.webhook_url.is_some() {
channels.push("webhook".to_string()); channels.push("webhook".to_string());
} }
if config.bark_url.is_some() { if config.bark_enabled && config.bark_url.is_some() {
channels.push("bark".to_string()); channels.push("bark".to_string());
} }
if config.serverchan_key.is_some() { if config.serverchan_enabled && config.serverchan_key.is_some() {
channels.push("serverchan".to_string()); channels.push("serverchan".to_string());
} }
if config.smtp.is_configured() { if config.smtp.enabled && config.smtp.is_configured() {
channels.push("smtp".to_string()); channels.push("smtp".to_string());
} }
channels channels
@@ -385,6 +431,7 @@ impl SmtpConfig {
fn send(self, notif: &Notification) -> anyhow::Result<()> { fn send(self, notif: &Notification) -> anyhow::Result<()> {
let host = required(self.host.as_deref(), "smtp host")?; let host = required(self.host.as_deref(), "smtp host")?;
let port = self.port.unwrap_or(587); let port = self.port.unwrap_or(587);
let secure = self.secure.unwrap_or(true);
let user = required(self.user.as_deref(), "smtp user")?; let user = required(self.user.as_deref(), "smtp user")?;
let pass = required(self.pass.as_deref(), "smtp pass")?; let pass = required(self.pass.as_deref(), "smtp pass")?;
let from = required(self.from.as_deref(), "smtp from")?; let from = required(self.from.as_deref(), "smtp from")?;
@@ -400,7 +447,12 @@ impl SmtpConfig {
)) ))
.body(format_notification_text(notif))?; .body(format_notification_text(notif))?;
let mailer = SmtpTransport::relay(host)? let builder = if secure {
SmtpTransport::relay(host)?
} else {
SmtpTransport::builder_dangerous(host)
};
let mailer = builder
.port(port) .port(port)
.credentials(Credentials::new(user.to_string(), pass.to_string())) .credentials(Credentials::new(user.to_string(), pass.to_string()))
.build(); .build();
@@ -413,6 +465,17 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
map.get(key).map(|value| value.trim().to_string()) map.get(key).map(|value| value.trim().to_string())
} }
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
}
fn parse_bool(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on" | "enabled"
)
}
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> { fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured")) value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
} }
+14 -2
View File
@@ -179,7 +179,13 @@ mod tests {
fn captcha_is_one_time() { fn captcha_is_one_time() {
let store = RegisterCaptchaStore::new(); let store = RegisterCaptchaStore::new();
let challenge = store.issue(None); let challenge = store.issue(None);
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone(); let answer = store
.inner
.lock()
.get(&challenge.id)
.unwrap()
.answer
.clone();
store.verify(&challenge.id, &answer, None).unwrap(); store.verify(&challenge.id, &answer, None).unwrap();
assert_eq!( assert_eq!(
@@ -192,7 +198,13 @@ mod tests {
fn captcha_rejects_other_ip() { fn captcha_rejects_other_ip() {
let store = RegisterCaptchaStore::new(); let store = RegisterCaptchaStore::new();
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone(); let answer = store
.inner
.lock()
.get(&challenge.id)
.unwrap()
.answer
.clone();
assert_eq!( assert_eq!(
store store
+3 -1
View File
@@ -57,7 +57,9 @@ struct AvailRow {
/// single transaction so a partial failure doesn't leave `token_routes` and /// single transaction so a partial failure doesn't leave `token_routes` and
/// `route_channels` out of sync. /// `route_channels` out of sync.
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> { pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
rebuild_routes_inner(&state.db).await let summary = rebuild_routes_inner(&state.db).await?;
crate::proxy::token_router::router::invalidate_route_cache();
Ok(summary)
} }
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from /// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
+93
View File
@@ -0,0 +1,93 @@
use sqlx::SqlitePool;
use parking_lot::RwLock;
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
#[derive(Debug)]
pub struct RuntimeAuthSettings {
default_proxy_token: String,
proxy_token: RwLock<String>,
}
impl RuntimeAuthSettings {
#[cfg(test)]
pub fn from_token_for_test(proxy_token: impl Into<String>) -> Self {
let proxy_token = proxy_token.into();
Self {
default_proxy_token: proxy_token.clone(),
proxy_token: RwLock::new(proxy_token),
}
}
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
let stored: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(PROXY_TOKEN_SETTING_KEY)
.fetch_optional(pool)
.await?;
let proxy_token = stored
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_proxy_token.trim())
.to_string();
Ok(Self {
default_proxy_token,
proxy_token: RwLock::new(proxy_token),
})
}
pub fn proxy_token(&self) -> String {
self.proxy_token.read().clone()
}
pub async fn set_proxy_token(
&self,
pool: &SqlitePool,
proxy_token: String,
) -> anyhow::Result<String> {
let normalized = normalize_proxy_token(&proxy_token)?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(PROXY_TOKEN_SETTING_KEY)
.bind(&normalized)
.execute(pool)
.await?;
*self.proxy_token.write() = normalized.clone();
Ok(normalized)
}
pub fn proxy_token_masked(&self) -> String {
mask_token(&self.proxy_token())
}
pub fn using_default_proxy_token(&self) -> bool {
self.proxy_token() == self.default_proxy_token
}
}
pub fn normalize_proxy_token(value: &str) -> anyhow::Result<String> {
let token = value.trim();
if !token.starts_with("sk-") {
anyhow::bail!("下游访问令牌必须以 sk- 开头");
}
if token.chars().count() < 6 {
anyhow::bail!("下游访问令牌至少 6 位(含 sk-)");
}
Ok(token.to_string())
}
pub fn mask_token(value: &str) -> String {
let token = value.trim();
if token.is_empty() {
return String::new();
}
let suffix: String = token
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("••••{}", suffix)
}
+174
View File
@@ -0,0 +1,174 @@
use chrono::{SecondsFormat, Utc};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::VecDeque;
use std::fmt;
use std::sync::atomic::{AtomicI64, Ordering};
use tracing::{
field::{Field, Visit},
Level, Subscriber,
};
use tracing_subscriber::{layer::Context, Layer};
const MAX_RUNTIME_LOGS: usize = 500;
static NEXT_ID: AtomicI64 = AtomicI64::new(1);
static RUNTIME_LOGS: Lazy<RwLock<VecDeque<RuntimeLogEntry>>> =
Lazy::new(|| RwLock::new(VecDeque::with_capacity(MAX_RUNTIME_LOGS)));
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeLogEntry {
pub id: i64,
#[serde(rename = "type")]
pub event_type: String,
pub title: String,
pub message: Option<String>,
pub level: String,
#[serde(rename = "read")]
pub is_read: bool,
pub related_id: Option<i64>,
pub related_type: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeLogLayer;
impl<S> Layer<S> for RuntimeLogLayer
where
S: Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let metadata = event.metadata();
let level = metadata.level();
if !matches!(*level, Level::ERROR | Level::WARN | Level::INFO) {
return;
}
let mut visitor = LogFieldVisitor::default();
event.record(&mut visitor);
let title = visitor
.message
.unwrap_or_else(|| metadata.name().to_string());
let mut details = Vec::with_capacity(visitor.fields.len() + 1);
details.push(metadata.target().to_string());
details.extend(visitor.fields);
push(RuntimeLogEntry {
id: -NEXT_ID.fetch_add(1, Ordering::Relaxed),
event_type: "runtime".to_string(),
title,
message: Some(details.join(" · ")).filter(|value| !value.trim().is_empty()),
level: level_name(level).to_string(),
is_read: true,
related_id: None,
related_type: None,
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
});
}
}
pub fn list() -> Vec<RuntimeLogEntry> {
RUNTIME_LOGS.read().iter().cloned().collect()
}
pub fn clear() {
RUNTIME_LOGS.write().clear();
}
fn push(entry: RuntimeLogEntry) {
let mut logs = RUNTIME_LOGS.write();
if logs.len() >= MAX_RUNTIME_LOGS {
logs.pop_front();
}
logs.push_back(entry);
}
fn level_name(level: &Level) -> &'static str {
match *level {
Level::ERROR => "error",
Level::WARN => "warning",
_ => "info",
}
}
#[derive(Debug, Default)]
struct LogFieldVisitor {
message: Option<String>,
fields: Vec<String>,
}
impl LogFieldVisitor {
fn record_value(&mut self, field: &Field, value: String) {
if field.name() == "message" {
self.message = Some(value);
} else {
self.fields.push(format!("{}={}", field.name(), value));
}
}
}
impl Visit for LogFieldVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.record_value(field, value.to_string());
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.record_value(field, value.to_string());
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.record_value(field, value.to_string());
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.record_value(field, value.to_string());
}
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
self.record_value(field, value.to_string());
}
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.record_value(field, normalize_debug_value(format!("{value:?}")));
}
}
fn normalize_debug_value(value: String) -> String {
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
value[1..value.len() - 1].replace("\\\"", "\"")
} else {
value
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_subscriber::prelude::*;
#[test]
fn runtime_log_layer_captures_info_events() {
clear();
let subscriber = tracing_subscriber::registry().with(RuntimeLogLayer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(key_id = 42, "Built-in key ready");
});
let logs = list();
let entry = logs.last().expect("runtime log entry");
assert_eq!(entry.event_type, "runtime");
assert_eq!(entry.title, "Built-in key ready");
assert_eq!(entry.level, "info");
assert_eq!(entry.is_read, true);
assert!(entry
.message
.as_deref()
.unwrap_or_default()
.contains("key_id=42"));
}
}
+10 -1
View File
@@ -21,8 +21,17 @@ pub async fn enforce_key_request_limit(
return Err(AppError::Forbidden("API key is disabled".into())); return Err(AppError::Forbidden("API key is disabled".into()));
} }
if let Some(max_requests) = max_requests { if let Some(max_requests) = max_requests {
if max_requests <= 0 {
return Err(AppError::RateLimitedWithRetry {
message: "下游密钥请求额度已用完".into(),
retry_after_secs: 60,
});
}
if used_requests.unwrap_or(0) + increment > max_requests { if used_requests.unwrap_or(0) + increment > max_requests {
return Err(AppError::RateLimited); return Err(AppError::RateLimitedWithRetry {
message: "下游密钥请求额度已用完".into(),
retry_after_secs: 60,
});
} }
} }
Ok(()) Ok(())
-115
View File
@@ -1,115 +0,0 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::SqlitePool;
use std::sync::Arc;
const UPDATE_CONFIG_KEY: &str = "update_center_config";
#[derive(Clone)]
pub struct UpdateCenter {
db: Arc<SqlitePool>,
client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
pub release_notes: Option<String>,
pub download_url: Option<String>,
}
impl UpdateCenter {
pub fn new(db: Arc<SqlitePool>) -> Self {
Self {
db,
client: reqwest::Client::new(),
}
}
pub async fn check_for_updates(&self) -> AppResult<UpdateInfo> {
let config = self.load_config().await?;
let current_version = env!("CARGO_PKG_VERSION").to_string();
let Some(endpoint) = config
.get("check_url")
.and_then(Value::as_str)
.filter(|url| !url.trim().is_empty())
else {
return Ok(UpdateInfo {
current_version,
latest_version: None,
update_available: false,
release_notes: None,
download_url: None,
});
};
let payload: Value = self
.client
.get(endpoint)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("Update check failed: {err}")))?
.json()
.await
.map_err(|err| AppError::UpstreamError(format!("Invalid update response: {err}")))?;
let latest_version = payload
.get("version")
.or_else(|| payload.get("latestVersion"))
.and_then(Value::as_str)
.map(str::to_string);
let update_available = latest_version
.as_deref()
.map(|latest| latest != current_version)
.unwrap_or(false);
Ok(UpdateInfo {
current_version,
latest_version,
update_available,
release_notes: payload
.get("release_notes")
.or_else(|| payload.get("releaseNotes"))
.and_then(Value::as_str)
.map(str::to_string),
download_url: payload
.get("download_url")
.or_else(|| payload.get("downloadUrl"))
.and_then(Value::as_str)
.map(str::to_string),
})
}
pub async fn apply_update(&self, version: &str) -> AppResult<String> {
let task_id = format!("update-{}", uuid::Uuid::new_v4());
sqlx::query(
"INSERT INTO background_tasks
(id, task_type, title, status, message, dedupe_key, created_at, updated_at)
VALUES (?, 'update-center-deploy', ?, 'pending', ?, ?, datetime('now'), datetime('now'))",
)
.bind(&task_id)
.bind(format!("Apply BoosAPI {version}"))
.bind("Update request accepted; an external supervisor should perform download and restart")
.bind(format!("update-center:deploy:{version}"))
.execute(&*self.db)
.await?;
Ok(task_id)
}
async fn load_config(&self) -> AppResult<Value> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(UPDATE_CONFIG_KEY)
.fetch_optional(&*self.db)
.await?;
raw.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|err| AppError::BadRequest(format!("Invalid update center config: {err}")))?
.map(Ok)
.unwrap_or_else(|| Ok(Value::Object(Default::default())))
}
}
+110 -7
View File
@@ -160,6 +160,23 @@ async fn insert_asset(
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
if should_project_workspace_asset(status, &metadata) {
if let Err(err) = upsert_workspace_asset_daily(
state,
None,
input.user_id,
&input.source,
&input.kind,
size_bytes,
file_path.is_some(),
1,
)
.await
{
tracing::warn!(%err, "failed to update workspace asset projection");
}
}
Ok(StoredWorkspaceAsset { Ok(StoredWorkspaceAsset {
public_id: public_id.to_string(), public_id: public_id.to_string(),
file_name, file_name,
@@ -169,6 +186,51 @@ async fn insert_asset(
}) })
} }
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
status == "completed"
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|| metadata.get("responseItem").is_some()
|| metadata.get("moneyPrinter").is_some()
|| metadata.get("digitalHuman").is_some())
}
async fn upsert_workspace_asset_daily(
state: &AppState,
local_day: Option<&str>,
user_id: Option<i64>,
source: &str,
kind: &str,
size_bytes: i64,
has_local_file: bool,
delta: i64,
) -> Result<(), AppError> {
let asset_count_delta = delta;
let local_file_delta = if has_local_file { delta } else { 0 };
let size_delta = size_bytes.max(0) * delta;
sqlx::query(
"INSERT INTO workspace_asset_daily
(local_day, user_id, source, kind, asset_count, local_file_count, total_size_bytes, updated_at)
VALUES (COALESCE(?, date('now', 'localtime')), ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(local_day, user_id, source, kind) DO UPDATE SET
asset_count = MAX(0, asset_count + excluded.asset_count),
local_file_count = MAX(0, local_file_count + excluded.local_file_count),
total_size_bytes = MAX(0, total_size_bytes + excluded.total_size_bytes),
updated_at = datetime('now')",
)
.bind(local_day)
.bind(user_id.unwrap_or(0))
.bind(source)
.bind(kind)
.bind(asset_count_delta)
.bind(local_file_delta)
.bind(size_delta)
.execute(&*state.db)
.await?;
Ok(())
}
pub async fn record_remote_asset( pub async fn record_remote_asset(
state: &AppState, state: &AppState,
input: WorkspaceAssetInput, input: WorkspaceAssetInput,
@@ -188,11 +250,7 @@ pub async fn record_remote_asset(
.await; .await;
}; };
let client = reqwest::Client::builder() match state.http.get(url).send().await {
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
match client.get(url).send().await {
Ok(response) if response.status().is_success() => { Ok(response) if response.status().is_success() => {
let mime_type = response let mime_type = response
.headers() .headers()
@@ -307,6 +365,45 @@ pub async fn record_bytes_asset(
.await .await
} }
pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<(), AppError> {
use sqlx::Row;
let row = sqlx::query(
"SELECT user_id, source, kind, status, metadata, size_bytes, file_path, date(created_at, 'localtime') AS local_day
FROM workspace_assets
WHERE public_id = ?
LIMIT 1",
)
.bind(public_id)
.fetch_optional(&*state.db)
.await?;
let Some(row) = row else {
return Ok(());
};
let status: String = row.try_get("status").unwrap_or_default();
let metadata_raw: String = row.try_get("metadata").unwrap_or_else(|_| "{}".to_string());
let metadata = serde_json::from_str::<Value>(&metadata_raw).unwrap_or_else(|_| json!({}));
if !should_project_workspace_asset(&status, &metadata) {
return Ok(());
}
let source: String = row.try_get("source").unwrap_or_default();
let kind: String = row.try_get("kind").unwrap_or_default();
let user_id: Option<i64> = row.try_get("user_id").ok().flatten();
let size_bytes: i64 = row.try_get("size_bytes").unwrap_or_default();
let file_path: Option<String> = row.try_get("file_path").ok().flatten();
let local_day: Option<String> = row.try_get("local_day").ok().flatten();
upsert_workspace_asset_daily(
state,
local_day.as_deref(),
user_id,
&source,
&kind,
size_bytes,
file_path.as_deref().is_some_and(|value| !value.trim().is_empty()),
-1,
)
.await
}
pub async fn record_image_assets_from_response( pub async fn record_image_assets_from_response(
state: &AppState, state: &AppState,
user_id: Option<i64>, user_id: Option<i64>,
@@ -338,7 +435,10 @@ pub async fn record_image_assets_from_response(
prompt: prompt.clone(), prompt: prompt.clone(),
model: model.clone(), model: model.clone(),
remote_url: Some(url.trim().to_string()), remote_url: Some(url.trim().to_string()),
metadata: json!({ "responseItem": item }), metadata: json!({
"workspaceAsset": { "role": "output" },
"responseItem": item,
}),
}; };
match record_remote_asset(state, input).await { match record_remote_asset(state, input).await {
Ok(asset) => stored_assets.push(asset), Ok(asset) => stored_assets.push(asset),
@@ -357,7 +457,10 @@ pub async fn record_image_assets_from_response(
prompt: prompt.clone(), prompt: prompt.clone(),
model: model.clone(), model: model.clone(),
remote_url: None, remote_url: None,
metadata: json!({ "responseItem": item }), metadata: json!({
"workspaceAsset": { "role": "output" },
"responseItem": item,
}),
}; };
match record_base64_asset(state, input, b64).await { match record_base64_asset(state, input, b64).await {
Ok(asset) => stored_assets.push(asset), Ok(asset) => stored_assets.push(asset),
-6
View File
@@ -1,6 +0,0 @@
export declare function parseBatchApiKeys(input: unknown): string[];
export declare function buildBatchApiKeyConnectionName(
baseName: string | null | undefined,
index: number,
total: number,
): string;
-4
View File
@@ -1,4 +0,0 @@
export {
buildBatchApiKeyConnectionName,
parseBatchApiKeys,
} from '../server/services/shared/apiKeyBatchCore.js';
-40
View File
@@ -1,40 +0,0 @@
export type UpdateVersionCandidateLike = {
normalizedVersion?: string | null;
displayVersion?: string | null;
tagName?: string | null;
digest?: string | null;
};
export type UpdateHelperRuntimeLike = {
imageTag?: string | null;
imageDigest?: string | null;
};
export type UpdateReminderCandidate = {
source: 'github-release' | 'docker-hub-tag';
kind: 'new-version' | 'new-digest';
candidateKey: string;
displayVersion: string;
tagName: string;
digest: string | null;
};
export declare function normalizeStableVersion(value?: string | null): string;
export declare function compareStableVersions(
left?: string | null,
right?: string | null,
): number | null;
export declare function isSameImageTarget(
current: UpdateHelperRuntimeLike | null | undefined,
target: { tag?: string | null; digest?: string | null },
): boolean;
export declare function buildUpdateReminderCandidateKey(
source: 'github-release' | 'docker-hub-tag',
candidate: { tagName?: string | null; digest?: string | null },
): string;
export declare function resolveUpdateReminderCandidate(input: {
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
githubRelease: UpdateVersionCandidateLike | null | undefined;
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
}): UpdateReminderCandidate | null;
-7
View File
@@ -1,7 +0,0 @@
export {
buildUpdateReminderCandidateKey,
compareStableVersions,
isSameImageTarget,
normalizeStableVersion,
resolveUpdateReminderCandidate,
} from '../server/shared/updateCenterReminder.js';
+38
View File
@@ -0,0 +1,38 @@
use crate::config::AppConfig;
use reqwest::{Client, Proxy};
use std::time::Duration;
pub fn client(config: &AppConfig, timeout_secs: u64) -> Client {
let mut builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
if let Some(proxy_url) = template_proxy_url(config) {
match Proxy::all(&proxy_url) {
Ok(proxy) => {
builder = builder.proxy(proxy);
}
Err(err) => {
tracing::warn!(%err, %proxy_url, "invalid template proxy url; falling back to direct template request");
}
}
}
builder.build().unwrap_or_else(|_| Client::new())
}
fn template_proxy_url(config: &AppConfig) -> Option<String> {
config
.proxy
.template_proxy_url
.as_deref()
.and_then(non_empty)
.map(str::to_string)
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
}
fn non_empty(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
fn non_empty_owned(value: String) -> Option<String> {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}
+28 -5
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer'; import { create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import AdminLoginPage from './pages/auth/AdminLoginPage.js'; import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import UserLoginPage from './pages/auth/UserLoginPage.js';
function collectText(node: ReactTestInstance): string { function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => { return (node.children || []).map((child) => {
@@ -36,13 +37,13 @@ describe('Login surface', () => {
&& node.props.className.includes('brand-mark-canvas') && node.props.className.includes('brand-mark-canvas')
)); ));
expect(pageText).toContain('BoosAPI'); expect(pageText).toContain('BossAI');
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('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
expect(pageText).toContain('统一代理网关'); expect(pageText).toContain('统一 API 网关');
expect(pageText).toContain('智能路由引擎'); expect(pageText).toContain('智能路由引擎');
expect(pageText).toContain('自动模型发现'); expect(pageText).toContain('工作流生产台');
expect(lightBrandPanel).toBeTruthy(); expect(lightBrandPanel).toBeTruthy();
expect(authStage).toBeTruthy(); expect(authStage).toBeTruthy();
expect(brandMarkCanvas).toBeTruthy(); expect(brandMarkCanvas).toBeTruthy();
@@ -61,4 +62,26 @@ describe('Login surface', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('renders OAuth provider entries as configured placeholders on user login', () => {
const root = create(
<MemoryRouter>
<UserLoginPage t={(text) => text} />
</MemoryRouter>,
);
try {
const providers = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-oauth-provider')
));
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
expect(providers.every((node) => node.props.disabled === true)).toBe(true);
expect(collectText(root.root)).toContain('待配置');
} finally {
root?.unmount();
}
});
}); });
+398 -98
View File
@@ -1,12 +1,16 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react'; import React, { Suspense, lazy, useState, useEffect, useRef, useId } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
import { gsap } from 'gsap';
import { ToastProvider, useToast } from './components/Toast.js'; 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 {
ADMIN_PROFILE_STORAGE_KEY,
LEGACY_USER_PROFILE_STORAGE_KEY,
LEGACY_THEME_STORAGE_KEY, LEGACY_THEME_STORAGE_KEY,
THEME_MODE_STORAGE_KEY, THEME_MODE_STORAGE_KEY,
USER_PROFILE_STORAGE_KEY, USER_PROFILE_STORAGE_KEY,
@@ -37,6 +41,7 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js')); const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js')); const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js')); const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
const Workspace = lazy(() => import('./pages/Workspace.js')); const Workspace = lazy(() => import('./pages/Workspace.js'));
const Workflows = lazy(() => import('./pages/Workflows.js')); const Workflows = lazy(() => import('./pages/Workflows.js'));
const Monitors = lazy(() => import('./pages/Monitors.js')); const Monitors = lazy(() => import('./pages/Monitors.js'));
@@ -54,6 +59,7 @@ type UserProfile = {
name: string; name: string;
avatarSeed: string; avatarSeed: string;
avatarStyle: string; avatarStyle: string;
avatarUrl?: string;
}; };
type UserAnnouncementPopupRow = { type UserAnnouncementPopupRow = {
id: number; id: number;
@@ -63,21 +69,17 @@ type UserAnnouncementPopupRow = {
publishedAt?: string | null; publishedAt?: string | null;
firstSeenAt?: string | null; firstSeenAt?: string | null;
}; };
const DICEBEAR_STYLES = [ const AVATAR_STYLES = [
'pixel-art', 'aurora',
'pixel-art-neutral', 'skyline',
'bottts', 'lotus',
'bottts-neutral', 'ember',
'identicon', 'mint',
'initials', 'violet',
'avataaars', 'graphite',
'avataaars-neutral', 'cobalt',
'personas',
'lorelei',
'lorelei-neutral',
'fun-emoji',
] as const; ] as const;
type DicebearStyle = typeof DICEBEAR_STYLES[number]; type AvatarStyle = typeof AVATAR_STYLES[number];
function resolveStoredThemeMode(): ThemeMode { function resolveStoredThemeMode(): ThemeMode {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY); const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved; if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
@@ -99,24 +101,76 @@ function hashString(input: string): number {
} }
return Math.abs(hash); return Math.abs(hash);
} }
function pickDicebearStyle(seed: string): DicebearStyle { function pickAvatarStyle(seed: string): AvatarStyle {
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length; const index = hashString(seed || 'default') % AVATAR_STYLES.length;
return DICEBEAR_STYLES[index]; return AVATAR_STYLES[index];
} }
function buildDicebearAvatarUrl(style: string, seed: string): string { function resolveAvatarStyle(style: string, seed: string): AvatarStyle {
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle) return AVATAR_STYLES.includes(style as AvatarStyle)
? style ? (style as AvatarStyle)
: pickDicebearStyle(seed); : pickAvatarStyle(seed);
const safeSeed = (seed || 'default').trim() || 'default';
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
} }
function resolveStoredProfile(): UserProfile { function buildLocalAvatarStyle(style: string, seed: string): React.CSSProperties {
const safeStyle = resolveAvatarStyle(style, seed);
const hue = hashString(`${safeStyle}:${seed || 'default'}`) % 360;
const secondHue = (hue + 46 + (hashString(seed || safeStyle) % 92)) % 360;
return {
background:
`radial-gradient(circle at 28% 24%, hsl(${secondHue} 86% 88%) 0 18%, transparent 19%), ` +
`linear-gradient(135deg, hsl(${hue} 74% 50%), hsl(${secondHue} 70% 48%))`,
color: '#fff',
};
}
function normalizeAvatarUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
if (
trimmed.startsWith('https://')
|| trimmed.startsWith('http://')
|| trimmed.startsWith('/')
|| trimmed.startsWith('data:image/')
) {
return trimmed;
}
return '';
}
async function fileToLocalAvatarDataUrl(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('头像文件必须是图片格式');
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('头像图片不能超过 5MB');
}
const bitmap = await createImageBitmap(file);
try { try {
const raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY); const size = 256;
if (!raw) { const canvas = document.createElement('canvas');
const avatarSeed = createRandomAvatarSeed(); canvas.width = size;
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) }; canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器无法处理头像图片');
const scale = Math.max(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
ctx.clearRect(0, 0, size, size);
ctx.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
return canvas.toDataURL('image/webp', 0.82);
} finally {
bitmap.close();
} }
}
function defaultProfile(name: string): UserProfile {
const avatarSeed = createRandomAvatarSeed();
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
}
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string }; const parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string };
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : ''; const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
const avatarSeed = typeof parsed?.avatarSeed === 'string' const avatarSeed = typeof parsed?.avatarSeed === 'string'
@@ -127,17 +181,85 @@ function resolveStoredProfile(): UserProfile {
? parsed.avatarStyle.trim() ? parsed.avatarStyle.trim()
: ''; : '';
return { return {
name: name || '管理员', name: name || fallbackName,
avatarSeed: resolvedSeed, avatarSeed: resolvedSeed,
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle) avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
? avatarStyle ? avatarStyle
: pickDicebearStyle(resolvedSeed), : pickAvatarStyle(resolvedSeed),
avatarUrl: normalizeAvatarUrl((parsed as any)?.avatarUrl),
}; };
} catch { } catch {
const avatarSeed = createRandomAvatarSeed(); return null;
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
} }
} }
function resolveStoredProfile(audience: 'admin' | 'user'): UserProfile {
const fallbackName = audience === 'admin' ? '管理员' : '用户';
const storageKey = audience === 'admin' ? ADMIN_PROFILE_STORAGE_KEY : USER_PROFILE_STORAGE_KEY;
const stored = parseStoredProfile(localStorage.getItem(storageKey), fallbackName);
if (stored) return stored;
if (audience === 'user') {
const legacy = parseStoredProfile(localStorage.getItem(LEGACY_USER_PROFILE_STORAGE_KEY), fallbackName);
if (legacy && legacy.name !== '管理员') {
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(legacy));
return legacy;
}
}
const next = defaultProfile(fallbackName);
localStorage.setItem(storageKey, JSON.stringify(next));
return next;
}
function profileStorageKey(audience: 'admin' | 'user'): string {
return audience === 'admin' ? ADMIN_PROFILE_STORAGE_KEY : USER_PROFILE_STORAGE_KEY;
}
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
const name = username.trim();
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
const avatarStyle = current?.avatarStyle?.trim() || pickAvatarStyle(avatarSeed);
return {
name: name || current?.name || fallbackName,
avatarSeed,
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
? avatarStyle
: pickAvatarStyle(avatarSeed),
avatarUrl: current?.avatarUrl,
};
}
function LocalAvatar({
profile,
label,
className,
style,
}: {
profile: UserProfile;
label: string;
className: string;
style?: React.CSSProperties;
}) {
const [imageBroken, setImageBroken] = useState(false);
useEffect(() => {
setImageBroken(false);
}, [profile.avatarUrl]);
const initial = (label || profile.name || 'B').trim().slice(0, 1).toUpperCase() || 'B';
const avatarUrl = normalizeAvatarUrl(profile.avatarUrl);
return (
<span
className={className}
aria-hidden="true"
style={{ ...buildLocalAvatarStyle(profile.avatarStyle, profile.avatarSeed), ...style }}
>
{avatarUrl && !imageBroken ? (
<img src={avatarUrl} alt="" onError={() => setImageBroken(true)} />
) : initial}
</span>
);
}
function UserProfileModal({ function UserProfileModal({
open, open,
profile, profile,
@@ -151,18 +273,23 @@ function UserProfileModal({
onSave: (nextProfile: UserProfile) => void; onSave: (nextProfile: UserProfile) => void;
t: (text: string) => string; t: (text: string) => string;
}) { }) {
const avatarInputId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(profile.name); const [name, setName] = useState(profile.name);
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed); const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle); const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
const [avatarUploadName, setAvatarUploadName] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setName(profile.name); setName(profile.name);
setAvatarSeed(profile.avatarSeed); setAvatarSeed(profile.avatarSeed);
setAvatarStyle(profile.avatarStyle); setAvatarStyle(profile.avatarStyle);
setAvatarUrl(profile.avatarUrl || '');
setAvatarUploadName('');
setError(''); setError('');
}, [open, profile]); }, [open, profile]);
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
@@ -176,7 +303,29 @@ function UserProfileModal({
const handleRandomAvatar = () => { const handleRandomAvatar = () => {
const nextSeed = createRandomAvatarSeed(); const nextSeed = createRandomAvatarSeed();
setAvatarSeed(nextSeed); setAvatarSeed(nextSeed);
setAvatarStyle(pickDicebearStyle(nextSeed)); setAvatarStyle(pickAvatarStyle(nextSeed));
setAvatarUrl('');
setAvatarUploadName('');
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleAvatarFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setError('');
const dataUrl = await fileToLocalAvatarDataUrl(file);
setAvatarUrl(dataUrl);
setAvatarUploadName(file.name);
} catch (uploadError: any) {
setError(t(uploadError?.message || '头像上传失败'));
event.target.value = '';
}
};
const handleClearAvatarImage = () => {
setAvatarUrl('');
setAvatarUploadName('');
setError('');
if (fileInputRef.current) fileInputRef.current.value = '';
}; };
const handleSubmit = () => { const handleSubmit = () => {
const normalizedName = name.trim(); const normalizedName = name.trim();
@@ -188,14 +337,26 @@ function UserProfileModal({
setError(t('用户名最多 24 个字符')); setError(t('用户名最多 24 个字符'));
return; return;
} }
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
if (avatarUrl.trim() && !normalizedAvatarUrl) {
setError(t('头像图片读取失败,请重新上传'));
return;
}
onSave({ onSave({
name: normalizedName, name: normalizedName,
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(), avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle) avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
? avatarStyle ? avatarStyle
: pickDicebearStyle(avatarSeed), : pickAvatarStyle(avatarSeed),
avatarUrl: normalizedAvatarUrl || undefined,
}); });
}; };
const previewProfile: UserProfile = {
name: name.trim() || profile.name,
avatarSeed,
avatarStyle,
avatarUrl: normalizeAvatarUrl(avatarUrl) || undefined,
};
return ( return (
<CenteredModal <CenteredModal
open={open} open={open}
@@ -212,15 +373,16 @@ function UserProfileModal({
</> </>
)} )}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}> <div className="profile-avatar-preview-row">
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}> <LocalAvatar
<img className="profile-avatar-preview"
src={avatarUrl} profile={previewProfile}
alt={name.trim() || 'avatar'} label={previewProfile.name}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/> />
<div>
<strong>{t('头像实时预览')}</strong>
<span>{t('会同步显示在右上角和侧边栏。')}</span>
</div> </div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
</div> </div>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
@@ -235,12 +397,41 @@ function UserProfileModal({
/> />
</div> </div>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('本地上传头像')}</div>
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle} <div className="profile-avatar-upload">
<input
ref={fileInputRef}
id={avatarInputId}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handleAvatarFileChange}
/>
<label htmlFor={avatarInputId}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M8 8l4-4m0 0l4 4m-4-4v12" />
</svg>
<span>{t('选择图片')}</span>
</label>
<div>
<strong>{avatarUploadName || (avatarUrl ? t('已选择本地头像') : t('未选择文件'))}</strong>
<span>{t('图片会压缩后保存在本机浏览器,不上传服务器。')}</span>
</div> </div>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像 · 风格:')}{resolveAvatarStyle(avatarStyle, avatarSeed)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}> <button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')} {t('换一个随机头像')}
</button> </button>
{avatarUrl.trim() && (
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleClearAvatarImage}>
{t('清除图片头像')}
</button>
)}
</div>
</div> </div>
{error && ( {error && (
<div className="alert alert-error"> <div className="alert alert-error">
@@ -337,7 +528,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> },
@@ -347,6 +538,7 @@ export const sidebarGroups = [
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> }, { to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> }, { to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> }, { to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> }, { to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
@@ -379,6 +571,7 @@ export const userSidebarGroups = [
label: '创作应用', label: '创作应用',
items: [ items: [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> }, { to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> }, { to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> }, { to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
@@ -387,17 +580,18 @@ export const userSidebarGroups = [
}, },
]; ];
const topNavItems = [ const topNavItems = [
{ label: '控制台', to: '/' }, { label: '控制台', to: '/dashboard' },
{ label: '模型广场', to: '/models' }, { label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' },
{ label: '工作流', to: '/workflows' }, { label: '工作流', to: '/workflows' },
{ label: '视频助手', to: '/comfyui-agent' },
{ label: '工作空间', to: '/workspace' },
{ label: '关于', to: '/about' }, { label: '关于', to: '/about' },
]; ];
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 (
@@ -410,6 +604,7 @@ function RouteLoadingFallback() {
function AppShell() { function AppShell() {
const { language, toggleLanguage, t } = useI18n(); const { language, toggleLanguage, t } = useI18n();
const location = useLocation(); const location = useLocation();
const topbarRef = useRef<HTMLElement>(null);
const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null); const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null);
const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null); const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -419,7 +614,9 @@ function AppShell() {
const themeMenuRef = useRef<HTMLDivElement>(null); const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode()); const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches); const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile()); const isUserPage = location.pathname.startsWith('/user/');
const profileAudience: 'admin' | 'user' = isUserPage ? 'user' : 'admin';
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile(profileAudience));
const [showProfileModal, setShowProfileModal] = useState(false); const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false); const [showNotifications, setShowNotifications] = useState(false);
@@ -438,9 +635,54 @@ function AppShell() {
? (systemPrefersDark ? 'dark' : 'light') ? (systemPrefersDark ? 'dark' : 'light')
: themeMode; : themeMode;
const rawDisplayName = (userProfile.name || '').trim(); const rawDisplayName = (userProfile.name || '').trim();
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员'); const accountRoleLabel = profileAudience === 'admin' ? t('管理员') : t('普通用户');
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : accountRoleLabel;
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色'); const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed); useEffect(() => {
const root = topbarRef.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 || {};
const items = Array.from(root.querySelectorAll<HTMLElement>('.topbar-motion-item'));
if (conditions.reduceMotion) {
gsap.set(items, {
autoAlpha: 1,
clearProps: 'transform,opacity,visibility',
});
return undefined;
}
const tween = gsap.fromTo(
items,
{ autoAlpha: 0, y: conditions.isDesktop ? -8 : -4, scale: 0.985 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.34,
ease: 'power2.out',
stagger: 0.032,
clearProps: 'transform,opacity,visibility',
},
);
return () => tween.kill();
},
);
return () => mm.revert();
}, []);
useEffect(() => {
setUserProfile(resolveStoredProfile(profileAudience));
}, [profileAudience]);
useEffect(() => { useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemPrefersDark(media.matches); const sync = () => setSystemPrefersDark(media.matches);
@@ -560,6 +802,35 @@ function AppShell() {
cancelled = true; cancelled = true;
}; };
}, [location.pathname, userAuthed]); }, [location.pathname, userAuthed]);
useEffect(() => {
if (
!userAuthed
|| !location.pathname.startsWith('/user/')
|| ['/user/login', '/user/register'].includes(location.pathname)
) return;
let cancelled = false;
const syncUserProfile = async () => {
try {
const response = await userApi.me();
if (cancelled) return;
const user = (response as any)?.user || (response as any)?.data || {};
const username = typeof user.username === 'string' ? user.username.trim() : '';
if (!username) return;
setUserProfile((current) => {
if ((current.name || '').trim() === username) return current;
const next = profileFromUsername(username, current, '用户');
localStorage.setItem(profileStorageKey('user'), JSON.stringify(next));
return next;
});
} catch {
// Keep the local profile if the user profile endpoint is temporarily unavailable.
}
};
void syncUserProfile();
return () => {
cancelled = true;
};
}, [location.pathname, userAuthed]);
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
@@ -579,14 +850,15 @@ function AppShell() {
const handleSaveProfile = (nextProfile: UserProfile) => { const handleSaveProfile = (nextProfile: UserProfile) => {
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed(); const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
const normalized = { const normalized = {
name: nextProfile.name.trim() || t('管理员'), name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
avatarSeed: normalizedSeed, avatarSeed: normalizedSeed,
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle) avatarStyle: AVATAR_STYLES.includes(nextProfile.avatarStyle as AvatarStyle)
? nextProfile.avatarStyle ? nextProfile.avatarStyle
: pickDicebearStyle(normalizedSeed), : pickAvatarStyle(normalizedSeed),
avatarUrl: normalizeAvatarUrl(nextProfile.avatarUrl) || undefined,
}; };
setUserProfile(normalized); setUserProfile(normalized);
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized)); localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
setShowProfileModal(false); setShowProfileModal(false);
toast.success(t('个人信息已保存')); toast.success(t('个人信息已保存'));
}; };
@@ -617,19 +889,16 @@ function AppShell() {
setAnnouncementPopupPending(false); setAnnouncementPopupPending(false);
} }
}; };
const isUserPage = location.pathname.startsWith('/user/');
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}
@@ -638,10 +907,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" />;
@@ -674,18 +939,15 @@ function AppShell() {
const currentTopNavItems = isUserPage ? [ const currentTopNavItems = isUserPage ? [
{ label: '个人中心', to: '/user/dashboard' }, { label: '个人中心', to: '/user/dashboard' },
{ label: '模型广场', to: '/user/models' }, { label: '模型广场', to: '/user/models' },
{ label: '用户公告', to: '/user/announcements' },
{ label: '工作流', to: '/user/workflows' }, { label: '工作流', to: '/user/workflows' },
{ label: '视频助手', to: '/user/comfyui-agent' },
{ label: '工作空间', to: '/user/workspace' },
{ label: '关于', to: '/user/about' }, { label: '关于', to: '/user/about' },
] : topNavItems; ] : topNavItems;
return ( return (
<> <>
<header className="topbar"> <header ref={topbarRef} className="topbar">
{isMobile && ( {isMobile && (
<button <button
className="topbar-icon-btn" className="topbar-icon-btn topbar-motion-item"
aria-label={t('打开导航')} aria-label={t('打开导航')}
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
type="button" type="button"
@@ -695,11 +957,16 @@ function AppShell() {
</svg> </svg>
</button> </button>
)} )}
<div className="topbar-logo"> <div className="topbar-logo topbar-motion-item">
<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-copy">
<span className="topbar-logo-text">BossAI</span>
<span className="topbar-logo-subtitle">{isUserPage ? t('用户工作台') : t('管理控制台')}</span>
</span>
</div> </div>
<nav className="topbar-nav"> <nav className="topbar-nav topbar-motion-item">
{currentTopNavItems.map((item) => ( {currentTopNavItems.map((item) => (
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}> <NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
{t(item.label)} {t(item.label)}
@@ -708,20 +975,19 @@ function AppShell() {
</nav> </nav>
<div className="topbar-right"> <div className="topbar-right">
<button <button
className="topbar-icon-btn" className="topbar-icon-btn topbar-language-btn topbar-motion-item"
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'} aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
onClick={toggleLanguage} onClick={toggleLanguage}
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
> >
{language === 'zh' ? 'EN' : '中'} {language === 'zh' ? 'EN' : '中'}
</button> </button>
<button className="topbar-search-trigger" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}> <button className="topbar-search-trigger topbar-motion-item" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg> <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<span className="topbar-search-label">{t('搜索')}</span> <span className="topbar-search-label">{t('搜索')}</span>
<kbd className="topbar-search-kbd">Ctrl K</kbd> <kbd className="topbar-search-kbd">Ctrl K</kbd>
</button> </button>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button ref={notifBtnRef} className="topbar-icon-btn" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}> <button ref={notifBtnRef} className="topbar-icon-btn topbar-motion-item" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="topbar-badge"> <span className="topbar-badge">
@@ -733,7 +999,7 @@ function AppShell() {
</div> </div>
<div ref={themeMenuRef} style={{ position: 'relative' }}> <div ref={themeMenuRef} style={{ position: 'relative' }}>
<button <button
className="topbar-icon-btn" className="topbar-icon-btn topbar-motion-item"
aria-label={themeMode === 'system' aria-label={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})` ? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))} : (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
@@ -779,8 +1045,8 @@ function AppShell() {
<div ref={userMenuRef} style={{ position: 'relative' }}> <div ref={userMenuRef} style={{ position: 'relative' }}>
<button <button
type="button" type="button"
className="topbar-avatar" className="topbar-user-trigger topbar-motion-item"
aria-label={displayName} aria-label={`${t('账户菜单')}: ${displayName}`}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={showUserMenu} aria-expanded={showUserMenu}
onClick={() => { onClick={() => {
@@ -788,14 +1054,37 @@ function AppShell() {
setShowThemeMenu(false); setShowThemeMenu(false);
}} }}
> >
<img <span className="topbar-user-avatar">
src={avatarUrl} <LocalAvatar
alt={displayName} className="topbar-user-avatar-inner"
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }} profile={userProfile}
label={displayName}
/> />
</span>
<span className="topbar-user-copy">
<span className="topbar-user-name">{displayName}</span>
<span className="topbar-user-role">{accountRoleLabel}</span>
</span>
<svg className={`topbar-user-chevron ${showUserMenu ? 'open' : ''}`} width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9l6 6 6-6" />
</svg>
</button> </button>
{userMenuPresence.shouldRender && ( {userMenuPresence.shouldRender && (
<div className={`user-dropdown ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}> <div className={`user-dropdown user-dropdown-profile ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div className="user-dropdown-header">
<span className="user-dropdown-avatar">
<LocalAvatar
className="user-dropdown-avatar-inner"
profile={userProfile}
label={displayName}
/>
</span>
<span className="user-dropdown-identity">
<strong>{displayName}</strong>
<span>{accountRoleLabel} / {resolvedThemeLabel}</span>
</span>
</div>
<div className="user-dropdown-divider" />
<button <button
className="user-dropdown-item" className="user-dropdown-item"
onClick={() => { onClick={() => {
@@ -829,8 +1118,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) => (
@@ -840,7 +1129,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)}
> >
@@ -852,7 +1141,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}
@@ -867,6 +1156,7 @@ function AppShell() {
</MobileDrawer> </MobileDrawer>
) : ( ) : (
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}> <aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<nav className="sidebar-scroll" aria-label={t(isUserPage ? '用户工作台' : '管理控制台')}>
{currentSidebarGroups.map((group) => ( {currentSidebarGroups.map((group) => (
<div key={group.label} className="sidebar-group"> <div key={group.label} className="sidebar-group">
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>} {!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
@@ -874,7 +1164,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}
@@ -885,19 +1175,27 @@ function AppShell() {
))} ))}
</div> </div>
))} ))}
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}> </nav>
<div className="sidebar-footer">
<button
className="sidebar-collapse-btn"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
aria-label={sidebarCollapsed ? t('展开侧边栏') : t('收起侧边栏')}
type="button"
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg> </svg>
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>} {!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
</button> </button>
</div>
</aside> </aside>
)} )}
<main className="main-content"> <main className="main-content">
<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 />} />
@@ -914,6 +1212,7 @@ function AppShell() {
<Route path="/settings/notify" element={<NotificationSettings />} /> <Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} /> <Route path="/models" element={<Models />} />
<Route path="/picture-agent" element={<PictureAgent />} /> <Route path="/picture-agent" element={<PictureAgent />} />
<Route path="/money-printer" element={<MoneyPrinter />} />
<Route path="/digital-human" element={<DigitalHuman />} /> <Route path="/digital-human" element={<DigitalHuman />} />
<Route path="/workflows" element={<Workflows />} /> <Route path="/workflows" element={<Workflows />} />
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} /> <Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
@@ -933,6 +1232,7 @@ function AppShell() {
<Route path="/user/usage" element={<UsageRecords />} /> <Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/announcements" element={<UserAnnouncements />} /> <Route path="/user/announcements" element={<UserAnnouncements />} />
<Route path="/user/picture-agent" element={<PictureAgent />} /> <Route path="/user/picture-agent" element={<PictureAgent />} />
<Route path="/user/money-printer" element={<MoneyPrinter />} />
<Route path="/user/digital-human" element={<DigitalHuman />} /> <Route path="/user/digital-human" element={<DigitalHuman />} />
<Route path="/user/workflows" element={<Workflows />} /> <Route path="/user/workflows" element={<Workflows />} />
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} /> <Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
@@ -940,7 +1240,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>
+158 -37
View File
@@ -322,6 +322,15 @@ function normalizeDownstreamKeyRow(row: any): any {
groupName: row.groupName ?? row.group_name ?? null, groupName: row.groupName ?? row.group_name ?? null,
usedCost: Number(row.usedCost ?? row.used_cost ?? 0), usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0), usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
billing: row.billing ?? null,
rangeUsage: row.rangeUsage ?? row.range_usage ?? {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: null,
totalTokens: 0,
totalCost: 0,
},
maxCost: row.maxCost ?? row.max_cost ?? null, maxCost: row.maxCost ?? row.max_cost ?? null,
maxRequests: row.maxRequests ?? row.max_requests ?? null, maxRequests: row.maxRequests ?? row.max_requests ?? null,
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models), supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
@@ -383,6 +392,9 @@ function normalizeProxyLogRow(row: any): any {
clientAppId: row.clientAppId ?? row.client_app_id ?? null, clientAppId: row.clientAppId ?? row.client_app_id ?? null,
clientAppName: row.clientAppName ?? row.client_app_name ?? null, clientAppName: row.clientAppName ?? row.client_app_name ?? null,
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null, clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
contentSummary: row.contentSummary ?? row.content_summary ?? null,
requestContent: row.requestContent ?? row.request_content ?? null,
responseContent: row.responseContent ?? row.response_content ?? null,
createdAt: row.createdAt ?? row.created_at ?? null, createdAt: row.createdAt ?? row.created_at ?? null,
}; };
} }
@@ -683,6 +695,8 @@ export type RuntimeSettingsPayload = {
barkUrl?: string; barkUrl?: string;
webhookEnabled?: boolean; webhookEnabled?: boolean;
barkEnabled?: boolean; barkEnabled?: boolean;
feishuEnabled?: boolean;
feishuWebhook?: string;
serverChanEnabled?: boolean; serverChanEnabled?: boolean;
serverChanKey?: string; serverChanKey?: string;
telegramEnabled?: boolean; telegramEnabled?: boolean;
@@ -775,6 +789,9 @@ export type ProxyLogListItem = {
promptTokens?: number | null; promptTokens?: number | null;
completionTokens?: number | null; completionTokens?: number | null;
estimatedCost?: number | null; estimatedCost?: number | null;
contentSummary?: string | null;
requestContent?: string | null;
responseContent?: string | null;
}; };
export type ProxyLogDetail = ProxyLogListItem & { export type ProxyLogDetail = ProxyLogListItem & {
@@ -1083,6 +1100,13 @@ export type WorkspaceAssetsResponse = {
pageSize: number; pageSize: number;
}; };
export type WorkspaceBatchDeleteResponse = {
success: boolean;
deletedCount: number;
deletedIds: string[];
failedIds: string[];
};
export type WorkspaceAssetQuery = { export type WorkspaceAssetQuery = {
source?: string; source?: string;
kind?: string; kind?: string;
@@ -1142,6 +1166,69 @@ export type WorkflowSavePayload = {
isTemplate?: boolean; isTemplate?: boolean;
}; };
export type DigitalHumanJob = {
id: string;
type: string;
title: string;
status: string;
message?: string | null;
error?: string | null;
progress: number;
progressTotal: number;
result?: any;
createdAt?: string | null;
updatedAt?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
};
export type DigitalHumanJobResponse = {
success: boolean;
job: DigitalHumanJob;
};
export type DigitalHumanJobsResponse = {
success: boolean;
jobs: DigitalHumanJob[];
};
export type MoneyPrinterJob = DigitalHumanJob;
export type MoneyPrinterJobResponse = {
success: boolean;
job: MoneyPrinterJob;
};
export type MoneyPrinterJobsResponse = {
success: boolean;
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 () => {
@@ -1704,43 +1791,6 @@ export const api = {
method: "PUT", method: "PUT",
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
getUpdateCenterStatus: () => request("/api/update-center/status"),
saveUpdateCenterConfig: (data: any) =>
request("/api/update-center/config", {
method: "PUT",
body: JSON.stringify(data),
}),
checkUpdateCenter: () =>
request("/api/update-center/check", {
method: "POST",
body: JSON.stringify({}),
}),
deployUpdateCenter: (data: {
source: "github-release" | "docker-hub-tag";
targetTag: string;
targetDigest?: string | null;
}) =>
request("/api/update-center/deploy", {
method: "POST",
body: JSON.stringify(data),
}),
rollbackUpdateCenter: (data: { targetRevision: string }) =>
request("/api/update-center/rollback", {
method: "POST",
body: JSON.stringify(data),
}),
streamUpdateCenterTaskLogs: (
taskId: string,
handlers: {
onLog?: (entry: any) => void;
onDone?: (payload: any) => void;
signal?: AbortSignal;
},
) =>
streamSse(
`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`,
handlers,
),
testSystemProxy: (data: SystemProxyTestRequest) => testSystemProxy: (data: SystemProxyTestRequest) =>
request("/api/settings/system-proxy/test", { request("/api/settings/system-proxy/test", {
method: "POST", method: "POST",
@@ -2000,6 +2050,11 @@ export const api = {
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`), request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) => deleteWorkspaceAsset: (publicId: string) =>
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }), request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
request<WorkspaceBatchDeleteResponse>("/api/workspace/assets/batch-delete", {
method: "POST",
body: JSON.stringify({ publicIds }),
}),
getWorkflowNodeTypes: () => getWorkflowNodeTypes: () =>
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"), request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
getWorkflows: (params?: { include_public?: boolean }) => getWorkflows: (params?: { include_public?: boolean }) =>
@@ -2020,6 +2075,36 @@ export const api = {
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`), request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) => getWorkflowRun: (publicId: string) =>
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`), request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
createDigitalHumanJob: (data: any) =>
request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getDigitalHumanJobs: (params?: { limit?: number }) =>
request<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
getDigitalHumanJob: (id: string) =>
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
cancelDigitalHumanJob: (id: string) =>
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
request<MoneyPrinterStatusResponse>("/api/money-printer/status"),
createMoneyPrinterJob: (data: any) =>
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getMoneyPrinterJobs: (params?: { limit?: number }) =>
request<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
getMoneyPrinterJob: (id: string) =>
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
cancelMoneyPrinterJob: (id: string) =>
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
}; };
export const userApi = { export const userApi = {
register: (data: { register: (data: {
@@ -2047,6 +2132,7 @@ export const userApi = {
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) => getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`), userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"), getSummary: () => userRequest("/api/user/summary"),
getDashboard: () => userRequest("/api/user/dashboard"),
getModelsMarketplace: (options?: { getModelsMarketplace: (options?: {
refresh?: boolean; refresh?: boolean;
includePricing?: boolean; includePricing?: boolean;
@@ -2069,6 +2155,11 @@ export const userApi = {
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`), userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) => deleteWorkspaceAsset: (publicId: string) =>
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }), userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
userRequest<WorkspaceBatchDeleteResponse>("/api/user/workspace/assets/batch-delete", {
method: "POST",
body: JSON.stringify({ publicIds }),
}),
getWorkflowNodeTypes: () => getWorkflowNodeTypes: () =>
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"), userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
getWorkflows: (params?: { include_public?: boolean }) => getWorkflows: (params?: { include_public?: boolean }) =>
@@ -2089,4 +2180,34 @@ export const userApi = {
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`), userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
getWorkflowRun: (publicId: string) => getWorkflowRun: (publicId: string) =>
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`), userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
createDigitalHumanJob: (data: any) =>
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getDigitalHumanJobs: (params?: { limit?: number }) =>
userRequest<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
getDigitalHumanJob: (id: string) =>
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
cancelDigitalHumanJob: (id: string) =>
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
userRequest<MoneyPrinterStatusResponse>("/api/money-printer/status"),
createMoneyPrinterJob: (data: any) =>
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
method: "POST",
body: JSON.stringify(data),
timeoutMs: 60_000,
}),
getMoneyPrinterJobs: (params?: { limit?: number }) =>
userRequest<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
getMoneyPrinterJob: (id: string) =>
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
cancelMoneyPrinterJob: (id: string) =>
userRequest<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
}; };
+5
View File
@@ -3,7 +3,10 @@ import { clearSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode'; export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const LEGACY_THEME_STORAGE_KEY = 'theme'; export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile'; export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
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;
@@ -24,5 +27,7 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
target.removeItem(THEME_MODE_STORAGE_KEY); target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY); target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY); target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
target.removeItem(FIRST_USE_DOC_REMINDER_KEY); target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
} }
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
type MotionVariant = 'page' | 'login';
interface MotionDivProps extends React.HTMLAttributes<HTMLDivElement> {
motionKey?: string;
motionVariant?: MotionVariant;
}
const PAGE_TARGET_SELECTOR = [
':scope > .console-hero',
':scope > .page-header',
':scope > .card',
':scope > .mobile-card',
':scope > .workflow-shell',
':scope > .picture-agent-shell',
':scope > .workspace-layout',
':scope > .dashboard-grid',
':scope > .digital-human-layout',
':scope > .money-printer-layout',
':scope > .about-page',
].join(', ');
const LOGIN_TARGET_SELECTOR = [
'.login-brand-header',
'.login-brand-copy-block',
'.login-compat-line',
'.login-capability-row',
'.login-auth-eyebrow',
'.login-auth-title',
'.login-auth-copy',
'.login-mode-tabs',
'.login-auth-panel form',
'.login-auth-footer',
].join(', ');
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] {
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18);
}
export default function MotionDiv({
children,
className,
motionKey,
motionVariant = 'page',
...props
}: MotionDivProps) {
const rootRef = useRef<HTMLDivElement>(null);
const resolvedMotionKey = motionKey || motionVariant;
useEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const mm = gsap.matchMedia();
mm.add(
{
reduceMotion: '(prefers-reduced-motion: reduce)',
isDesktop: '(min-width: 900px)',
},
(context) => {
const conditions = context.conditions || {};
if (conditions.reduceMotion) {
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
return undefined;
}
const targets = selectMotionTargets(root, motionVariant);
const distance = motionVariant === 'login' ? 16 : 10;
const rootTween = gsap.fromTo(
root,
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
{
autoAlpha: 1,
y: 0,
duration: motionVariant === 'login' ? 0.46 : 0.34,
ease: 'power2.out',
clearProps: 'transform,opacity,visibility',
},
);
const targetTween = targets.length
? gsap.fromTo(
targets,
{ autoAlpha: 0, y: distance },
{
autoAlpha: 1,
y: 0,
duration: motionVariant === 'login' ? 0.58 : 0.42,
ease: 'power2.out',
stagger: motionVariant === 'login' ? 0.045 : 0.025,
delay: 0.04,
clearProps: 'transform,opacity,visibility',
},
)
: null;
return () => {
rootTween.kill();
targetTween?.kill();
};
},
);
return () => mm.revert();
}, [resolvedMotionKey, motionVariant]);
return (
<div
ref={rootRef}
className={['motion-root', className].filter(Boolean).join(' ')}
{...props}
>
{children}
</div>
);
}
+19 -10
View File
@@ -23,11 +23,11 @@ interface ModelAnalysisPanelProps {
data?: ModelAnalysisData | null; data?: ModelAnalysisData | null;
} }
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [ const tabs: Array<{ key: TabKey; label: string; code: string }> = [
{ key: 'spend', label: '消耗分布', icon: '💰' }, { key: 'spend', label: '消耗分布', code: 'SP' },
{ key: 'trend', label: '消耗趋势', icon: '📈' }, { key: 'trend', label: '消耗趋势', code: 'TR' },
{ key: 'calls', label: '调用分布', icon: '🔄' }, { key: 'calls', label: '调用分布', code: 'CL' },
{ key: 'rank', label: '排行榜', icon: '🏆' }, { key: 'rank', label: '排行榜', code: 'RK' },
]; ];
function toSafeNumber(value: unknown): number { function toSafeNumber(value: unknown): number {
@@ -46,6 +46,13 @@ function formatPercent(value: number): string {
return `${toSafeNumber(value).toFixed(1)}%`; return `${toSafeNumber(value).toFixed(1)}%`;
} }
function formatPiePercent(_text: unknown, datum: unknown): string {
const record = datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
const percent = toSafeNumber(record._percent_);
if (percent <= 0) return '';
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
}
function EmptyBlock() { function EmptyBlock() {
return ( return (
<div className="empty-state" style={{ padding: 28 }}> <div className="empty-state" style={{ padding: 28 }}>
@@ -71,15 +78,16 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const callRanking = (data?.callRanking || []).slice(0, 10); const callRanking = (data?.callRanking || []).slice(0, 10);
const hasData = totals.calls > 0 const hasData = totals.calls > 0
|| spendDistribution.length > 0 || spendDistribution.some((item) => toSafeNumber(item.spend) > 0)
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0); || spendTrend.some((item) => toSafeNumber(item.spend) > 0)
|| callsDistribution.some((item) => toSafeNumber(item.calls) > 0);
const spendBarSpec = useMemo(() => ({ const spendBarSpec = useMemo(() => ({
type: 'bar' as const, type: 'bar' as const,
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }], data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
xField: 'value', yField: 'model', direction: 'horizontal' as const, xField: 'value', yField: 'model', direction: 'horizontal' as const,
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } }, bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } }, label: { visible: true, position: 'right', formatMethod: (text: unknown) => formatCurrency(Number(text || 0)), style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }], axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
animation: true, background: 'transparent', animation: true, background: 'transparent',
}), [spendDistribution, labelColor]); }), [spendDistribution, labelColor]);
@@ -102,7 +110,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
valueField: 'calls', categoryField: 'model', valueField: 'calls', categoryField: 'model',
outerRadius: 0.8, innerRadius: 0.55, outerRadius: 0.8, innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } }, pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } }, label: { visible: true, position: 'outside', formatMethod: formatPiePercent, style: { fill: labelColor, fontSize: 12, stroke: 'transparent' } },
legends: { visible: false }, legends: { visible: false },
animation: true, animation: true,
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'], color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
@@ -138,7 +146,8 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`} className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
> >
{tab.icon} {tab.label} <span className="pill-tab-code">{tab.code}</span>
{tab.label}
</button> </button>
))} ))}
</div> </div>
+32
View File
@@ -0,0 +1,32 @@
import type { CSSProperties, ReactNode } from 'react';
type PageHeaderProps = {
title: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
className?: string;
style?: CSSProperties;
titleAs?: 'h1' | 'h2';
};
export default function PageHeader({
title,
subtitle,
actions,
className = '',
style,
titleAs = 'h2',
}: PageHeaderProps) {
const Heading = titleAs;
const classes = ['page-header', className].filter(Boolean).join(' ');
return (
<div className={classes} style={style}>
<div className="page-header-copy">
<Heading className="page-title">{title}</Heading>
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
</div>
{actions ? <div className="page-actions">{actions}</div> : null}
</div>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export {
stripCommonWrappers, stripCommonWrappers,
type BrandInfo, type BrandInfo,
type BrandMatchContext, type BrandMatchContext,
} from '../../server/shared/modelBrand.js'; } from '../../shared/modelBrand.js';
const LEGACY_ICON_ALIASES: Record<string, string> = { const LEGACY_ICON_ALIASES: Record<string, string> = {
anthropic: 'claude-color', anthropic: 'claude-color',
@@ -4,10 +4,13 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
interface SiteDistributionData { interface SiteDistributionData {
siteName: string; siteName: string;
platform: string; platform?: string | null;
totalBalance: number; totalBalance?: number;
totalSpend: number; totalSpend?: number;
accountCount: number; accountCount?: number;
spend?: number;
calls?: number;
tokens?: number;
} }
interface SiteDistributionChartProps { interface SiteDistributionChartProps {
@@ -26,6 +29,13 @@ function safeNumber(value: unknown): number {
return value; return value;
} }
function formatPercentLabel(datum: unknown): string {
const item = coerceDatumRecord(datum);
const percent = safeNumber(item._percent_);
if (percent <= 0) return '';
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
}
function SkeletonCircle() { function SkeletonCircle() {
return ( return (
<div <div
@@ -98,8 +108,11 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
return data.map((item) => ({ return data.map((item) => ({
siteName: item.siteName, siteName: item.siteName,
platform: item.platform, platform: item.platform,
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend), value: safeNumber(viewMode === 'balance'
? item.totalBalance
: item.totalSpend ?? item.spend),
accountCount: safeNumber(item.accountCount), accountCount: safeNumber(item.accountCount),
calls: safeNumber(item.calls),
})); }));
}, [data, viewMode]); }, [data, viewMode]);
@@ -118,7 +131,12 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
outerRadius: 0.8, outerRadius: 0.8,
innerRadius: 0.55, innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } }, pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } }, label: {
visible: true,
position: 'outside',
formatMethod: (_text: unknown, datum: unknown) => formatPercentLabel(datum),
style: { fill: labelColor, fontSize: 12, stroke: 'transparent' },
},
legends: { visible: false }, legends: { visible: false },
tooltip: { tooltip: {
mark: { mark: {
@@ -149,6 +167,13 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
return String(item.accountCount || 0); return String(item.accountCount || 0);
}, },
}, },
{
key: '调用',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.calls || 0);
},
},
] as any, ] as any,
}, },
}, },
@@ -159,6 +184,7 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
}, [chartData, hasData, labelColor]); }, [chartData, hasData, labelColor]);
const formatValue = (value: number): string => { const formatValue = (value: number): string => {
if (viewMode === 'balance' && value === 0) return '$0.00';
if (value >= 1000) return `$${value.toFixed(2)}`; if (value >= 1000) return `$${value.toFixed(2)}`;
if (value >= 1) return `$${value.toFixed(3)}`; if (value >= 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(6)}`; return `$${value.toFixed(6)}`;
+38 -12
View File
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart'; import { VChart } from '@visactor/react-vchart';
import { useThemeLabelColor } from '../useThemeLabelColor.js';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
@@ -7,7 +8,10 @@ import { VChart } from '@visactor/react-vchart';
interface SiteTrendData { interface SiteTrendData {
date: string; date: string;
sites: Record<string, { spend: number; calls: number }>; sites?: Record<string, { spend?: number; calls?: number; tokens?: number }>;
spend?: number;
calls?: number;
tokens?: number;
} }
interface SiteTrendChartProps { interface SiteTrendChartProps {
@@ -43,20 +47,26 @@ const COLOR_PALETTE = [
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) { export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
const [metric, setMetric] = useState<Metric>('spend'); const [metric, setMetric] = useState<Metric>('spend');
const labelColor = useThemeLabelColor();
/* ---------- data transform ---------- */ /* ---------- data transform ---------- */
const flatData = useMemo(() => { const flatData = useMemo(() => {
if (!data || data.length === 0) return []; if (!data || data.length === 0) return [];
return data.flatMap((d) => return data.flatMap((d) => {
Object.entries(d.sites).map(([site, v]) => ({ const sites = d.sites && Object.keys(d.sites).length > 0
? d.sites
: { 全部站点: { spend: d.spend, calls: d.calls, tokens: d.tokens } };
return Object.entries(sites).map(([site, v]) => ({
date: d.date, date: d.date,
site, site,
value: metric === 'spend' ? v.spend : v.calls, value: metric === 'spend' ? Number(v.spend ?? 0) : Number(v.calls ?? 0),
})), }));
); });
}, [data, metric]); }, [data, metric]);
const hasPositiveValue = flatData.some((item) => Number(item.value || 0) > 0);
/* ---------- loading state ---------- */ /* ---------- loading state ---------- */
if (loading) { if (loading) {
@@ -72,7 +82,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
/* ---------- empty state ---------- */ /* ---------- empty state ---------- */
if (!data || data.length === 0 || flatData.length === 0) { if (!data || data.length === 0 || flatData.length === 0 || !hasPositiveValue) {
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
<div style={headerStyle}> <div style={headerStyle}>
@@ -80,7 +90,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
</div> </div>
<div className="empty-state" style={{ padding: 48 }}> <div className="empty-state" style={{ padding: 48 }}>
<div className="empty-state-title">暂无趋势数据</div> <div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div> <div className="empty-state-desc">所选时间范围内还没有有效的{metric === 'spend' ? '消耗' : '调用'}记录</div>
</div> </div>
</div> </div>
); );
@@ -144,22 +154,38 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
axes: [ axes: [
{ {
orient: 'bottom', orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } }, label: {
style: { fontSize: 11, fill: labelColor },
formatMethod: (value: string) => {
const text = String(value || '');
if (text.length >= 10) return text.slice(5, 10);
return text;
},
},
domainLine: { style: { stroke: 'var(--color-border-light)' } }, domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } }, tick: { style: { stroke: 'var(--color-border-light)' } },
}, },
{ {
orient: 'left', orient: 'left',
label: { label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' }, style: { fontSize: 11, fill: labelColor },
formatMethod: (value: string) => {
const n = Number(value || 0);
if (!Number.isFinite(n)) return String(value || '');
if (metric === 'spend') {
if (n >= 1) return `$${n.toFixed(1)}`;
return `$${n.toFixed(4)}`;
}
return Math.round(n).toLocaleString();
}, },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } }, },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [3, 5], lineWidth: 1 } },
domainLine: { visible: false }, domainLine: { visible: false },
}, },
], ],
color: COLOR_PALETTE, color: COLOR_PALETTE,
background: 'transparent', background: 'transparent',
padding: { left: 8, right: 16, top: 8, bottom: 8 }, padding: { left: 48, right: 18, top: 12, bottom: 36 },
}; };
/* ---------- render ---------- */ /* ---------- render ---------- */
+7 -6
View File
@@ -8,11 +8,12 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const targetWindow = window;
const query = breakpoint === MOBILE_BREAKPOINT const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY ? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`; : `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null; const media = typeof targetWindow.matchMedia === 'function' ? targetWindow.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint); const update = () => setIsMobile(media ? media.matches : targetWindow.innerWidth <= breakpoint);
update(); update();
if (media && typeof media.addEventListener === 'function') { if (media && typeof media.addEventListener === 'function') {
@@ -24,14 +25,14 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
}; };
} }
const addResizeListener = typeof window.addEventListener === 'function'; const addResizeListener = typeof targetWindow.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function'; const removeResizeListener = typeof targetWindow.removeEventListener === 'function';
if (!addResizeListener) return; if (!addResizeListener) return;
window.addEventListener('resize', update); targetWindow.addEventListener('resize', update);
return () => { return () => {
if (removeResizeListener) { if (removeResizeListener) {
window.removeEventListener('resize', update); targetWindow.removeEventListener('resize', update);
} }
}; };
}, [breakpoint]); }, [breakpoint]);
+14
View File
@@ -180,6 +180,18 @@ export const zhToEnSupplemental: Record<string, string> = {
'个模型': 'models', '个模型': 'models',
'个人信息': 'personal information', '个人信息': 'personal information',
'个人信息已保存': 'Personal information has been saved', '个人信息已保存': 'Personal information has been saved',
'头像实时预览': 'Avatar live preview',
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
'本地上传头像': 'Upload local avatar',
'选择图片': 'Choose image',
'已选择本地头像': 'Local avatar selected',
'未选择文件': 'No file selected',
'图片会压缩后保存在本机浏览器,不上传服务器。': 'The image is compressed and saved in this browser, not uploaded to the server.',
'头像文件必须是图片格式': 'Avatar file must be an image',
'头像图片不能超过 5MB': 'Avatar image must be under 5MB',
'当前浏览器无法处理头像图片': 'This browser cannot process the avatar image',
'头像上传失败': 'Avatar upload failed',
'头像图片读取失败,请重新上传': 'Avatar image could not be read. Please upload it again.',
'个站点': 'sites', '个站点': 'sites',
'个账号': 'accounts', '个账号': 'accounts',
'跟随系统': 'Follow the system', '跟随系统': 'Follow the system',
@@ -205,6 +217,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}', '缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}',
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.', '缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)', '缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
'清除图片头像': 'Clear image avatar',
'换一个随机头像': 'Change a random avatar', '换一个随机头像': 'Change a random avatar',
'恢复待处理的请求快照。': 'Restore a pending request snapshot.', '恢复待处理的请求快照。': 'Restore a pending request snapshot.',
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.', '恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
@@ -468,6 +481,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'收件人地址': 'Recipient address', '收件人地址': 'Recipient address',
'收起': 'close', '收起': 'close',
'收起侧边栏': 'Collapse sidebar', '收起侧边栏': 'Collapse sidebar',
'展开侧边栏': 'Expand sidebar',
'收起高级参数': 'Close advanced parameters', '收起高级参数': 'Close advanced parameters',
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.', '手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
'手动增改路由': 'Manually add and modify routes', '手动增改路由': 'Manually add and modify routes',
+82 -1
View File
@@ -7,6 +7,9 @@ const LANGUAGE_STORAGE_KEY = 'app_language';
const zhToEn: Record<string, string> = { const zhToEn: Record<string, string> = {
'管理员': 'Admin', '管理员': 'Admin',
'管理控制台': 'Admin Console',
'用户工作台': 'User Workspace',
'账户菜单': 'Account Menu',
'登录令牌无效': 'Invalid admin token', '登录令牌无效': 'Invalid admin token',
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist', '当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
'当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):', '当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):',
@@ -19,6 +22,14 @@ 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',
'第三方登录': 'Third-party sign-in',
'第三方账号快速注册': 'Quick sign-up with third-party accounts',
'接入后可自动创建账号,并绑定同一用户身份。': 'Once connected, accounts can be created automatically and bound to the same user identity.',
'管理员配置 OAuth 后即可启用这些入口。': 'These entries become available after an admin configures OAuth.',
'等待管理员配置 OAuth 后启用': 'Waiting for an admin to configure OAuth',
'待配置': 'Not configured',
'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',
@@ -26,10 +37,24 @@ const zhToEn: Record<string, string> = {
'用户名最多 24 个字符': 'Username can be at most 24 characters', '用户名最多 24 个字符': 'Username can be at most 24 characters',
'个人信息': 'Profile', '个人信息': 'Profile',
'右上角头像实时预览': 'Top-right avatar live preview', '右上角头像实时预览': 'Top-right avatar live preview',
'头像实时预览': 'Avatar live preview',
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
'用户名': 'Username', '用户名': 'Username',
'例如:小王': 'e.g. Alex', '例如:小王': 'e.g. Alex',
'本地上传头像': 'Upload Local Avatar',
'选择图片': 'Choose Image',
'已选择本地头像': 'Local avatar selected',
'未选择文件': 'No file selected',
'图片会压缩后保存在本机浏览器,不上传服务器。': 'The image is compressed and saved in this browser, not uploaded to the server.',
'头像文件必须是图片格式': 'Avatar file must be an image',
'头像图片不能超过 5MB': 'Avatar image must be under 5MB',
'当前浏览器无法处理头像图片': 'This browser cannot process the avatar image',
'头像上传失败': 'Avatar upload failed',
'头像图片读取失败,请重新上传': 'Avatar image could not be read. Please upload it again.',
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:', '头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
'头像 · 风格:': 'Avatar · Style: ',
'换一个随机头像': 'Randomize Avatar', '换一个随机头像': 'Randomize Avatar',
'清除图片头像': 'Clear Image Avatar',
'取消': 'Cancel', '取消': 'Cancel',
'保存': 'Save', '保存': 'Save',
'关闭': 'Close', '关闭': 'Close',
@@ -85,6 +110,62 @@ const zhToEn: Record<string, string> = {
'模型测试': 'Model Testing', '模型测试': 'Model Testing',
'关于': 'About', '关于': 'About',
'关于 BoosAPI': 'About BoosAPI', '关于 BoosAPI': 'About BoosAPI',
'BoosAPI 网关内核驱动': 'Powered by the BoosAPI gateway core',
'一个平台,接住模型、工作流和多模态生产。': 'One platform for models, workflows, and multimodal production.',
'BossAI 把多站点账号、模型路由、兼容 API、ComfyUI 工作流、创作助手和普通用户工作空间收进同一个自托管控制台。': 'BossAI brings multi-site accounts, model routing, compatible APIs, ComfyUI workflows, creative assistants, and user workspaces into one self-hosted console.',
'核心兼容能力': 'Core compatibility',
'兼容协议': 'Compatible protocol',
'消息入口': 'Messages endpoint',
'工作流前端': 'Workflow frontend',
'平台运行结构': 'Platform runtime structure',
'当前版本': 'Current version',
'平台能力概览': 'Platform capability overview',
'工作流后端': 'Workflow backends',
'自托管服务': 'Self-hosted service',
'统一模型入口': 'Unified model entry',
'OpenAI、Claude、Responses、图像和视频接口集中到一套鉴权、计费和日志体系。': 'OpenAI, Claude, Responses, image, and video interfaces share one authentication, billing, and logging system.',
'模型路由中枢': 'Model routing hub',
'按站点、账号、成本、健康度和冷却状态选择通道,失败后自动避让。': 'Select channels by site, account, cost, health, and cooldown state, with automatic failover.',
'工作流执行面': 'Workflow execution surface',
'嵌入官方 ComfyUI 前端,同时接入 Cloud、RunningHub、外部 ComfyUI 和 BoosAPI 执行器。': 'Embed the official ComfyUI frontend while connecting Cloud, RunningHub, external ComfyUI, and the BoosAPI executor.',
'多模态应用层': 'Multimodal app layer',
'生图助手、视频助手、数字人和短视频工厂复用同一套模型路由与用户 Key。': 'Picture Agent, Video Assistant, Digital Human, and Short Video Factory reuse the same model routing and user keys.',
'运行能力': 'Runtime capabilities',
'围绕接入、路由、创作和运维,把 AI 应用需要的底座放在一个控制台里。': 'Access, routing, creation, and operations are organized into one console for AI apps.',
'站点与账号': 'Sites and accounts',
'维护上游站点、账号、OAuth、API Key、余额和连接状态。': 'Manage upstream sites, accounts, OAuth, API keys, balances, and connection state.',
'聚合模型可用性、价格、账号覆盖、绑定令牌和路由健康度。': 'Aggregate model availability, prices, account coverage, bound tokens, and route health.',
'用户体系': 'User system',
'普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。': 'Regular users have independent API keys, usage logs, workspaces, and visible model scopes.',
'审计告警': 'Audit and alerts',
'使用日志、程序日志、通知渠道和运行事件集中留痕。': 'Usage logs, program logs, notification channels, and runtime events are recorded centrally.',
'只归档最终产出,按用户隔离图片、视频、音频和工作流资产。': 'Archive only final outputs, with images, videos, audio, and workflow assets isolated by user.',
'任务编排': 'Task orchestration',
'后台生成、取消、查询、归档和失败原因追踪保持一致体验。': 'Background generation, cancellation, queries, archiving, and failure tracking stay consistent.',
'请求处理路径': 'Request processing path',
'接入请求': 'Receive request',
'下游 Key、用户身份和协议类型先进入统一网关。': 'Downstream keys, user identity, and protocol type enter the unified gateway first.',
'选择通道': 'Select channel',
'结合路由优先级、账号状态、成本、延迟和冷却策略选择上游。': 'Choose an upstream using route priority, account state, cost, latency, and cooldown policy.',
'执行生产': 'Execute production',
'对话、生图、视频、工作流和数字人任务共享同一套执行链路。': 'Chat, image, video, workflow, and digital human tasks share one execution path.',
'记录归档': 'Record and archive',
'调用日志、费用、延迟和最终产出进入用户隔离的工作空间。': 'Call logs, cost, latency, and final outputs enter user-isolated workspaces.',
'OpenAI 对话兼容入口,支持流式与非流式。': 'OpenAI-compatible chat endpoint with streaming and non-streaming support.',
'Claude Messages API 兼容入口,支持 Anthropic 请求和事件。': 'Claude Messages API-compatible endpoint for Anthropic requests and events.',
'Responses API 入口,适配支持该协议的上游。': 'Responses API endpoint for upstreams that support the protocol.',
'图像生成入口,供生图助手与工作流节点调用。': 'Image generation endpoint for Picture Agent and workflow nodes.',
'视频任务创建入口,可接火山方舟或 Comfy 视频模板。': 'Video task creation endpoint for Volcengine Ark or Comfy video templates.',
'只暴露当前可路由、未冷却且账号有效的模型。': 'Expose only currently routable models with valid accounts and no cooldown.',
'Comfy Cloud API 后端,适合官方订阅和云端执行。': 'Comfy Cloud API backend for official subscriptions and cloud execution.',
'RunningHub API 后端,适合复用插件、模型和模板生态。': 'RunningHub API backend for plugin, model, and template ecosystems.',
'连接自部署完整版 ComfyUI,由 BossAI 统一代理和嵌入。': 'Connect a self-hosted full ComfyUI instance through BossAI proxy and embedding.',
'Rust API-native 执行器,直接调用后台模型路由。': 'Rust API-native executor that directly calls backend model routing.',
'本地完整版 ComfyUI 模式,适合有 GPU 和完整模型目录的环境。': 'Local full ComfyUI mode for environments with a GPU and full model directory.',
'技术底座': 'Technical foundation',
'后端、前端和工作流资产都由同一个 Rust 服务统一托管。': 'Backend, frontend, and workflow assets are served by the same Rust service.',
'数据与隐私': 'Data and privacy',
'BossAI 完全自托管。账号、令牌、路由、日志、用户资产和工作台状态都保存在你的运行环境中,系统 Key 默认脱敏并受保护。': 'BossAI is fully self-hosted. Accounts, tokens, routes, logs, user assets, and workspace state stay in your runtime, with system keys masked and protected by default.',
'站点文档': 'Site Docs', '站点文档': 'Site Docs',
'任务状态已更新': 'Task status updated', '任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again', '会话已过期,请重新登录': 'Session expired, please sign in again',
@@ -102,6 +183,7 @@ const zhToEn: Record<string, string> = {
'深色模式': 'Dark Mode', '深色模式': 'Dark Mode',
'退出登录': 'Sign Out', '退出登录': 'Sign Out',
'收起侧边栏': 'Collapse Sidebar', '收起侧边栏': 'Collapse Sidebar',
'展开侧边栏': 'Expand Sidebar',
'系统设置': 'System Settings', '系统设置': 'System Settings',
'站点管理': 'Site Management', '站点管理': 'Site Management',
'账号管理': 'Connection Management', '账号管理': 'Connection Management',
@@ -321,7 +403,6 @@ const zhToEn: Record<string, string> = {
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM', '轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
'零配置嵌入式数据库': 'Zero-config embedded database', '零配置嵌入式数据库': 'Zero-config embedded database',
'项目链接': 'Project Links', '项目链接': 'Project Links',
'数据与隐私': 'Data & Privacy',
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.', 'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
// User SaaS translations // User SaaS translations
+6298 -110
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -3,12 +3,9 @@
<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.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen">
<script> <script>
+289 -140
View File
@@ -1,179 +1,328 @@
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
const VERSION = '1.3.0'; const VERSION = '1.3.0';
const FEATURES = [ const HERO_TAGS = [
{ icon: 'GW', title: '统一模型网关', desc: '一个入口聚合 OpenAI / Claude / Gemini / 方舟等兼容上游,统一鉴权、计费与日志。' }, 'Unified API Gateway',
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' }, 'Intelligent Routing',
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' }, 'Cloud Workflow',
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' }, 'Workspace Archive',
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' }, 'Multimodal Agents',
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
]; ];
const TECH_STACK = [ const HERO_SIGNALS = [
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' }, { value: 'OpenAI', label: '兼容协议' },
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' }, { value: 'Claude', label: '消息入口' },
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' }, { value: 'Comfy', label: '工作流前端' },
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' }, ];
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' }, const CAPABILITIES = [
{
label: 'Gateway',
title: '统一模型入口',
desc: 'OpenAI、Claude、Responses、图像和视频接口集中到一套鉴权、计费和日志体系。',
stat: '/v1/*',
},
{
label: 'Routing',
title: '模型路由中枢',
desc: '按站点、账号、成本、健康度和冷却状态选择通道,失败后自动避让。',
stat: 'P0 / P1',
},
{
label: 'Workflow',
title: '工作流执行面',
desc: '嵌入官方 ComfyUI 前端,同时接入 Cloud、RunningHub、外部 ComfyUI 和 BoosAPI 执行器。',
stat: '5 modes',
},
{
label: 'Agents',
title: '多模态应用层',
desc: '生图助手、视频助手、数字人和短视频工厂复用同一套模型路由与用户 Key。',
stat: '4 apps',
},
];
const OPERATIONS = [
{ title: '站点与账号', desc: '维护上游站点、账号、OAuth、API Key、余额和连接状态。' },
{ title: '模型广场', desc: '聚合模型可用性、价格、账号覆盖、绑定令牌和路由健康度。' },
{ title: '用户体系', desc: '普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。' },
{ title: '审计告警', desc: '使用日志、程序日志、通知渠道和运行事件集中留痕。' },
{ title: '工作空间', desc: '只归档最终产出,按用户隔离图片、视频、音频和工作流资产。' },
{ title: '任务编排', desc: '后台生成、取消、查询、归档和失败原因追踪保持一致体验。' },
]; ];
const ENDPOINTS = [ const ENDPOINTS = [
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' }, { path: '/v1/chat/completions', desc: 'OpenAI 对话兼容入口,支持流式与非流式。' },
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' }, { path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求和事件。' },
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' }, { path: '/v1/responses', desc: 'Responses API 入口,适配支持该协议的上游。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' }, { path: '/v1/images/generations', desc: '图像生成入口,供生图助手与工作流节点调用。' },
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' }, { path: '/v1/videos', desc: '视频任务创建入口,可接火山方舟或 Comfy 视频模板。' },
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' }, { path: '/v1/models', desc: '只暴露当前可路由、未冷却且账号有效的模型。' },
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' }, ];
const WORKFLOW_BACKENDS = [
{ name: 'cloud', desc: 'Comfy Cloud API 后端,适合官方订阅和云端执行。' },
{ name: 'runninghub', desc: 'RunningHub API 后端,适合复用插件、模型和模板生态。' },
{ name: 'external', desc: '连接自部署完整版 ComfyUI,由 BossAI 统一代理和嵌入。' },
{ name: 'boosapi', desc: 'Rust API-native 执行器,直接调用后台模型路由。' },
{ name: 'docker', desc: '本地完整版 ComfyUI 模式,适合有 GPU 和完整模型目录的环境。' },
];
const TECH_STACK = [
'Rust',
'Axum',
'Tokio',
'SQLx',
'React',
'Vite',
'GSAP',
'ComfyUI Frontend',
]; ];
export default function About() { export default function About() {
const rootRef = useRef<HTMLDivElement>(null);
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 || {};
const motionItems = Array.from(root.querySelectorAll<HTMLElement>('.about-motion-item'));
const orbitItems = Array.from(root.querySelectorAll<HTMLElement>('.about-orbit-card'));
const orbitCore = root.querySelector<HTMLElement>('.about-orbit-core');
if (conditions.reduceMotion) {
gsap.set([...motionItems, ...orbitItems, orbitCore].filter(Boolean), {
autoAlpha: 1,
clearProps: 'transform,opacity,visibility',
});
return undefined;
}
const enterTween = gsap.fromTo(
motionItems,
{ autoAlpha: 0, y: conditions.isDesktop ? 18 : 10, scale: 0.99 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.54,
ease: 'power2.out',
stagger: 0.045,
clearProps: 'transform,opacity,visibility',
},
);
const orbitTween = orbitItems.length
? gsap.fromTo(
orbitItems,
{ autoAlpha: 0, y: 12 },
{
autoAlpha: 1,
y: 0,
duration: 0.48,
ease: 'power2.out',
stagger: { each: 0.06, from: 'center' },
delay: 0.12,
clearProps: 'transform,opacity,visibility',
},
)
: null;
const floatTween = orbitCore
? gsap.to(orbitCore, {
y: -4,
duration: 2.4,
ease: 'sine.inOut',
repeat: -1,
yoyo: true,
})
: null;
return () => {
enterTween.kill();
orbitTween?.kill();
floatTween?.kill();
};
},
);
return () => mm.revert();
}, []);
return ( return (
<div className="animate-fade-in" style={{ maxWidth: 860 }}> <div ref={rootRef} className="about-page">
{/* Header */} <section className="about-hero about-motion-item">
<div className="page-header" style={{ marginBottom: 14 }}> <div className="about-hero-copy-block">
<h2 className="page-title">{tr('关于 BoosAPI')}</h2> <div className="about-brand">
<img src="/logo.png" alt="BossAI" className="about-logo" />
<div>
<div className="about-brand-name">BossAI</div>
<div className="about-brand-meta">{tr('BoosAPI 网关内核驱动')}</div>
</div>
</div> </div>
{/* Hero card */} <h1>{tr('一个平台,接住模型、工作流和多模态生产。')}</h1>
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}> <p>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}> {tr('BossAI 把多站点账号、模型路由、兼容 API、ComfyUI 工作流、创作助手和普通用户工作空间收进同一个自托管控制台。')}
<img </p>
src="/logo.png"
alt="BoosAPI" <div className="about-proof-row" aria-label={tr('核心兼容能力')}>
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }} {HERO_SIGNALS.map((item) => (
/> <div key={item.value}>
<div> <strong>{item.value}</strong>
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div> <span>{tr(item.label)}</span>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
</div> </div>
))}
</div> </div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')} <div className="about-chip-row">
</div> {HERO_TAGS.map((tag) => (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}> <span key={tag} className="about-chip">{tag}</span>
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
<span
key={tag}
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '5px 9px',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'var(--color-bg-secondary)',
}}
>
{tag}
</span>
))} ))}
</div> </div>
</div> </div>
{/* Features */} <div className="about-platform-card" aria-label={tr('平台运行结构')}>
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}> <div className="about-version">{tr('当前版本')} v{VERSION}</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3> <div className="about-orbit">
<div style={{ <div className="about-orbit-core">
display: 'grid', <img src="/logo.png" alt="" />
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', <span>BossAI</span>
gap: 10, </div>
}}> <div className="about-orbit-card orbit-card-1">API Gateway</div>
{FEATURES.map((f) => ( <div className="about-orbit-card orbit-card-2">Route Engine</div>
<div key={f.title} style={{ <div className="about-orbit-card orbit-card-3">Workflow UI</div>
display: 'flex', gap: 10, padding: '8px 0', <div className="about-orbit-card orbit-card-4">Agent Apps</div>
borderBottom: '1px solid var(--color-border-light)', </div>
}}> <div className="about-signal-grid" aria-label={tr('平台能力概览')}>
<span style={{
width: 34,
height: 26,
borderRadius: 8,
border: '1px solid var(--color-border-light)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
color: 'var(--color-primary)',
background: 'var(--color-bg-secondary)',
flexShrink: 0,
}}>
{f.icon}
</span>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div> <strong>7</strong>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}> <span>{tr('兼容入口')}</span>
{tr(f.desc)} </div>
<div>
<strong>5</strong>
<span>{tr('工作流后端')}</span>
</div>
<div>
<strong>1</strong>
<span>{tr('自托管服务')}</span>
</div> </div>
</div> </div>
</div> </div>
</section>
<section className="about-capability-grid about-motion-item">
{CAPABILITIES.map((item) => (
<article key={item.title} className="about-capability-card">
<div className="about-capability-topline">
<span>{item.label}</span>
<code>{item.stat}</code>
</div>
<h3>{tr(item.title)}</h3>
<p>{tr(item.desc)}</p>
</article>
))}
</section>
<section className="about-section about-motion-item">
<div className="about-section-heading">
<h2>{tr('运行能力')}</h2>
<p>{tr('围绕接入、路由、创作和运维,把 AI 应用需要的底座放在一个控制台里。')}</p>
</div>
<div className="about-ops-grid">
{OPERATIONS.map((item) => (
<div key={item.title} className="about-ops-item">
<span>{tr(item.title)}</span>
<p>{tr(item.desc)}</p>
</div>
))} ))}
</div> </div>
</div> </section>
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}> <section className="about-flow about-motion-item" aria-label={tr('请求处理路径')}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3> <div className="about-flow-step">
<div style={{ display: 'grid', gap: 9 }}> <span>01</span>
<strong>{tr('接入请求')}</strong>
<p>{tr('下游 Key、用户身份和协议类型先进入统一网关。')}</p>
</div>
<div className="about-flow-step">
<span>02</span>
<strong>{tr('选择通道')}</strong>
<p>{tr('结合路由优先级、账号状态、成本、延迟和冷却策略选择上游。')}</p>
</div>
<div className="about-flow-step">
<span>03</span>
<strong>{tr('执行生产')}</strong>
<p>{tr('对话、生图、视频、工作流和数字人任务共享同一套执行链路。')}</p>
</div>
<div className="about-flow-step">
<span>04</span>
<strong>{tr('记录归档')}</strong>
<p>{tr('调用日志、费用、延迟和最终产出进入用户隔离的工作空间。')}</p>
</div>
</section>
<div className="about-two-column about-motion-item">
<section className="about-section">
<div className="about-section-heading">
<h2>{tr('工作流后端')}</h2>
</div>
<div className="about-definition-list">
{WORKFLOW_BACKENDS.map((backend) => (
<div key={backend.name} className="about-definition-row">
<code>{backend.name}</code>
<div>{tr(backend.desc)}</div>
</div>
))}
</div>
</section>
<section className="about-section about-api-section">
<div className="about-section-heading">
<h2>{tr('兼容入口')}</h2>
</div>
<div className="about-definition-list">
{ENDPOINTS.map((endpoint) => ( {ENDPOINTS.map((endpoint) => (
<div <div key={endpoint.path} className="about-definition-row about-endpoint-row">
key={endpoint.path} <code>{endpoint.path}</code>
style={{ <div>{tr(endpoint.desc)}</div>
display: 'grid',
gridTemplateColumns: 'minmax(190px, 260px) 1fr',
gap: 12,
alignItems: 'start',
padding: '9px 0',
borderBottom: '1px solid var(--color-border-light)',
}}
>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-primary)' }}>
{endpoint.path}
</code>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.6 }}>
{tr(endpoint.desc)}
</div> </div>
))}
</div> </div>
</section>
</div>
<section className="about-bottom-grid about-motion-item">
<div className="about-section about-tech-panel">
<div className="about-section-heading">
<h2>{tr('技术底座')}</h2>
<p>{tr('后端、前端和工作流资产都由同一个 Rust 服务统一托管。')}</p>
</div>
<div className="about-tech-strip">
{TECH_STACK.map((item) => (
<span key={item}>{item}</span>
))} ))}
</div> </div>
</div> </div>
{/* Tech Stack */} <div className="about-section about-privacy">
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}> <h2>{tr('数据与隐私')}</h2>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3> <p>
<div style={{ {tr('BossAI 完全自托管。账号、令牌、路由、日志、用户资产和工作台状态都保存在你的运行环境中,系统 Key 默认脱敏并受保护。')}
display: 'grid', </p>
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
}}>
{TECH_STACK.map((t) => (
<div key={t.name} style={{
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
{tr(t.desc)}
</div>
</div>
))}
</div>
</div>
{/* Privacy */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
</div>
</div> </div>
</section>
</div> </div>
); );
} }
@@ -1,60 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import About from './About.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getUpdateCenterStatus: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
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();
});
}
describe('About page', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('shows product information without update prompts or project links', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<About />
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('关于 BoosAPI');
expect(text).toContain('核心特色');
expect(text).toContain('兼容入口');
expect(text).not.toContain('更新提醒');
expect(text).not.toContain('前往更新中心');
expect(text).not.toContain('项目链接');
expect(apiMock.getUpdateCenterStatus).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});
+1 -1
View File
@@ -1339,7 +1339,7 @@ export default function Accounts() {
: canAddVerifiedConnection; : canAddVerifiedConnection;
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in admin-ops-page accounts-ops-page">
<div className="page-header"> <div className="page-header">
<h2 className="page-title">{tr("连接管理")}</h2> <h2 className="page-title">{tr("连接管理")}</h2>
{activeSegment !== "tokens" && ( {activeSegment !== "tokens" && (
+86 -7
View File
@@ -112,6 +112,9 @@ const QUICK_PROMPTS = [
'根据已有角色和场景,继续生成 ComfyUI 工作流 JSON', '根据已有角色和场景,继续生成 ComfyUI 工作流 JSON',
]; ];
const STREAMING_REASONING_PLACEHOLDER = '正在等待模型返回真实思考过程,收到后会实时替换这里。';
const STREAMING_BODY_PLACEHOLDER = '模型正在组织正文和素材步骤...';
const SEEDANCE_PROMPT_TEMPLATES = [ const SEEDANCE_PROMPT_TEMPLATES = [
{ {
title: '文生视频', title: '文生视频',
@@ -651,7 +654,7 @@ function renderMarkdown(content: string): string {
} }
function processThinkTags(content: string, reasoningContent = '') { function processThinkTags(content: string, reasoningContent = '') {
if (!content.includes('<think>')) { if (!content || !/<think>/i.test(content)) {
return { content, reasoningContent }; return { content, reasoningContent };
} }
const thoughts: string[] = []; const thoughts: string[] = [];
@@ -665,7 +668,18 @@ function processThinkTags(content: string, reasoningContent = '') {
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
replyParts.push(content.substring(lastIndex)); replyParts.push(content.substring(lastIndex));
const cleanContent = replyParts.join('').replace(/<\/?think>/gi, '').trim();
let cleanContent = replyParts.join('');
const lastOpenThinkIndex = cleanContent.toLowerCase().lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = cleanContent.substring(lastOpenThinkIndex + '<think>'.length);
if (!/<\/think>/i.test(fragmentAfterLastOpen)) {
thoughts.push(fragmentAfterLastOpen.trim());
cleanContent = cleanContent.substring(0, lastOpenThinkIndex);
}
}
cleanContent = cleanContent.replace(/<\/?think>/gi, '').trim();
const thoughtsText = thoughts.filter(Boolean).join('\n\n---\n\n'); const thoughtsText = thoughts.filter(Boolean).join('\n\n---\n\n');
return { return {
content: cleanContent, content: cleanContent,
@@ -821,7 +835,7 @@ export default function ComfyUIAgent() {
useEffect(() => { useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, streaming?.content]); }, [messages, streaming?.content, streaming?.reasoningContent, streaming?.images.length, streaming?.scenes.length, streaming?.tts.length]);
useEffect(() => { useEffect(() => {
activeConversationIdRef.current = activeConversationId; activeConversationIdRef.current = activeConversationId;
@@ -1351,9 +1365,16 @@ export default function ComfyUIAgent() {
const hasReasoning = processed.reasoningContent.trim().length > 0; const hasReasoning = processed.reasoningContent.trim().length > 0;
if (!hasReasoning && !isStreaming) return null; if (!hasReasoning && !isStreaming) return null;
return ( return (
<details open={isStreaming} style={reasoningBoxStyle}> <details
open={isStreaming}
style={{
...reasoningBoxStyle,
...(!hasReasoning && isStreaming ? reasoningPendingBoxStyle : {}),
}}
>
<summary style={reasoningSummaryStyle}> <summary style={reasoningSummaryStyle}>
{isStreaming ? '思考中...' : '思考过程'} <span>思考过程</span>
{isStreaming ? <em style={reasoningLiveBadgeStyle}>实时</em> : null}
</summary> </summary>
{hasReasoning ? ( {hasReasoning ? (
<div <div
@@ -1361,8 +1382,9 @@ export default function ComfyUIAgent() {
dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.reasoningContent) }} dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.reasoningContent) }}
/> />
) : ( ) : (
<div style={reasoningContentStyle}> <div style={reasoningPendingStyle}>
正在分析你的需求、拆解角色、场景、镜头与后续工作流步骤... <span style={reasoningPulseDotStyle} />
<span>{STREAMING_REASONING_PLACEHOLDER}</span>
</div> </div>
)} )}
</details> </details>
@@ -1717,6 +1739,9 @@ export default function ComfyUIAgent() {
{renderReasoning(streaming, true)} {renderReasoning(streaming, true)}
{streaming.content ? renderMessageContent(streaming) : null} {streaming.content ? renderMessageContent(streaming) : null}
{streaming.content ? <span style={cursorStyle}>|</span> : null} {streaming.content ? <span style={cursorStyle}>|</span> : null}
{!streaming.content && !streaming.images.length && !streaming.scenes.length && !streaming.tts.length ? (
<div style={streamingBodyStatusStyle}>{STREAMING_BODY_PLACEHOLDER}</div>
) : null}
{renderAssets(streaming)} {renderAssets(streaming)}
<div style={messageActionsStyle}> <div style={messageActionsStyle}>
<button <button
@@ -2127,6 +2152,9 @@ const reasoningSummaryStyle: CSSProperties = {
fontSize: 12, fontSize: 12,
fontWeight: 700, fontWeight: 700,
color: 'var(--color-primary)', color: 'var(--color-primary)',
display: 'flex',
alignItems: 'center',
gap: 6,
}; };
const reasoningContentStyle: CSSProperties = { const reasoningContentStyle: CSSProperties = {
@@ -2136,6 +2164,57 @@ const reasoningContentStyle: CSSProperties = {
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
}; };
const reasoningPendingBoxStyle: CSSProperties = {
borderStyle: 'dashed',
background:
'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 7%, var(--color-bg)), color-mix(in srgb, var(--color-info) 5%, var(--color-bg)))',
};
const reasoningLiveBadgeStyle: CSSProperties = {
marginLeft: 8,
padding: '1px 6px',
border: '1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border-light))',
borderRadius: 999,
color: 'var(--color-primary)',
background: 'color-mix(in srgb, var(--color-primary) 8%, transparent)',
fontSize: 11,
fontStyle: 'normal',
fontWeight: 700,
};
const reasoningPendingStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 10px 10px',
color: 'var(--color-text-muted)',
fontSize: 12,
lineHeight: 1.6,
};
const reasoningPulseDotStyle: CSSProperties = {
width: 7,
height: 7,
flexShrink: 0,
borderRadius: 999,
background: 'var(--color-primary)',
boxShadow: '0 0 0 5px color-mix(in srgb, var(--color-primary) 12%, transparent)',
};
const streamingBodyStatusStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
width: 'fit-content',
maxWidth: '100%',
marginTop: 4,
padding: '7px 10px',
border: '1px solid var(--color-border-light)',
borderRadius: 999,
background: 'color-mix(in srgb, var(--color-bg) 74%, var(--color-surface))',
color: 'var(--color-text-muted)',
fontSize: 12,
};
const inputBarStyle: CSSProperties = { const inputBarStyle: CSSProperties = {
padding: 16, padding: 16,
borderTop: '1px solid var(--color-border)', borderTop: '1px solid var(--color-border)',
+219 -64
View File
@@ -15,13 +15,22 @@ const SiteTrendChart = lazy(
() => import("../components/charts/SiteTrendChart.js"), () => import("../components/charts/SiteTrendChart.js"),
); );
function scheduleDashboardIdleWork(callback: () => void, timeout = 1600): () => void {
if (typeof requestIdleCallback === "function") {
const idleId = requestIdleCallback(callback, { timeout });
return () => cancelIdleCallback(idleId);
}
const timer = globalThis.setTimeout(callback, Math.min(timeout, 450));
return () => globalThis.clearTimeout(timer);
}
function getGreeting(): string { function getGreeting(): string {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour < 6) return "🌙 夜深了"; if (hour < 6) return "夜深了";
if (hour < 11) return "☀️ 早上好"; if (hour < 11) return "早上好";
if (hour < 13) return "👋 中午好"; if (hour < 13) return "中午好";
if (hour < 18) return "🌤️ 下午好"; if (hour < 18) return "下午好";
return "🌙 晚上好"; return "晚上好";
} }
function safeNumber(value: unknown): number { function safeNumber(value: unknown): number {
@@ -88,7 +97,7 @@ function formatAvailabilityPercent(value: number | null | undefined): string {
Number.isNaN(value) || Number.isNaN(value) ||
!Number.isFinite(value) !Number.isFinite(value)
) )
return "—"; return "-";
return `${Math.round(value)}%`; return `${Math.round(value)}%`;
} }
@@ -233,6 +242,7 @@ export default function Dashboard({
const [siteTrend, setSiteTrend] = useState<any[]>([]); const [siteTrend, setSiteTrend] = useState<any[]>([]);
const [siteLoading, setSiteLoading] = useState(true); const [siteLoading, setSiteLoading] = useState(true);
const [sites, setSites] = useState<any[]>([]); const [sites, setSites] = useState<any[]>([]);
const [chartsReady, setChartsReady] = useState(false);
const [siteSpeedStates, setSiteSpeedStates] = useState< const [siteSpeedStates, setSiteSpeedStates] = useState<
Record<string, SiteSpeedState> Record<string, SiteSpeedState>
>({}); >({});
@@ -318,6 +328,8 @@ export default function Dashboard({
loadSiteStats(); loadSiteStats();
}, [loadSiteStats]); }, [loadSiteStats]);
useEffect(() => scheduleDashboardIdleWork(() => setChartsReady(true)), []);
useEffect(() => { useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
let disposed = false; let disposed = false;
@@ -526,6 +538,111 @@ export default function Dashboard({
const siteAvailability = showInactiveSites const siteAvailability = showInactiveSites
? [...activeSites, ...inactiveSites] ? [...activeSites, ...inactiveSites]
: activeSites; : activeSites;
const proxySuccessRate =
proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 0;
const activeAccountRate =
totalAccounts > 0 ? (activeAccounts / totalAccounts) * 100 : 0;
const siteRiskCount = activeSites.filter((site) => {
const availability = site.availabilityPercent ?? 100;
const latency = site.averageLatencyMs ?? 0;
return availability < 99 || latency > 1800 || site.failedCount > 0;
}).length;
const riskySites = activeSites
.filter((site) => {
const availability = site.availabilityPercent ?? 100;
const latency = site.averageLatencyMs ?? 0;
return availability < 99 || latency > 1800 || site.failedCount > 0;
})
.sort((a, b) => {
const aScore =
(100 - (a.availabilityPercent ?? 100)) * 100 +
(a.failedCount || 0) * 8 +
Math.max(0, (a.averageLatencyMs || 0) - 1200) / 10;
const bScore =
(100 - (b.availabilityPercent ?? 100)) * 100 +
(b.failedCount || 0) * 8 +
Math.max(0, (b.averageLatencyMs || 0) - 1200) / 10;
return bScore - aScore;
})
.slice(0, 3);
const overallHealthTone =
proxySuccessRate >= 99 && siteRiskCount === 0
? "success"
: proxySuccessRate >= 95 && siteRiskCount <= 2
? "warning"
: "danger";
const overallHealthLabel =
overallHealthTone === "success"
? "稳定"
: overallHealthTone === "warning"
? "需关注"
: "异常";
const spendTone =
todaySpend <= todayReward
? "success"
: todaySpend <= Math.max(1, todayReward * 2)
? "warning"
: "danger";
const throughputLabel =
requestsPerMinute > 0
? `${Math.round(requestsPerMinute).toLocaleString()} RPM`
: "暂无流量";
const operationsPulse = [
{
label: "整体状态",
value: overallHealthLabel,
note: `${proxySuccessRate.toFixed(1)}% 成功率`,
tone: overallHealthTone,
},
{
label: "站点风险",
value: `${siteRiskCount}`,
note: `${activeSites.length}/${rawSiteAvailability.length} 活跃站点`,
tone: siteRiskCount > 0 ? "warning" : "success",
},
{
label: "今日成本",
value: `$${todaySpend.toFixed(2)}`,
note: `奖励 $${todayReward.toFixed(2)}`,
tone: spendTone,
},
{
label: "当前吞吐",
value: throughputLabel,
note: `${formatCompactTokenMetric(tokensPerMinute)} TPM`,
tone: requestsPerMinute > 0 ? "primary" : "muted",
},
];
const dashboardCommandLinks = [
{
to: "/routes",
label: "路由健康",
value: `${proxySuccessRate.toFixed(1)}%`,
note: "24h 成功率",
tone: proxySuccessRate >= 99 ? "success" : proxySuccessRate >= 95 ? "warning" : "danger",
},
{
to: "/logs",
label: "使用日志",
value: Math.round(proxy24hTotal).toLocaleString(),
note: "最近 24h 请求",
tone: "primary",
},
{
to: "/sites",
label: "站点管理",
value: String(siteRiskCount),
note: siteRiskCount > 0 ? "需关注站点" : "风险站点",
tone: siteRiskCount > 0 ? "warning" : "success",
},
{
to: "/settings/notify",
label: "通知设置",
value: `${activeAccountRate.toFixed(0)}%`,
note: "账户启用率",
tone: activeAccountRate >= 80 ? "success" : "warning",
},
];
const getLatencyColor = (ms: number) => const getLatencyColor = (ms: number) =>
ms <= 500 ms <= 500
@@ -576,19 +693,28 @@ export default function Dashboard({
}; };
return ( return (
<div className="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 +745,62 @@ export default function Dashboard({
</svg> </svg>
</button> </button>
</div> </div>
</section>
<section className="dashboard-war-room" aria-label="运营战情室">
<div className="dashboard-pulse-grid">
{operationsPulse.map((item) => (
<div className="dashboard-pulse-card" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
<em>{item.note}</em>
</div> </div>
))}
</div>
<div className="dashboard-risk-panel">
<div className="dashboard-risk-head">
<div>
<span>风险雷达</span>
<strong>{riskySites.length > 0 ? "优先检查这些站点" : "暂无明显站点风险"}</strong>
</div>
<Link to="/sites" className="btn btn-ghost">站点管理</Link>
</div>
{riskySites.length > 0 ? (
<div className="dashboard-risk-list">
{riskySites.map((site) => (
<Link
key={site.siteId}
to={buildSiteLast24hLogsRoute(site.siteId)}
className="dashboard-risk-row"
>
<strong>{site.siteName}</strong>
<span>{formatAvailabilityPercent(site.availabilityPercent)}</span>
<em>{site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : "暂无延迟"}</em>
</Link>
))}
</div>
) : (
<div className="dashboard-risk-empty">
站点可用性、平均延迟和失败数都处于可接受范围。
</div>
)}
</div>
</section>
<nav className="dashboard-command-strip" aria-label="运营快捷入口">
{dashboardCommandLinks.map((item) => (
<Link
key={item.to}
to={item.to}
className="dashboard-command-item"
data-tone={item.tone}
>
<span>{item.label}</span>
<strong>{item.value}</strong>
<em>{item.note}</em>
</Link>
))}
</nav>
<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">
@@ -969,25 +1150,8 @@ export default function Dashboard({
</div> </div>
{/* 站点级分析 */} {/* 站点级分析 */}
<div <div className="dashboard-section-head">
style={{ <div className="dashboard-section-title">
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
marginTop: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 14,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
<svg <svg
width="16" width="16"
height="16" height="16"
@@ -1004,24 +1168,12 @@ export default function Dashboard({
</svg> </svg>
站点分析 站点分析
</div> </div>
<div style={{ display: "flex", gap: 4 }}> <div className="dashboard-range-tabs">
{[7, 30, 90].map((d) => ( {[7, 30, 90].map((d) => (
<button <button
key={d} key={d}
onClick={() => setTrendDays(d)} onClick={() => setTrendDays(d)}
style={{ className={`dashboard-range-tab ${trendDays === d ? "is-active" : ""}`}
padding: "4px 12px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
border: "none",
cursor: "pointer",
background:
trendDays === d ? "var(--color-primary)" : "var(--color-bg)",
color:
trendDays === d ? "white" : "var(--color-text-secondary)",
transition: "all 0.2s ease",
}}
> >
{d}天 {d}天
</button> </button>
@@ -1029,26 +1181,27 @@ export default function Dashboard({
</div> </div>
</div> </div>
<div <div className="dashboard-chart-grid">
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr",
gap: 16,
marginBottom: 24,
}}
>
<div className="chart-panel-enter animate-slide-up stagger-6"> <div className="chart-panel-enter animate-slide-up stagger-6">
{chartsReady ? (
<Suspense fallback={<ChartFallback height={320} />}> <Suspense fallback={<ChartFallback height={320} />}>
<SiteDistributionChart <SiteDistributionChart
data={siteDistribution} data={siteDistribution}
loading={siteLoading} loading={siteLoading}
/> />
</Suspense> </Suspense>
) : (
<ChartFallback height={320} />
)}
</div> </div>
<div className="chart-panel-enter animate-slide-up stagger-7"> <div className="chart-panel-enter animate-slide-up stagger-7">
{chartsReady ? (
<Suspense fallback={<ChartFallback height={320} />}> <Suspense fallback={<ChartFallback height={320} />}>
<SiteTrendChart data={siteTrend} loading={siteLoading} /> <SiteTrendChart data={siteTrend} loading={siteLoading} />
</Suspense> </Suspense>
) : (
<ChartFallback height={320} />
)}
</div> </div>
</div> </div>
@@ -1190,7 +1343,7 @@ export default function Dashboard({
> >
{site.averageLatencyMs != null {site.averageLatencyMs != null
? `${site.averageLatencyMs}ms` ? `${site.averageLatencyMs}ms`
: "—"} : "暂无"}
</span> </span>
<span className="site-observability-metric-sep">·</span> <span className="site-observability-metric-sep">·</span>
<span>{Math.round(site.totalRequests || 0)} 次</span> <span>{Math.round(site.totalRequests || 0)} 次</span>
@@ -1216,7 +1369,7 @@ export default function Dashboard({
`成功/失败:${bucket.successCount}/${bucket.failedCount}`, `成功/失败:${bucket.successCount}/${bucket.failedCount}`,
bucket.averageLatencyMs != null bucket.averageLatencyMs != null
? `平均响应:${bucket.averageLatencyMs}ms` ? `平均响应:${bucket.averageLatencyMs}ms`
: "平均响应:—", : "平均响应:暂无",
].join(" · ")} ].join(" · ")}
data-tooltip-align="start" data-tooltip-align="start"
title={[ title={[
@@ -1227,7 +1380,7 @@ export default function Dashboard({
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`, `${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
bucket.averageLatencyMs != null bucket.averageLatencyMs != null
? `平均响应 ${bucket.averageLatencyMs}ms` ? `平均响应 ${bucket.averageLatencyMs}ms`
: "平均响应 —", : "平均响应 暂无",
].join(" | ")} ].join(" | ")}
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`} aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
/> />
@@ -1293,6 +1446,8 @@ export default function Dashboard({
</div> </div>
{insightsLoading && !insightsData ? ( {insightsLoading && !insightsData ? (
<ChartFallback height={260} /> <ChartFallback height={260} />
) : !chartsReady ? (
<ChartFallback height={260} />
) : ( ) : (
<Suspense fallback={<ChartFallback height={260} />}> <Suspense fallback={<ChartFallback height={260} />}>
<ModelAnalysisPanel data={insightsData?.modelAnalysis} /> <ModelAnalysisPanel data={insightsData?.modelAnalysis} />
@@ -1585,7 +1740,7 @@ export default function Dashboard({
<div style={{ fontSize: 18, fontWeight: 700 }}> <div style={{ fontSize: 18, fontWeight: 700 }}>
{proxy24hTotal > 0 {proxy24hTotal > 0
? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}` ? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}`
: "—"} : "-"}
</div> </div>
</div> </div>
</div> </div>
+594 -261
View File
@@ -1,27 +1,41 @@
import React, { CSSProperties, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { fetchSessionResponse } from '../api.js'; import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary, type WorkspaceAsset } from '../api.js';
import type { SessionAudience } from '../authSession.js'; import type { SessionAudience } from '../authSession.js';
type Mode = 'audio' | 'image' | 'text'; type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
type UploadPurpose = 'avatar' | 'audio';
type MappingKey = keyof typeof DEFAULT_MAPPING;
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [ const DEFAULT_MAPPING = {
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' }, prompt: { nodeId: '', field: 'prompt' },
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' }, script: { nodeId: '', field: 'text' },
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' }, avatar: { nodeId: '', field: 'image' },
referenceAudio: { nodeId: '', field: 'audio' },
voiceReference: { nodeId: '', field: 'audio' },
output: { nodeId: '', field: 'video' },
};
const AUDIO_ROLES: Array<{ value: AudioRole; label: string; description: string }> = [
{ value: 'both', label: '口型 + 音色参考', description: '同一参考音频同时注入口型和音色节点。' },
{ value: 'lip_sync', label: '只驱动口型', description: '人物按参考音频开口,适合已有成品配音。' },
{ value: 'voice_reference', label: '只做音色参考', description: '模板内 TTS 节点按文案生成声音。' },
]; ];
const MODEL_PRESETS = [ function terminal(status: string) {
'longcat-video-avatar', return ['succeeded', 'failed', 'cancelled'].includes(status);
'doubao-seedance-1-0-lite-i2v-250428', }
'doubao-seedance-1-0-lite-t2v-250428',
'wan2-1-14b-i2v-250225',
];
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话']; function statusLabel(status: string) {
if (status === 'succeeded') return '完成';
if (status === 'failed') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'running') return '生成中';
return '排队中';
}
async function parseError(response: Response) { function parseError(response: Response) {
const text = await response.text().catch(() => ''); return response.text().then((text) => {
if (!text) return `HTTP ${response.status}`; if (!text) return `HTTP ${response.status}`;
try { try {
const value = JSON.parse(text); const value = JSON.parse(text);
@@ -29,75 +43,314 @@ async function parseError(response: Response) {
} catch { } catch {
return text; return text;
} }
});
} }
function extractTaskId(value: any): string { function graphMapping(workflow?: WorkflowSummary | null) {
return value?.response?.id return workflow?.graph?.metadata?.digitalHuman?.nodeMapping
|| value?.response?.task_id || workflow?.graph?.digitalHuman?.nodeMapping
|| value?.response?.data?.id || DEFAULT_MAPPING;
|| value?.response?.data?.task_id
|| value?.id
|| value?.task_id
|| '';
} }
function copyText(value: string) { function mappingValue(mapping: any, key: MappingKey) {
if (!value) return; const value = mapping?.[key] || DEFAULT_MAPPING[key];
void navigator.clipboard?.writeText(value); if (typeof value === 'string') return { nodeId: value, field: DEFAULT_MAPPING[key].field };
return {
nodeId: String(value?.nodeId || value?.node_id || ''),
field: String(value?.field || value?.fieldName || DEFAULT_MAPPING[key].field),
};
}
function jobAssets(job: DigitalHumanJob) {
return Array.isArray(job.result?.assets) ? job.result.assets : [];
}
function templateHasMapping(workflow: WorkflowSummary) {
const mapping = graphMapping(workflow);
return ['prompt', 'script', 'avatar', 'referenceAudio', 'output'].every((key) => {
return Boolean(mappingValue(mapping, key as MappingKey).nodeId);
});
}
function workflowKindLabel(workflow: WorkflowSummary) {
if (workflow.isTemplate || workflow.visibility === 'template') return '官方模板';
if (workflow.visibility === 'public') return '共享模板';
return '私有工作流';
}
function sourcePrompt(graph: any) {
return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graph || {};
}
function collectPromptNodes(graph: any) {
const prompt = sourcePrompt(graph);
const nodes: Array<{ id: string; classType: string; inputs: Record<string, any>; title: string }> = [];
if (prompt && typeof prompt === 'object' && !Array.isArray(prompt)) {
for (const [id, node] of Object.entries(prompt as Record<string, any>)) {
if (!node || typeof node !== 'object') continue;
const classType = String((node as any).class_type || (node as any).type || '');
const inputs = ((node as any).inputs && typeof (node as any).inputs === 'object') ? (node as any).inputs : {};
if (classType || Object.keys(inputs).length > 0) {
nodes.push({
id: String(id),
classType,
inputs,
title: String((node as any)._meta?.title || (node as any).title || classType || id),
});
}
}
}
const graphNodes = graph?.nodes;
if (Array.isArray(graphNodes)) {
for (const node of graphNodes) {
if (!node || typeof node !== 'object') continue;
const id = String(node.id || node.nodeId || '');
if (!id || nodes.some((item) => item.id === id)) continue;
const classType = String(node.type || node.class_type || '');
nodes.push({
id,
classType,
inputs: {},
title: String(node.title || node.label || classType || id),
});
}
}
return nodes;
}
function includesAny(value: string, words: string[]) {
const text = value.toLowerCase();
return words.some((word) => text.includes(word));
}
function findNode(
nodes: ReturnType<typeof collectPromptNodes>,
words: string[],
inputWords: string[] = [],
) {
return nodes.find((node) => {
const haystack = `${node.classType} ${node.title}`;
const inputNames = Object.keys(node.inputs || {}).join(' ');
return includesAny(haystack, words) || (inputWords.length > 0 && includesAny(inputNames, inputWords));
});
}
function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
if (!workflow) return DEFAULT_MAPPING;
const nodes = collectPromptNodes(workflow.graph);
const promptNode = findNode(nodes, ['prompt', 'positive', 'textgenerate', 'text generate', 'wan', 'ltx'], ['prompt']);
const scriptNode = findNode(nodes, ['script', 'text', 'tts', 'voice', 'audio'], ['text', 'script', 'caption']);
const avatarNode = findNode(nodes, ['loadimage', 'image', 'video', 'avatar', 'portrait', 'person'], ['image', 'video', 'avatar']);
const audioNode = findNode(nodes, ['audio', 'sound', 'voice', 'tts', 'load audio'], ['audio', 'voice']);
const outputNode = findNode(nodes, ['savevideo', 'save video', 'previewvideo', 'video combine', 'vhs', 'output'], ['video', 'filename_prefix'])
|| [...nodes].reverse().find((node) => includesAny(`${node.classType} ${node.title}`, ['save', 'preview', 'output']));
return {
prompt: { nodeId: promptNode?.id || '', field: promptNode?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
script: { nodeId: scriptNode?.id || promptNode?.id || '', field: scriptNode?.inputs?.script !== undefined ? 'script' : 'text' },
avatar: { nodeId: avatarNode?.id || '', field: avatarNode?.inputs?.video !== undefined ? 'video' : 'image' },
referenceAudio: { nodeId: audioNode?.id || '', field: 'audio' },
voiceReference: { nodeId: audioNode?.id || '', field: 'audio' },
output: { nodeId: outputNode?.id || '', field: outputNode?.inputs?.filename_prefix !== undefined ? 'filename_prefix' : 'video' },
};
}
function assetFileUrl(asset: WorkspaceAsset, audience: SessionAudience) {
if (asset.remoteUrl) return asset.remoteUrl;
if (!asset.hasLocalFile) return '';
const prefix = audience === 'user' ? '/api/user' : '/api';
return `${prefix}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`;
}
function assetName(asset: WorkspaceAsset) {
return asset.title || asset.fileName || asset.publicId;
}
function formatJobTime(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', { hour12: false });
} }
export default function DigitalHuman() { export default function DigitalHuman() {
const location = useLocation(); const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin'; const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [mode, setMode] = useState<Mode>('audio'); const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
const [model, setModel] = useState(MODEL_PRESETS[0]); const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。'); const audioInputRef = useRef<HTMLInputElement | null>(null);
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。'); const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [imageUrl, setImageUrl] = useState(''); const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
const [audioUrl, setAudioUrl] = useState(''); const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]); const [templateId, setTemplateId] = useState('');
const [resolution, setResolution] = useState('720p'); const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
const [avatarUrl, setAvatarUrl] = useState('');
const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
const [productScript, setProductScript] = useState('今天给大家介绍这款商品。它的核心优势是上手简单、效果稳定,适合直播间高频讲解和短视频种草。');
const [prompt, setPrompt] = useState('真人数字人主播,面向镜头自然讲解商品,口型准确,表情稳定,商业带货风格。');
const [audioRole, setAudioRole] = useState<AudioRole>('both');
const [ratio, setRatio] = useState('9:16'); const [ratio, setRatio] = useState('9:16');
const [duration, setDuration] = useState(8); const [resolution, setResolution] = useState('1080p');
const [refImageIndex, setRefImageIndex] = useState(0); const [subtitles, setSubtitles] = useState(true);
const [audioCfg, setAudioCfg] = useState(4.5);
const [segments, setSegments] = useState(false);
const [multiPerson, setMultiPerson] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
const [assetPicker, setAssetPicker] = useState<UploadPurpose | ''>('');
const [loadingAssets, setLoadingAssets] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [result, setResult] = useState<any>(null);
const bodyPreview = useMemo(() => ({ const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
model: model.trim(), const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
prompt: [ const sortedWorkflows = useMemo(() => {
prompt.trim(), return [...workflows].sort((a, b) => {
script.trim() ? `台词:${script.trim()}` : '', const score = (item: WorkflowSummary) => (templateHasMapping(item) ? 0 : 10)
`风格:${voiceStyle}`, + (item.isTemplate || item.visibility === 'template' ? 0 : item.visibility === 'public' ? 2 : 4);
].filter(Boolean).join('\n'), return score(a) - score(b) || a.name.localeCompare(b.name, 'zh-CN');
mode, });
ratio, }, [workflows]);
resolution, const mapped = useMemo(() => ({
duration, prompt: mappingValue(mapping, 'prompt'),
audio_url: mode === 'audio' ? audioUrl.trim() : undefined, script: mappingValue(mapping, 'script'),
image_url: mode !== 'text' ? imageUrl.trim() : undefined, avatar: mappingValue(mapping, 'avatar'),
audio_cfg: audioCfg, referenceAudio: mappingValue(mapping, 'referenceAudio'),
ref_img_index: refImageIndex, voiceReference: mappingValue(mapping, 'voiceReference'),
segmented: segments, output: mappingValue(mapping, 'output'),
multi_person: multiPerson, }), [mapping]);
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
const submit = async () => { const missingMapping = [
setSubmitting(true); ['提示词节点', mapped.prompt.nodeId],
['文案节点', mapped.script.nodeId],
['人物素材节点', mapped.avatar.nodeId],
['参考音频节点', mapped.referenceAudio.nodeId],
['输出节点', mapped.output.nodeId],
].filter(([, value]) => !value).map(([label]) => label);
const canSubmit = !!templateId
&& !!avatarUrl.trim()
&& !!referenceAudioUrl.trim()
&& !!productScript.trim()
&& missingMapping.length === 0
&& !submitting;
const selectedTemplateReady = selectedWorkflow ? templateHasMapping(selectedWorkflow) || missingMapping.length === 0 : false;
const pickerAssets = workspaceAssets.filter((asset) => {
if (assetPicker === 'audio') return asset.kind === 'audio';
if (assetPicker === 'avatar') return asset.kind === 'image' || asset.kind === 'video';
return false;
});
const loadData = useCallback(async () => {
const [workflowPayload, jobPayload] = await Promise.all([
client.getWorkflows({ include_public: true }),
client.getDigitalHumanJobs({ limit: 30 }),
]);
const nextWorkflows = Array.isArray(workflowPayload.items) ? workflowPayload.items : [];
setWorkflows(nextWorkflows);
setJobs(Array.isArray(jobPayload.jobs) ? jobPayload.jobs : []);
setTemplateId((current) => current || nextWorkflows.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId || nextWorkflows[0]?.publicId || '');
}, [client]);
useEffect(() => {
loadData().catch((err) => setError(err?.message || '数字人数据加载失败'));
}, [loadData]);
useEffect(() => {
setMapping(graphMapping(selectedWorkflow));
}, [selectedWorkflow?.publicId]);
useEffect(() => {
if (!jobs.some((job) => !terminal(job.status))) return;
const timer = window.setInterval(() => {
client.getDigitalHumanJobs({ limit: 30 })
.then((payload) => setJobs(payload.jobs || []))
.catch(() => {});
}, 4000);
return () => window.clearInterval(timer);
}, [client, jobs]);
const uploadFile = async (file: File, purpose: UploadPurpose) => {
setUploading(purpose);
setError(''); setError('');
setResult(null);
try { try {
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', { const form = new FormData();
form.append('file', file);
form.append('purpose', purpose);
const response = await fetchSessionResponse(audience, '/api/digital-human/upload', {
method: 'POST', method: 'POST',
body: JSON.stringify(bodyPreview), body: form,
timeoutMs: 300_000, timeoutMs: 180_000,
}); });
if (!response.ok) throw new Error(await parseError(response)); if (!response.ok) throw new Error(await parseError(response));
setResult(await response.json()); const payload = await response.json();
const fileUrl = payload?.asset?.fileUrl || '';
if (purpose === 'avatar') setAvatarUrl(fileUrl);
if (purpose === 'audio') setReferenceAudioUrl(fileUrl);
} catch (err: any) {
setError(err?.message || '素材上传失败');
} finally {
setUploading('');
}
};
const updateMapping = (key: keyof typeof DEFAULT_MAPPING, field: 'nodeId' | 'field', value: string) => {
setMapping((prev: any) => ({
...prev,
[key]: {
...mappingValue(prev, key),
[field]: value,
},
}));
};
const inferMapping = () => {
setMapping(inferMappingFromWorkflow(selectedWorkflow));
};
const openAssetPicker = async (purpose: UploadPurpose) => {
setAssetPicker(purpose);
setLoadingAssets(true);
setError('');
try {
const kind = purpose === 'audio' ? 'audio' : undefined;
const payload = await client.getWorkspaceAssets({ kind, pageSize: 60 });
setWorkspaceAssets(Array.isArray(payload.items) ? payload.items : []);
} catch (err: any) {
setError(err?.message || '工作空间素材加载失败');
} finally {
setLoadingAssets(false);
}
};
const chooseAsset = (asset: WorkspaceAsset) => {
const url = assetFileUrl(asset, audience);
if (!url) {
setError('这个素材没有可用文件地址');
return;
}
if (assetPicker === 'avatar') setAvatarUrl(url);
if (assetPicker === 'audio') setReferenceAudioUrl(url);
setAssetPicker('');
};
const submit = async () => {
if (!canSubmit) return;
setSubmitting(true);
setError('');
try {
const payload = await client.createDigitalHumanJob({
templateId,
nodeMapping: mapping,
avatarUrl: avatarUrl.trim(),
referenceAudioUrl: referenceAudioUrl.trim(),
productScript: productScript.trim(),
prompt: prompt.trim(),
audioRole,
ratio,
resolution,
subtitles,
});
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
} catch (err: any) { } catch (err: any) {
setError(err?.message || '数字人任务创建失败'); setError(err?.message || '数字人任务创建失败');
} finally { } finally {
@@ -105,236 +358,316 @@ export default function DigitalHuman() {
} }
}; };
const taskId = extractTaskId(result); const cancel = async (id: string) => {
const payload = await client.cancelDigitalHumanJob(id);
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
};
const clear = async () => {
await client.clearDigitalHumanJobs();
await loadData();
};
const retry = (job: DigitalHumanJob) => {
const request = job.result?.request || {};
setTemplateId(request.templateId || job.result?.templateId || templateId);
setMapping(request.nodeMapping || job.result?.mapping || mapping);
setAvatarUrl(request.avatarUrl || '');
setReferenceAudioUrl(request.referenceAudioUrl || '');
setProductScript(request.productScript || '');
setPrompt(request.prompt || prompt);
setAudioRole(request.audioRole || 'both');
setRatio(request.ratio || '9:16');
setResolution(request.resolution || '1080p');
setSubtitles(request.subtitles !== false);
};
return ( return (
<div className="page"> <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">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p> <h1>数字人商品讲解</h1>
<p>选择 Comfy 工作流模板,上传人物图片或视频、商品文案和参考音频,后台生成真人口播讲解视频并归档到工作空间。</p>
</div> </div>
<div className="page-actions"> <div className="console-hero-rail" aria-label="数字人任务摘要">
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}> <div>
{submitting ? '创建中...' : '创建数字人任务'} <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-primary" onClick={submit} disabled={!canSubmit}>
{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 style={layoutStyle}> <div className="digital-human-layout">
<section style={panelStyle}> <section className="digital-human-panel">
<div style={modeGridStyle}> <div className="digital-human-section-head">
{MODE_OPTIONS.map((item) => ( <div>
<h2>1. 选择工作流模板</h2>
<p>优先选择已配置数字人节点映射的模板;普通 Comfy 工作流也可以用自动猜测补齐。</p>
</div>
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>猜测映射</button>
</div>
<div className="digital-human-template-grid">
{sortedWorkflows.slice(0, 6).map((workflow) => {
const ready = templateHasMapping(workflow);
const active = workflow.publicId === templateId;
return (
<button <button
key={item.value} key={workflow.publicId}
type="button" type="button"
onClick={() => setMode(item.value)} className={`digital-human-template-card${active ? ' is-active' : ''}`}
style={mode === item.value ? activeModeStyle : modeStyle} onClick={() => setTemplateId(workflow.publicId)}
> >
<strong>{item.label}</strong> <span className="digital-human-template-top">
<span>{item.description}</span> <strong>{workflow.name}</strong>
<em>{ready ? '可直接生成' : '需映射'}</em>
</span>
<span className="digital-human-template-desc">
{workflow.description || 'Comfy 工作流模板,可注入人物素材、文案和参考音频。'}
</span>
<span className="digital-human-template-tags">
<i>{workflowKindLabel(workflow)}</i>
<i>{workflow.visibility}</i>
</span>
</button> </button>
);
})}
</div>
<label className="digital-human-field digital-human-template-select">
<span>全部模板</span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value="">请选择模板</option>
{sortedWorkflows.map((workflow) => (
<option key={workflow.publicId} value={workflow.publicId}>
{workflow.name} · {workflowKindLabel(workflow)}
</option>
))} ))}
</select>
</label>
{selectedWorkflow && (
<div className="digital-human-template-meta">
<strong>{selectedWorkflow.name}</strong>
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
<span>{selectedTemplateReady ? '节点映射已准备好。' : '节点映射不完整,可点击“猜测映射”后再微调。'}</span>
</div>
)}
<h2>2. 上传素材</h2>
<div className="digital-human-upload-grid">
<div className="digital-human-upload-card">
<strong>人物图片 / 视频</strong>
<span>{avatarUrl ? '已准备素材' : '支持本地上传或粘贴 URL'}</span>
{avatarUrl && (
avatarUrl.match(/\.(mp4|webm|mov|m4v)(\?|$)/i)
? <video className="digital-human-upload-preview" src={avatarUrl} muted controls preload="metadata" />
: <img className="digital-human-upload-preview" src={avatarUrl} alt="" loading="lazy" />
)}
<input ref={avatarInputRef} type="file" hidden accept="image/*,video/*" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadFile(file, 'avatar');
event.currentTarget.value = '';
}} />
<div className="digital-human-upload-actions">
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
{uploading === 'avatar' ? '上传中...' : '上传人物素材'}
</button>
<button type="button" className="btn btn-ghost" onClick={() => void openAssetPicker('avatar')}>工作空间</button>
</div>
<input value={avatarUrl} onChange={(event) => setAvatarUrl(event.target.value)} placeholder="或粘贴图片/视频 URL" />
</div>
<div className="digital-human-upload-card">
<strong>参考音频</strong>
<span>{referenceAudioUrl ? '已准备音频' : '用于口型驱动或音色参考'}</span>
{referenceAudioUrl && <audio className="digital-human-audio-preview" src={referenceAudioUrl} controls />}
<input ref={audioInputRef} type="file" hidden accept="audio/*" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadFile(file, 'audio');
event.currentTarget.value = '';
}} />
<div className="digital-human-upload-actions">
<button type="button" className="btn btn-ghost" onClick={() => audioInputRef.current?.click()} disabled={uploading === 'audio'}>
{uploading === 'audio' ? '上传中...' : '上传参考音频'}
</button>
<button type="button" className="btn btn-ghost" onClick={() => void openAssetPicker('audio')}>工作空间</button>
</div>
<input value={referenceAudioUrl} onChange={(event) => setReferenceAudioUrl(event.target.value)} placeholder="或粘贴音频 URL" />
</div>
</div> </div>
<div style={formGridStyle}> <h2>3. 商品文案</h2>
<label style={fieldStyle}> <label className="digital-human-field">
<span>模型</span> <span>讲解文案</span>
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} /> <textarea value={productScript} onChange={(event) => setProductScript(event.target.value)} rows={6} />
<datalist id="digital-human-models">
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
</datalist>
</label> </label>
<label style={fieldStyle}> <label className="digital-human-field">
<span>画面提示</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={3} />
</label>
<div className="digital-human-form-grid">
<label className="digital-human-field">
<span>参考音频用途</span>
<select value={audioRole} onChange={(event) => setAudioRole(event.target.value as AudioRole)}>
{AUDIO_ROLES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</label>
<label className="digital-human-field">
<span>画幅</span> <span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}> <select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option> <option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option> <option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option> <option value="1:1">1:1 方屏</option>
</select> </select>
</label> </label>
<label style={fieldStyle}> <label className="digital-human-field">
<span>分辨率</span> <span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}> <select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="720p">720p</option> <option value="720p">720p</option>
<option value="1080p">1080p</option> <option value="1080p">1080p</option>
<option value="480p">480p</option> <option value="480p">480p</option>
</select> </select>
</label> </label>
<label style={fieldStyle}> <label className="digital-human-checkbox">
<span>时长(秒)</span> <input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} /> <span>生成字幕</span>
</label> </label>
</div> </div>
<label style={fieldStyle}>
<span>人物/场景描述</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
</label>
<label style={fieldStyle}>
<span>台词/脚本</span>
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
</label>
<div style={formGridStyle}>
{mode !== 'text' && (
<label style={fieldStyle}>
<span>人物图 URL</span>
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
)}
{mode === 'audio' && (
<label style={fieldStyle}>
<span>音频 URL</span>
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
)}
<label style={fieldStyle}>
<span>口播风格</span>
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label style={fieldStyle}>
<span>ref_img_index</span>
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
</label>
<label style={fieldStyle}>
<span>audio CFG</span>
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
</label>
</div>
<div style={toggleRowStyle}>
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
</div>
</section> </section>
<aside style={panelStyle}> <section className="digital-human-panel">
<h2 style={sideTitleStyle}>任务请求</h2> <div className="digital-human-section-head">
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre> <div>
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button> <h2>节点映射</h2>
<p>把页面输入写入对应 Comfy 节点。字段名可按模板节点输入口微调。</p>
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2> </div>
{result ? ( <button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>重新猜测</button>
<> </div>
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>} <div className="digital-human-mapping-grid">
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre> {([
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button> ['prompt', '提示词节点'],
</> ['script', '文案节点'],
) : ( ['avatar', '人物素材节点'],
<div className="empty-state" style={{ minHeight: 160 }}> ['referenceAudio', '参考音频节点'],
<div className="empty-state-title">暂无任务</div> ['voiceReference', '音色参考节点(可选)'],
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div> ['output', '输出视频节点'],
] as Array<[keyof typeof DEFAULT_MAPPING, string]>).map(([key, label]) => {
const item = mapped[key];
return (
<div key={key} className="digital-human-mapping-row">
<span>{label}</span>
<input value={item.nodeId} onChange={(event) => updateMapping(key, 'nodeId', event.target.value)} placeholder="Node ID" />
<input value={item.field} onChange={(event) => updateMapping(key, 'field', event.target.value)} placeholder="字段名" />
</div>
);
})}
</div>
{missingMapping.length > 0 && (
<div className="alert alert-warning" style={{ marginTop: 12 }}>
缺少映射:{missingMapping.join('、')}
</div> </div>
)} )}
</aside>
<h2>任务队列</h2>
<div className="digital-human-job-actions">
<button type="button" className="btn btn-ghost" onClick={clear}>清空已完成</button>
</div> </div>
<div className="digital-human-job-list">
{jobs.length === 0 ? (
<div className="empty-state" style={{ minHeight: 180 }}>
<div className="empty-state-title">暂无任务</div>
<div className="empty-state-desc">提交后这里会显示 Comfy 执行进度和工作空间产出。</div>
</div>
) : jobs.map((job) => {
const progress = Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
return (
<article key={job.id} className="digital-human-job-card">
<div className="digital-human-job-head">
<div>
<strong>{job.title || '数字人讲解任务'}</strong>
<span>{job.message || statusLabel(job.status)}{formatJobTime(job.createdAt) ? ` · ${formatJobTime(job.createdAt)}` : ''}</span>
</div>
<em>{statusLabel(job.status)}</em>
</div>
<div className="digital-human-job-meta">
<span>进度 {progress}%</span>
{job.result?.prompt_id && <span>Prompt {job.result.prompt_id}</span>}
</div>
<div className="digital-human-progress"><span style={{ width: `${progress}%` }} /></div>
{job.error && <div className="digital-human-job-error">{job.error}</div>}
{jobAssets(job).length > 0 && (
<div className="digital-human-assets">
{jobAssets(job).map((asset: any) => (
<a key={asset.publicId} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId)}`}>
{asset.kind === 'video' ? '视频' : asset.kind === 'audio' ? '音频' : '封面'} · {asset.publicId}
</a>
))}
</div>
)}
<div className="digital-human-job-buttons">
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => cancel(job.id)}>取消</button>}
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>重试</button>}
</div>
</article>
);
})}
</div>
</section>
</div>
{assetPicker && (
<div className="modal-overlay" onClick={() => setAssetPicker('')}>
<div className="digital-human-picker modal-card" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<div>
<h3>{assetPicker === 'audio' ? '选择参考音频' : '选择人物素材'}</h3>
<p>从工作空间选取已有素材,只有最终产出仍会归档到工作空间。</p>
</div>
<button type="button" className="btn btn-ghost" onClick={() => setAssetPicker('')}>关闭</button>
</div>
{loadingAssets ? (
<div className="empty-state" style={{ minHeight: 180 }}>
<div className="empty-state-title">正在加载素材</div>
</div>
) : pickerAssets.length === 0 ? (
<div className="empty-state" style={{ minHeight: 180 }}>
<div className="empty-state-title">没有可选素材</div>
<div className="empty-state-desc">可以先上传本地文件,或去工作空间确认素材类型。</div>
</div>
) : (
<div className="digital-human-picker-grid">
{pickerAssets.map((asset) => {
const preview = assetFileUrl(asset, audience);
return (
<button key={asset.publicId} type="button" className="digital-human-picker-item" onClick={() => chooseAsset(asset)}>
{asset.kind === 'image' && preview ? <img src={preview} alt="" loading="lazy" /> : null}
{asset.kind === 'video' && preview ? <video src={preview} muted preload="metadata" /> : null}
{asset.kind === 'audio' ? <span className="digital-human-picker-audio">音频</span> : null}
<strong>{assetName(asset)}</strong>
<span>{asset.kind} · {asset.source || 'workspace'}</span>
</button>
);
})}
</div>
)}
</div>
</div>
)}
</div> </div>
); );
} }
const layoutStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.25fr) minmax(320px, 0.75fr)',
gap: 16,
};
const panelStyle: CSSProperties = {
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
borderRadius: 8,
padding: 16,
};
const modeGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 10,
marginBottom: 16,
};
const modeStyle: CSSProperties = {
textAlign: 'left',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
borderRadius: 8,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 6,
color: 'var(--color-text-primary)',
};
const activeModeStyle: CSSProperties = {
...modeStyle,
borderColor: 'var(--color-primary)',
boxShadow: '0 0 0 2px color-mix(in srgb, var(--color-primary) 15%, transparent)',
};
const formGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
marginBottom: 12,
};
const fieldStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
fontSize: 12,
color: 'var(--color-text-secondary)',
};
const inputStyle: CSSProperties = {
width: '100%',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
borderRadius: 6,
padding: '10px 12px',
fontSize: 13,
};
const textareaStyle: CSSProperties = {
...inputStyle,
minHeight: 110,
resize: 'vertical',
};
const toggleRowStyle: CSSProperties = {
display: 'flex',
gap: 16,
flexWrap: 'wrap',
fontSize: 13,
color: 'var(--color-text-secondary)',
};
const sideTitleStyle: CSSProperties = {
margin: '0 0 10px',
fontSize: 14,
};
const codeStyle: CSSProperties = {
maxHeight: 280,
overflow: 'auto',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: 12,
fontSize: 12,
whiteSpace: 'pre-wrap',
};
const taskIdStyle: CSSProperties = {
marginBottom: 8,
padding: '8px 10px',
borderRadius: 6,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-text-primary)',
fontSize: 12,
};
+5
View File
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
getAccounts: vi.fn(), getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(), getAccountsSnapshot: vi.fn(),
getAccountTokens: vi.fn(), getAccountTokens: vi.fn(),
getSites: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(), getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(), getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(), createDownstreamApiKey: vi.fn(),
@@ -166,6 +167,10 @@ beforeEach(() => {
}, },
}, },
]); ]);
apiMock.getSites.mockResolvedValue([
{ id: 201, name: '站点A', status: 'active' },
{ id: 202, name: '站点B', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([ apiMock.getAccountTokens.mockResolvedValue([
{ {
id: 301, id: 301,

Some files were not shown because too many files have changed in this diff Show More