Compare commits
85 Commits
main
...
aaba6f152e
| Author | SHA1 | Date | |
|---|---|---|---|
| aaba6f152e | |||
| f3d2d50f9a | |||
| 16162095f7 | |||
| 6629d9d0a3 | |||
| 3c6f061709 | |||
| d7d14e077e | |||
| 3b3292b934 | |||
| f7916a42e5 | |||
| f725de28bd | |||
| 97113964c8 | |||
| b429695cba | |||
| d6bf7352a0 | |||
| d800ed1f9f | |||
| 4855db3fc8 | |||
| 6cd0a8caca | |||
| 44dc89a0dd | |||
| 823ac3c25d | |||
| 5ecea182bc | |||
| cd843465b2 | |||
| ef0f91e683 | |||
| 9c1e5ae293 | |||
| 00b3365f5a | |||
| d5babf5f3f | |||
| 4b757b8250 | |||
| def8d7a204 | |||
| 25c6fdbd6c | |||
| 47202e6848 | |||
| 1b2880739a | |||
| 188e457e7f | |||
| 13756adc9d | |||
| f026582008 | |||
| b3eb86d5ea | |||
| 301d086cb6 | |||
| 023384b14b | |||
| d315f0ffe2 | |||
| f0385c9c8c | |||
| 339f91d014 | |||
| 0d231ec467 | |||
| be1fc1df48 | |||
| dd874bae30 | |||
| 45aa741d9c | |||
| 49c398db5e | |||
| 4d748dd938 | |||
| b40c522749 | |||
| d467e27fd8 | |||
| 5ef017c54e | |||
| 03046152fd | |||
| d75daf72d6 | |||
| d39100b56d | |||
| 2e4c5801b6 | |||
| 870b2a2da3 | |||
| 4448567368 | |||
| fae0f607aa | |||
| e4630c4cb6 | |||
| e72e323313 | |||
| 2b836b3e1e | |||
| 598b2f48d6 | |||
| 3f22d7b1ab | |||
| 5ce5ea1687 | |||
| f8741b544e | |||
| c316a78511 | |||
| 68ae9acb27 | |||
| 13c7d8aa4e | |||
| fb5fc9f1ae | |||
| 5765ea48b0 | |||
| 55bd3f41f8 | |||
| 938597b766 | |||
| fe0e3faa22 | |||
| d0f26118ff | |||
| c98d9a2c6d | |||
| 1293ebae68 | |||
| b29c7ffa7a | |||
| 056e9fb651 | |||
| fa5a2419d8 | |||
| 7b34153235 | |||
| 04792bd3f7 | |||
| bb31118792 | |||
| 5027767ca2 | |||
| fd930757f9 | |||
| eb6ed11cff | |||
| 931bcf3fb9 | |||
| e0bd9b4194 | |||
| 0f9fe4a81d | |||
| 6869504119 | |||
| 3f9f9f7665 |
+6
-5
@@ -1,18 +1,19 @@
|
||||
# Cargo build acceleration.
|
||||
#
|
||||
# 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
|
||||
# (no apt-get, see build-and-run.sh notes), so the mold block below is
|
||||
# commented out — uncomment it after running:
|
||||
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
|
||||
# when available, so the block below is kept as an optional manual setup for
|
||||
# direct `cargo build` / `cargo run` usage:
|
||||
#
|
||||
# sudo apt-get install -y 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).
|
||||
|
||||
# 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]
|
||||
# linker = "clang"
|
||||
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
@@ -2,3 +2,19 @@ comfy_source/venv
|
||||
comfy_source/.git
|
||||
comfy_source/__pycache__
|
||||
**/__pycache__
|
||||
target/
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
data/
|
||||
logs/
|
||||
tmp/
|
||||
.agents/
|
||||
.cache/
|
||||
.codex/
|
||||
.claude/
|
||||
.comfyui/
|
||||
boosapi
|
||||
bossai
|
||||
boosapi.db
|
||||
docker/*.tar
|
||||
|
||||
+7
-1
@@ -1,13 +1,15 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
node_modules/
|
||||
website/.next/
|
||||
website/out/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
logs/
|
||||
tmp/
|
||||
!tmp/.gitkeep
|
||||
@@ -19,5 +21,9 @@ docker/.env
|
||||
.comfyui/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
.cache/
|
||||
openspec/
|
||||
boosapi
|
||||
bossai
|
||||
docker/*.tar
|
||||
|
||||
Generated
+4174
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
|
||||
# Async runtime
|
||||
async-stream = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = [
|
||||
|
||||
@@ -66,6 +66,31 @@
|
||||
|
||||
## 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 部署
|
||||
|
||||
1. **准备 docker-compose.yml**:
|
||||
@@ -161,8 +186,54 @@
|
||||
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
||||
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
||||
| `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`) |
|
||||
|
||||
### 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)
|
||||
@@ -179,6 +250,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
|
||||
cp data/boosapi.db data/boosapi.db.bak
|
||||
```
|
||||
|
||||
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
|
||||
|
||||
### 查看日志
|
||||
如果使用 Systemd:
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<br>
|
||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||
<br>
|
||||
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。
|
||||
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
|
||||
<br>
|
||||
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
||||
</p>
|
||||
@@ -57,16 +57,6 @@
|
||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||
|
||||
## 🖼️ 界面预览
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🎨 Workflow Studio 与视频助手(Rust)
|
||||
@@ -75,7 +65,8 @@
|
||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||
- **场景生成**:自动为每个场景生成配图
|
||||
- **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/*`
|
||||
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||
@@ -186,6 +177,80 @@ BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
|
||||
|
||||
如果图像、视频、语音能力走 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(推荐)
|
||||
@@ -260,7 +325,7 @@ docker compose up -d
|
||||
│ ├── billing/ # 计费逻辑
|
||||
│ └── notification/ # 通知推送
|
||||
├── migrations/ # SQLite 迁移
|
||||
├── src/web/ # 前端源码(React 19 + Vite)
|
||||
├── src/web/ # 管理台前端源码(React + Vite)
|
||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||
│ ├── pages/
|
||||
@@ -269,6 +334,7 @@ docker compose up -d
|
||||
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
||||
│ │ └── ...
|
||||
│ └── components/
|
||||
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
||||
└── dist/web/ # 前端构建产物(由 Rust serve)
|
||||
```
|
||||
|
||||
@@ -306,6 +372,11 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
|
||||
|
||||
# 前端静态目录(默认 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 双模式 |
|
||||
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||
| Workflow Studio | API-native 工作流入口,后续承接节点画布 |
|
||||
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
||||
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
||||
| 管理台前端 | React + Vite(仅构建工具) |
|
||||
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
|
||||
| 样式 | Tailwind CSS v4 |
|
||||
| 数据可视化 | VChart |
|
||||
| 容器化 | Docker + Docker Compose + K3s |
|
||||
@@ -329,6 +401,8 @@ BOOSAPI_WEB_DIR=dist/web
|
||||
|
||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||
|
||||
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 本地开发
|
||||
@@ -336,11 +410,20 @@ BOOSAPI_WEB_DIR=dist/web
|
||||
### 后端(Rust)
|
||||
|
||||
```bash
|
||||
# 编译并运行
|
||||
cargo run --release
|
||||
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
|
||||
./build-and-run.sh --check
|
||||
|
||||
# 仅检查编译
|
||||
cargo check
|
||||
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
|
||||
./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
|
||||
@@ -351,9 +434,15 @@ export BOOSAPI_SERVER__PORT=4000
|
||||
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||
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)
|
||||
|
||||
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
||||
@@ -370,9 +459,60 @@ npm run build
|
||||
# 输出到 dist/web/,Rust 后端自动 serve
|
||||
```
|
||||
|
||||
可选使用 Bun 加速主前端构建:
|
||||
|
||||
```bash
|
||||
# 一次性使用 Bun 构建主前端
|
||||
./build-and-run.sh --bun
|
||||
|
||||
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
|
||||
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
|
||||
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
||||
```
|
||||
|
||||
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
|
||||
|
||||
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
||||
|
||||
### 独立官网(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 部署
|
||||
|
||||
```bash
|
||||
|
||||
+140
-151
@@ -2,206 +2,195 @@
|
||||
|
||||
# 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>
|
||||
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
||||
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
||||
<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>
|
||||
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
|
||||
<br>
|
||||
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
|
||||
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
|
||||
</p>
|
||||
|
||||
<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="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
|
||||
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&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">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
</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
|
||||
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
|
||||
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
|
||||
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
|
||||
## Key Features
|
||||
|
||||
| Pain Point | BoosAPI Solution |
|
||||
|------------|-----------------|
|
||||
| 🔑 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 |
|
||||
### Unified API Gateway
|
||||
|
||||
---
|
||||
- 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
|
||||
- **Multi-view generation**: front/back/left/right directional image generation
|
||||
- **Scene/video generation**: text description directly generates video content
|
||||
- **SSE real-time streaming**: generation progress feedback
|
||||
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
|
||||
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
|
||||
| backend | Purpose |
|
||||
|---------|---------|
|
||||
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
|
||||
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
|
||||
| `external` | Proxy a full ComfyUI instance you deploy yourself |
|
||||
| `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
|
||||
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
|
||||
- SSE streaming with automatic format conversion
|
||||
### Assistants
|
||||
|
||||
### 🧠 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
|
||||
- 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
|
||||
## Hardware Guide
|
||||
|
||||
### 📡 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 |
|
||||
|----------|---------|
|
||||
| New API | `new-api` |
|
||||
| One API | `one-api` |
|
||||
| OneHub | `onehub` |
|
||||
| DoneHub | `done-hub` |
|
||||
| Veloera | `veloera` |
|
||||
| AnyRouter | `anyrouter` |
|
||||
| Sub2API | `sub2api` |
|
||||
Recommended SaaS gateway server:
|
||||
|
||||
### 👥 Account & Token Management
|
||||
|
||||
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
|
||||
- Encrypted credential storage, auto-renewal, cascading disable
|
||||
|
||||
### 💰 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
|
||||
```txt
|
||||
4 CPU cores
|
||||
8GB RAM
|
||||
50GB SSD
|
||||
No GPU required
|
||||
```
|
||||
|
||||
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 |
|
||||
|-------|------|
|
||||
| Backend | Fastify |
|
||||
| Frontend | React 18 + Vite |
|
||||
| Language | TypeScript |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
|
||||
| Visualization | VChart |
|
||||
| Container | Docker + Docker Compose |
|
||||
| Test | Vitest |
|
||||
| Backend | Rust, Axum 0.8, Tokio |
|
||||
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
|
||||
| Proxy engine | Token Router + Proxy Conductor |
|
||||
| Protocols | OpenAI / Claude / Gemini compatible transformers |
|
||||
| Frontend | React 19 + Vite |
|
||||
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
|
||||
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
|
||||
| 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
|
||||
npm install
|
||||
npm run db:migrate
|
||||
npm run dev
|
||||
cargo check
|
||||
cargo run
|
||||
pnpm install
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
Build checks:
|
||||
|
||||
```bash
|
||||
npm run build # Build web + server
|
||||
npm test # Run tests
|
||||
cargo check
|
||||
pnpm run typecheck:web
|
||||
npx vite build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+368
-22
@@ -17,10 +17,17 @@ fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
||||
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
|
||||
FRONTEND_ONLY=0
|
||||
BACKEND_ONLY=0
|
||||
CHECK_ONLY=0
|
||||
NO_RUN=0
|
||||
FORCE_FRONTEND=0
|
||||
SKIP_FRONTEND=0
|
||||
PRODUCTION_BUNDLE=0
|
||||
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
||||
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() {
|
||||
cat <<'EOF'
|
||||
@@ -31,18 +38,33 @@ usage() {
|
||||
--release 使用 release 模式编译后端(更慢但性能好)
|
||||
--fast-release 使用 fast-release 模式编译后端(比 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 只构建前端
|
||||
--backend-only 只构建后端
|
||||
--force-frontend 强制重建前端,忽略缓存
|
||||
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
||||
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
|
||||
--no-run 构建后不启动服务
|
||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
||||
BOOSAPI_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 显示帮助
|
||||
|
||||
示例:
|
||||
./build-and-run.sh --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
|
||||
EOF
|
||||
}
|
||||
@@ -52,6 +74,21 @@ while [ $# -gt 0 ]; do
|
||||
--dev) BUILD_PROFILE="dev" ;;
|
||||
--release) BUILD_PROFILE="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)
|
||||
shift
|
||||
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
||||
@@ -61,6 +98,7 @@ while [ $# -gt 0 ]; do
|
||||
--backend-only) BACKEND_ONLY=1 ;;
|
||||
--force-frontend) FORCE_FRONTEND=1 ;;
|
||||
--skip-frontend) SKIP_FRONTEND=1 ;;
|
||||
--bun) FRONTEND_PM="bun" ;;
|
||||
--no-run) NO_RUN=1 ;;
|
||||
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -73,12 +111,122 @@ if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
||||
fail "--frontend-only 和 --backend-only 不能同时使用"
|
||||
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
|
||||
dev|debug) BUILD_PROFILE="dev" ;;
|
||||
release|fast-release) ;;
|
||||
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
|
||||
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() {
|
||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||
@@ -161,25 +309,73 @@ stop_previous_server() {
|
||||
fi
|
||||
}
|
||||
|
||||
build_frontend() {
|
||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||
log "跳过前端构建(--skip-frontend)"
|
||||
ensure_pnpm() {
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
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
|
||||
fi
|
||||
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
fail "未找到 npx,请先安装 Node.js"
|
||||
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
|
||||
ensure_pnpm
|
||||
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
|
||||
log "安装主前端依赖 (pnpm install)..."
|
||||
pnpm install --frozen-lockfile
|
||||
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
|
||||
fingerprint="$(
|
||||
{
|
||||
printf 'build-script-v2\n'
|
||||
printf 'frontend_pm=%s\n' "$frontend_pm"
|
||||
printf 'node='
|
||||
node --version 2>/dev/null || true
|
||||
printf 'npx='
|
||||
npx --version 2>/dev/null || true
|
||||
printf 'bun='
|
||||
bun --version 2>/dev/null || true
|
||||
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
|
||||
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
||||
done | sort | xargs -r sha256sum
|
||||
@@ -191,49 +387,179 @@ build_frontend() {
|
||||
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
|
||||
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
||||
ok "前端无变化,跳过 Vite 构建 → dist/web/"
|
||||
record_timing "前端检查" "$started"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "构建前端 (Vite)..."
|
||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
||||
npx vite build
|
||||
log "构建前端 (Vite via ${frontend_pm})..."
|
||||
# 直接调用 vite build;桌面端构建链路已移除。
|
||||
if [ "$frontend_pm" = "bun" ]; then
|
||||
bunx --bun vite build
|
||||
else
|
||||
pnpm exec vite build
|
||||
fi
|
||||
mkdir -p "$ROOT_DIR/dist/web"
|
||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||
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() {
|
||||
local started
|
||||
started="$(timer_start)"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||
fi
|
||||
|
||||
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。
|
||||
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。
|
||||
if ! command -v mold >/dev/null 2>&1; then
|
||||
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
|
||||
fi
|
||||
configure_fast_linker
|
||||
build_cargo_args
|
||||
|
||||
CARGO_FEATURE_ARGS=()
|
||||
if [ -n "$CARGO_FEATURES" ]; then
|
||||
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||
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
|
||||
|
||||
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build --release "${CARGO_FEATURE_ARGS[@]}"
|
||||
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||
BINARY="$ROOT_DIR/target/release/boosapi"
|
||||
ok "后端构建完成 → target/release/boosapi"
|
||||
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
||||
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||
BINARY="$ROOT_DIR/target/fast-release/boosapi"
|
||||
ok "后端构建完成 → target/fast-release/boosapi"
|
||||
else
|
||||
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_FEATURE_ARGS[@]}"
|
||||
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||
BINARY="$ROOT_DIR/target/debug/boosapi"
|
||||
ok "后端构建完成 → target/debug/boosapi"
|
||||
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() {
|
||||
@@ -248,21 +574,41 @@ cleanup_started_server() {
|
||||
|
||||
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
print_timing_summary
|
||||
log "跳过启动(--frontend-only)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||
ensure_workflow_ui_for_embedding
|
||||
fi
|
||||
fi
|
||||
|
||||
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
|
||||
print_timing_summary
|
||||
log "跳过启动(--no-run)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
print_timing_summary
|
||||
stop_previous_server
|
||||
|
||||
log "启动服务..."
|
||||
|
||||
+9
-9
@@ -28,19 +28,19 @@ proxy_log_retention_days = 30
|
||||
|
||||
[comfyui]
|
||||
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"
|
||||
port = 8188
|
||||
extra_args = []
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -20,6 +20,10 @@ RUN find /workspace/ComfyUI -mindepth 1 \
|
||||
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
||||
|
||||
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'
|
||||
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 |
@@ -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
@@ -2,12 +2,12 @@
|
||||
"name": "boosapi",
|
||||
"version": "1.3.0",
|
||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||
"main": "dist/desktop/main.js",
|
||||
"main": "dist/web/index.html",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"api-gateway",
|
||||
"proxy",
|
||||
"fastify",
|
||||
"rust",
|
||||
"openai",
|
||||
"claude",
|
||||
"aggregator"
|
||||
@@ -22,98 +22,45 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=25.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
||||
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
|
||||
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"build:web": "vite build",
|
||||
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
|
||||
"build:desktop": "tsc -p tsconfig.desktop.json",
|
||||
"build": "npm run build:web && npm run build:server && npm run build:desktop",
|
||||
"build": "npm run build:web",
|
||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
||||
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
|
||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
|
||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
||||
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
|
||||
"package:desktop": "npm run dist:desktop",
|
||||
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
|
||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||
"website:dev": "npm --prefix website run dev",
|
||||
"website:build": "npm --prefix website run build",
|
||||
"website:start": "npm --prefix website run start",
|
||||
"website:typecheck": "npm --prefix website run typecheck",
|
||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||
"start": "node dist/server/index.js",
|
||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
||||
"start:desktop": "electron dist/desktop/main.js",
|
||||
"comfyui:install": "node scripts/comfyui/install.mjs",
|
||||
"comfyui:start": "node scripts/comfyui/start.mjs",
|
||||
"comfyui:stop": "node scripts/comfyui/stop.mjs",
|
||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
|
||||
"schema:generate": "npm run db:generate && npm run schema:contract",
|
||||
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
|
||||
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
|
||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
||||
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
|
||||
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
|
||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
||||
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
||||
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
||||
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
|
||||
"test": "vitest run --root .",
|
||||
"test:watch": "vitest --root ."
|
||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
||||
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
||||
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
||||
"test": "vitest run --root src/web",
|
||||
"test:watch": "vitest --root src/web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@visactor/react-vchart": "^2.0.21",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fastify": "^5.8.4",
|
||||
"get-port": "^7.2.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^17.0.5",
|
||||
"minimatch": "^10.2.5",
|
||||
"minimist": "^1.2.8",
|
||||
"mysql2": "^3.20.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.16.3",
|
||||
"rate-limiter-flexible": "^11.0.0",
|
||||
"socks": "^2.8.7",
|
||||
"undici": "^6.24.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"jsdom": "^29.0.2",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -122,13 +69,11 @@
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-plugin-mermaid": "^2.0.17",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^9.0.4"
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"minimist": "^1.2.8"
|
||||
|
||||
Generated
+82
-3768
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
@@ -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
|
||||
Executable
+260
@@ -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
|
||||
@@ -4,10 +4,34 @@ import { spawn } from 'child_process';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
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 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 = {}) {
|
||||
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);
|
||||
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR;
|
||||
if (REPO) {
|
||||
function selectedVariants() {
|
||||
if (VARIANT === 'all') return Object.entries(variants);
|
||||
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.rm(CACHE_DIR, { recursive: true, force: true });
|
||||
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
|
||||
} else if (!hasSubmodule) {
|
||||
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
|
||||
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', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
|
||||
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
|
||||
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
|
||||
await run('git', ['submodule', 'update', '--init', '--recursive'], { 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}`);
|
||||
}
|
||||
}
|
||||
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 });
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -47,6 +47,8 @@ pub struct ProxyConfig {
|
||||
pub first_byte_timeout_secs: u64,
|
||||
pub max_retries: u32,
|
||||
pub proxy_log_retention_days: u32,
|
||||
#[serde(default)]
|
||||
pub template_proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -76,6 +78,7 @@ pub enum ComfyUIBackend {
|
||||
Boosapi,
|
||||
External,
|
||||
Cloud,
|
||||
Runninghub,
|
||||
Docker,
|
||||
}
|
||||
|
||||
@@ -109,13 +112,20 @@ impl Default for ComfyUIConfig {
|
||||
|
||||
impl ComfyUIConfig {
|
||||
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 {
|
||||
matches!(self.backend, ComfyUIBackend::Cloud)
|
||||
}
|
||||
|
||||
pub fn uses_runninghub_backend(&self) -> bool {
|
||||
matches!(self.backend, ComfyUIBackend::Runninghub)
|
||||
}
|
||||
|
||||
pub fn uses_docker_backend(&self) -> bool {
|
||||
matches!(self.backend, ComfyUIBackend::Docker)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use sqlx::{
|
||||
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
pub mod schema_compat;
|
||||
pub mod sqlite_to_postgres;
|
||||
|
||||
#[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()
|
||||
.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)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
|
||||
@@ -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)",
|
||||
];
|
||||
@@ -299,6 +299,200 @@ const TABLES: &[TableSpec] = &[
|
||||
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(
|
||||
|
||||
+24
-5
@@ -6,18 +6,21 @@ pub mod proxy;
|
||||
pub mod routes;
|
||||
pub mod services;
|
||||
pub mod shared;
|
||||
pub mod template_http;
|
||||
pub mod transformers;
|
||||
pub mod workflow;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: config::AppConfig,
|
||||
pub db: Arc<SqlitePool>,
|
||||
pub http: reqwest::Client,
|
||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||
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 registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||
}
|
||||
@@ -27,6 +30,7 @@ impl AppState {
|
||||
config: config::AppConfig,
|
||||
db: SqlitePool,
|
||||
builtin_keys: services::builtin_keys::BuiltinKeys,
|
||||
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
|
||||
) -> Self {
|
||||
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
@@ -38,21 +42,36 @@ impl AppState {
|
||||
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
||||
config.comfyui.clone(),
|
||||
boosapi_url.clone(),
|
||||
config.auth.proxy_token.clone(),
|
||||
runtime_auth.proxy_token(),
|
||||
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 {
|
||||
config,
|
||||
db: Arc::new(db),
|
||||
http,
|
||||
comfyui,
|
||||
builtin_keys: Arc::new(builtin_keys),
|
||||
register_captcha: Arc::new(
|
||||
services::register_captcha::RegisterCaptchaStore::new(),
|
||||
),
|
||||
runtime_auth: Arc::new(runtime_auth),
|
||||
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||
registration_limiter: Arc::new(
|
||||
services::registration_limiter::RegistrationLimiter::new(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_proxy_token(&self) -> String {
|
||||
self.runtime_auth.proxy_token()
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -1,11 +1,16 @@
|
||||
use anyhow::Context;
|
||||
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 std::ffi::OsString;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
@@ -104,7 +109,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.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);
|
||||
|
||||
@@ -116,6 +125,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Run migrations
|
||||
db::run_migrations(db_kind, Some(&pool), None).await?;
|
||||
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
|
||||
tracing::info!("Database migrations applied");
|
||||
|
||||
if args.migrate_only {
|
||||
@@ -134,9 +144,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
let builtin = builtin_keys::BuiltinKeys {
|
||||
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
|
||||
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 {
|
||||
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||
}
|
||||
|
||||
+494
-48
@@ -14,9 +14,7 @@ use crate::transformers::{
|
||||
canonical::CanonicalRequest,
|
||||
};
|
||||
use crate::AppState;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Resolved upstream info for a given model request.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -26,15 +24,6 @@ pub struct UpstreamRoute {
|
||||
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(
|
||||
text: &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.
|
||||
pub async fn execute_chat_completions(
|
||||
_state: AppState,
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let client = state.http.clone();
|
||||
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
||||
|
||||
let resp = client
|
||||
@@ -210,19 +199,29 @@ pub async fn execute_chat_completions(
|
||||
/// Execute a streaming chat completions request.
|
||||
/// Returns a vector of SSE data chunks as strings.
|
||||
pub async fn execute_chat_completions_stream(
|
||||
_state: AppState,
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> 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.
|
||||
pub async fn execute_anthropic_messages(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
request: CanonicalRequest,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let client = state.http.clone();
|
||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||
let compatibility = AnthropicMessagesCompatibility::default();
|
||||
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.
|
||||
pub async fn execute_anthropic_messages_stream(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
request: CanonicalRequest,
|
||||
) -> 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 compatibility = AnthropicMessagesCompatibility::default();
|
||||
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 stream = resp.bytes_stream();
|
||||
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).
|
||||
/// Returns SSE `data: ...` lines verbatim.
|
||||
pub async fn execute_streaming_request(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
path_suffix: &str,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let client = http_client();
|
||||
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 resp = execute_streaming_response(state, route, path_suffix, body).await?;
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
@@ -455,14 +449,50 @@ pub async fn execute_streaming_request(
|
||||
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.
|
||||
pub async fn execute_proxy_request(
|
||||
_state: AppState,
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
path_suffix: &str,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let client = state.http.clone();
|
||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||
|
||||
let resp = client
|
||||
@@ -492,8 +522,12 @@ pub async fn execute_proxy_request(
|
||||
}
|
||||
|
||||
/// Generic GET proxy request to an upstream endpoint path.
|
||||
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
pub async fn execute_proxy_get(
|
||||
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 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)
|
||||
}
|
||||
|
||||
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
|
||||
/// task endpoints as a first-class compatibility target.
|
||||
pub async fn execute_video_generation(
|
||||
@@ -525,6 +576,10 @@ pub async fn execute_video_generation(
|
||||
route: UpstreamRoute,
|
||||
body: 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) {
|
||||
let body = volcengine_video_create_body(body, &route.model)?;
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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.
|
||||
pub async fn execute_image_generation(
|
||||
_state: AppState,
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let client = state.http.clone();
|
||||
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
||||
|
||||
let resp = client
|
||||
@@ -661,13 +1107,13 @@ pub struct MultipartField {
|
||||
/// the route-resolved upstream model and skip any client-side fields
|
||||
/// that don't belong on the wire.
|
||||
pub async fn execute_image_edit(
|
||||
_state: AppState,
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
fields: Vec<MultipartField>,
|
||||
) -> AppResult<Value> {
|
||||
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 mut form = Form::new();
|
||||
|
||||
@@ -2,16 +2,52 @@ use super::types::{RouteDecision, RouteMatch};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
|
||||
use crate::proxy::executors::UpstreamRoute;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use regex::Regex;
|
||||
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)]
|
||||
pub struct TokenRouter {
|
||||
db: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
struct RouteRow {
|
||||
id: i64,
|
||||
model_pattern: String,
|
||||
@@ -19,7 +55,7 @@ struct RouteRow {
|
||||
route_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
struct ChannelRow {
|
||||
channel_id: i64,
|
||||
route_id: i64,
|
||||
@@ -91,14 +127,7 @@ impl TokenRouter {
|
||||
}
|
||||
|
||||
async fn match_route(&self, model: &str) -> AppResult<Option<(RouteRow, RouteMatch)>> {
|
||||
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?;
|
||||
let rows = self.route_rows().await?;
|
||||
|
||||
let mut best: Option<(RouteRow, RouteMatch, usize, usize)> = None;
|
||||
for row in rows {
|
||||
@@ -131,48 +160,7 @@ impl TokenRouter {
|
||||
requested_model: &str,
|
||||
exclude_channel_ids: &[i64],
|
||||
) -> AppResult<Option<SelectedChannel>> {
|
||||
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?;
|
||||
let rows = self.channel_rows(route.id).await?;
|
||||
|
||||
let row = rows
|
||||
.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<()> {
|
||||
sqlx::query(
|
||||
"UPDATE route_channels
|
||||
@@ -232,6 +303,7 @@ impl TokenRouter {
|
||||
.bind(channel_id)
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
invalidate_channel_cache_for_channel(channel_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -254,6 +326,7 @@ impl TokenRouter {
|
||||
.bind(channel_id)
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
invalidate_channel_cache_for_channel(channel_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -359,6 +432,7 @@ mod tests {
|
||||
site_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
unit_cost REAL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
)",
|
||||
)
|
||||
|
||||
+812
-67
File diff suppressed because it is too large
Load Diff
@@ -409,6 +409,7 @@ mod tests {
|
||||
first_byte_timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 7,
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
},
|
||||
@@ -416,6 +417,7 @@ mod tests {
|
||||
BuiltinKeys {
|
||||
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
|
||||
},
|
||||
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -217,6 +217,7 @@ const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
|
||||
|
||||
fn is_hidden_setting_key(key: &str) -> bool {
|
||||
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.
|
||||
@@ -313,6 +314,12 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
AppState::new(
|
||||
AppConfig {
|
||||
@@ -341,6 +348,7 @@ mod tests {
|
||||
first_byte_timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 7,
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
},
|
||||
@@ -348,11 +356,12 @@ mod tests {
|
||||
BuiltinKeys {
|
||||
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
|
||||
/// `builtin_key.*` rows.
|
||||
/// 5.2 — `GET /api/settings` returns regular settings but never protected
|
||||
/// secret rows.
|
||||
#[tokio::test]
|
||||
async fn list_settings_filters_builtin_key_prefix() {
|
||||
let state = settings_state().await;
|
||||
@@ -363,6 +372,10 @@ mod tests {
|
||||
!data.contains_key("builtin_key.comfyui_agent.secret"),
|
||||
"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.*`
|
||||
|
||||
@@ -140,6 +140,10 @@ pub async fn create_site(
|
||||
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 mut tx = state.db.begin().await?;
|
||||
@@ -154,8 +158,8 @@ pub async fn create_site(
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.url)
|
||||
.bind(&payload.platform)
|
||||
.bind(&site_url)
|
||||
.bind(&platform)
|
||||
.bind(&status)
|
||||
.bind(&payload.api_key)
|
||||
.bind(&payload.proxy_url)
|
||||
@@ -217,6 +221,27 @@ pub async fn update_site(
|
||||
.await?
|
||||
.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?;
|
||||
|
||||
sqlx::query(
|
||||
@@ -230,8 +255,8 @@ pub async fn update_site(
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.url.unwrap_or(existing.url))
|
||||
.bind(payload.platform.unwrap_or(existing.platform))
|
||||
.bind(next_url)
|
||||
.bind(next_platform)
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(payload.api_key.or(existing.api_key))
|
||||
.bind(payload.proxy_url.or(existing.proxy_url))
|
||||
@@ -466,6 +491,45 @@ fn normalize_site_url(raw: &str) -> AppResult<String> {
|
||||
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>) {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
|
||||
|
||||
@@ -49,8 +49,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
|
||||
// Downstream API key
|
||||
if auth.starts_with("sk-") {
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
let result =
|
||||
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
||||
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||
.await
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
|
||||
@@ -200,7 +201,9 @@ async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
let _ = client_socket
|
||||
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into()))
|
||||
.send(Message::Text(
|
||||
format!("invalid ComfyUI websocket URL: {err}").into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
+133
-20
@@ -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 {
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
outbound_key: proxy_token,
|
||||
key_name: "global-proxy".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -282,7 +283,7 @@ async fn resolve_agent_auth(
|
||||
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err((
|
||||
@@ -368,6 +369,8 @@ async fn status_handler(
|
||||
"keyName": key_name,
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"apiBase": upstream_base,
|
||||
"upstreamBase": upstream_base,
|
||||
"upstreamLabel": upstream_label,
|
||||
"comfyui": state.comfyui.status_response().await,
|
||||
})))
|
||||
@@ -680,6 +683,77 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
||||
.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 {
|
||||
let mime = attachment
|
||||
.mime_type
|
||||
@@ -701,7 +775,8 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("file");
|
||||
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||
let header =
|
||||
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||
|
||||
if let Some(text) = attachment
|
||||
.text
|
||||
@@ -935,22 +1010,7 @@ async fn chat_handler(
|
||||
continue;
|
||||
};
|
||||
|
||||
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 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 let Some(reasoning) = extract_reasoning_delta(&parsed) {
|
||||
if !send_sse(
|
||||
&tx,
|
||||
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
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1234,6 +1302,7 @@ mod tests {
|
||||
first_byte_timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 7,
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
},
|
||||
@@ -1241,6 +1310,7 @@ mod tests {
|
||||
BuiltinKeys {
|
||||
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
|
||||
}
|
||||
|
||||
#[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:
|
||||
/// no env → local; whitespace/empty env → still local (treated as unset);
|
||||
/// real env → fallback_remote with trimmed `upstreamBase`.
|
||||
|
||||
+1135
-15
File diff suppressed because it is too large
Load Diff
+83
-10
@@ -1,14 +1,15 @@
|
||||
// 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::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListEventsQuery {
|
||||
@@ -32,17 +33,15 @@ async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListEventsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing events");
|
||||
debug!("Listing events");
|
||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
||||
let mut bind_idx = 0;
|
||||
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 params: Vec<String> = vec![];
|
||||
|
||||
if let Some(ref t) = q.filter_type {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
||||
sql.push_str(" AND type = ?");
|
||||
params.push(t.clone());
|
||||
}
|
||||
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(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
|
||||
|
||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
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
|
||||
@@ -98,5 +122,54 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM events")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
runtime_logs::clear();
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-29
@@ -10,9 +10,14 @@ use include_dir::{include_dir, Dir};
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tower::service_fn;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
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 auth;
|
||||
@@ -23,6 +28,7 @@ mod digital_human;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
mod money_printer;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
mod picture_agent;
|
||||
@@ -31,7 +37,6 @@ mod search;
|
||||
mod site_announcements;
|
||||
pub(crate) mod stats;
|
||||
mod tasks;
|
||||
mod update_center;
|
||||
mod user;
|
||||
mod user_api_keys;
|
||||
#[path = "video_agent_legacy.rs"]
|
||||
@@ -46,6 +51,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
.merge(picture_agent::routes())
|
||||
.merge(money_printer::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(digital_human::routes())
|
||||
.merge(comfyui::routes())
|
||||
@@ -66,7 +72,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(workflow_ui::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(tasks::routes())
|
||||
.merge(update_center::routes())
|
||||
.merge(user::routes())
|
||||
.merge(user_api_keys::routes())
|
||||
.merge(workspace::routes())
|
||||
@@ -74,40 +79,42 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.with_state(state);
|
||||
|
||||
let router = Router::new().merge(api_routes);
|
||||
match std::env::var("BOOSAPI_WEB_DIR")
|
||||
let router = match std::env::var("BOOSAPI_WEB_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(web_dir) => {
|
||||
let web_dir_owned = web_dir.clone();
|
||||
let 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)
|
||||
.append_index_html_on_directories(true)
|
||||
.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 {
|
||||
let path = req.uri().path();
|
||||
if is_api_path(path) {
|
||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||
}
|
||||
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => Ok::<_, Infallible>(html_response(html)),
|
||||
Err(_) => Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", path)))
|
||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
|
||||
),
|
||||
}
|
||||
Ok::<_, Infallible>(filesystem_static_response(
|
||||
path,
|
||||
&web_dir,
|
||||
&website_dir,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
}));
|
||||
router.fallback_service(static_service)
|
||||
}
|
||||
None => router.fallback(embedded_static_handler),
|
||||
}
|
||||
};
|
||||
|
||||
router.layer(CompressionLayer::new())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
let path = req.uri().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);
|
||||
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());
|
||||
}
|
||||
|
||||
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
||||
if let Some(file) = static_dir.get_file("index.html") {
|
||||
return embedded_file_response("index.html", file.contents());
|
||||
}
|
||||
|
||||
let disk_fallback = filesystem_static_response(path, "dist/web", "dist/website").await;
|
||||
if disk_fallback.status() != StatusCode::NOT_FOUND {
|
||||
return disk_fallback;
|
||||
}
|
||||
|
||||
if should_serve_website(path) && EMBEDDED_WEBSITE.get_file("index.html").is_none() {
|
||||
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
||||
return embedded_file_response("index.html", file.contents());
|
||||
}
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.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())
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -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 {
|
||||
let content_type = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+38
-36
@@ -1,27 +1,28 @@
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::background_task::BackgroundTask,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
http::{HeaderMap, Response, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
const TEMPLATE_GALLERY_URL: &str =
|
||||
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
|
||||
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
||||
"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 TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
|
||||
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
||||
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
||||
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
||||
@@ -122,11 +123,12 @@ async fn resolve_picture_agent_auth(
|
||||
let token = extract_bearer_token(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
return Ok(PictureAgentAuth {
|
||||
user_id: None,
|
||||
role: "admin".to_string(),
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
outbound_key: proxy_token,
|
||||
key_name: "global-proxy".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -152,7 +154,7 @@ async fn resolve_picture_agent_auth(
|
||||
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
||||
user_id: Some(user_id),
|
||||
role: claims.role,
|
||||
outbound_key: state.config.auth.proxy_token.clone(),
|
||||
outbound_key: state.runtime_proxy_token(),
|
||||
key_name: "global-proxy".to_string(),
|
||||
}),
|
||||
Err(message) => Err(AppError::Forbidden(message)),
|
||||
@@ -285,6 +287,7 @@ mod tests {
|
||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||
@@ -344,18 +347,30 @@ async fn templates_handler(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||
let items = template_client()
|
||||
.get(TEMPLATE_GALLERY_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {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, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
|
||||
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
|
||||
(Vec::new(), json!({ "ok": false, "message": 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()
|
||||
.filter_map(|item| {
|
||||
let prompt = item.prompt?.trim().to_string();
|
||||
@@ -383,27 +398,14 @@ async fn templates_handler(
|
||||
"prompt": prompt,
|
||||
"source": {
|
||||
"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,
|
||||
"username": item.username,
|
||||
},
|
||||
}))
|
||||
})
|
||||
.take(limit)
|
||||
.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())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn picture_generation_client() -> Client {
|
||||
|
||||
+738
-361
File diff suppressed because it is too large
Load Diff
+488
-52
@@ -8,6 +8,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -23,6 +25,15 @@ pub struct SiteDistributionQuery {
|
||||
_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> {
|
||||
Router::new()
|
||||
.route("/api/stats/dashboard", get(dashboard))
|
||||
@@ -43,6 +54,45 @@ async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> 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")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
@@ -123,9 +173,8 @@ async fn dashboard(
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let site_availability = build_site_availability(&state).await?;
|
||||
|
||||
let payload = json!({
|
||||
Ok(json!({
|
||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"totalBalance": total_balance,
|
||||
"totalUsed": total_used,
|
||||
@@ -147,7 +196,6 @@ async fn dashboard(
|
||||
"requestsPerMinute": perf_requests 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": {
|
||||
"sites": sites,
|
||||
"accounts": accounts,
|
||||
@@ -165,30 +213,134 @@ async fn dashboard(
|
||||
"successRequests": success_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>> {
|
||||
@@ -275,6 +427,7 @@ async fn proxy_logs(
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.content_summary, l.request_content, l.response_content,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
@@ -354,6 +507,9 @@ pub struct ProxyLogRow {
|
||||
client_app_id: Option<String>,
|
||||
client_app_name: Option<String>,
|
||||
client_confidence: Option<String>,
|
||||
content_summary: Option<String>,
|
||||
request_content: Option<String>,
|
||||
response_content: 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.error_message, l.retry_count, l.is_stream,
|
||||
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,
|
||||
a.site_id AS site_id,
|
||||
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 })))
|
||||
}
|
||||
|
||||
async fn proxy_debug_traces() -> Json<Value> {
|
||||
Json(json!({ "items": [], "data": [], "total": 0 }))
|
||||
async fn proxy_debug_traces(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||
) -> Json<Value> {
|
||||
Json(json!({ "id": id, "events": [], "data": null }))
|
||||
) -> AppResult<Json<Value>> {
|
||||
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=...
|
||||
@@ -489,28 +847,70 @@ async fn site_distribution(
|
||||
.to_string();
|
||||
|
||||
// Sum per-site usage
|
||||
let rows: Vec<(i64, String, i64, f64, i64)> = 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)
|
||||
let mut rows: Vec<(i64, String, Option<String>, i64, f64, i64, i64, f64)> = sqlx::query_as(
|
||||
"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
|
||||
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 >= ?
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY SUM(m.total_spend) DESC"
|
||||
GROUP BY s.id, s.name, s.platform
|
||||
ORDER BY SUM(m.total_spend) DESC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.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
|
||||
.into_iter()
|
||||
.map(|(id, name, calls, spend, tokens)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.map(
|
||||
|(id, name, platform, calls, spend, tokens, account_count, balance)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"platform": platform,
|
||||
"calls": calls,
|
||||
"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,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "distribution": distribution })))
|
||||
@@ -528,25 +928,61 @@ async fn site_trend(
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let rows: Vec<(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)
|
||||
FROM model_day_usage
|
||||
WHERE local_day >= ?
|
||||
GROUP BY local_day
|
||||
ORDER BY local_day ASC",
|
||||
let mut rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT
|
||||
m.local_day,
|
||||
s.name,
|
||||
COALESCE(SUM(m.total_calls), 0),
|
||||
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)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let trend: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(day, calls, spend, tokens)| {
|
||||
if rows.is_empty() {
|
||||
rows = sqlx::query_as(
|
||||
"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!({
|
||||
"date": day,
|
||||
"calls": calls,
|
||||
"spend": spend,
|
||||
"tokens": tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let trend: Vec<Value> = by_day
|
||||
.into_iter()
|
||||
.map(|(day, sites)| {
|
||||
json!({
|
||||
"date": day,
|
||||
"sites": sites,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// Background tasks routes — list and inspect async operations
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/tasks", get(list_tasks))
|
||||
.route("/api/tasks/{id}", get(get_task))
|
||||
.route(
|
||||
"/api/update-center/tasks/{id}/stream",
|
||||
get(stream_task_logs),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -22,22 +22,29 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
||||
.route("/api/user/logs", get(get_user_logs))
|
||||
.route("/api/user/summary", get(get_user_summary))
|
||||
.route("/api/user/dashboard", get(get_user_dashboard))
|
||||
.route(
|
||||
"/api/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(
|
||||
"SELECT *
|
||||
FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
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(user_id)
|
||||
.bind(limit.clamp(1, 5000))
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
@@ -56,7 +63,19 @@ async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec
|
||||
"pending" => "pending",
|
||||
_ => "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,
|
||||
"route_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,
|
||||
"status_label": status,
|
||||
"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())
|
||||
}
|
||||
|
||||
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(
|
||||
r#"
|
||||
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
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
LIMIT 5000
|
||||
LIMIT ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit.clamp(1, 5000))
|
||||
.fetch_all(&*state.db)
|
||||
.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_name": "Picture Agent",
|
||||
"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(),
|
||||
"source": "usage-logs",
|
||||
"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_url").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("title").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>> {
|
||||
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>(
|
||||
r#"
|
||||
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.error_message, l.retry_count, l.is_stream,
|
||||
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,
|
||||
a.site_id AS site_id,
|
||||
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
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 5000
|
||||
LIMIT ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
items.extend(user_usage_log_items(state, user_id).await?);
|
||||
items.extend(user_picture_log_items(state, user_id).await?);
|
||||
items.extend(user_usage_log_items_limited(state, user_id, limit).await?);
|
||||
items.extend(user_picture_log_items_limited(state, user_id, limit).await?);
|
||||
sort_user_logs_desc(&mut items);
|
||||
if truncate_combined {
|
||||
items.truncate(limit as usize);
|
||||
}
|
||||
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)]
|
||||
struct UpdateProfilePayload {
|
||||
username: Option<String>,
|
||||
@@ -536,13 +645,7 @@ async fn get_user_summary(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let items = load_user_log_items(&state, user_id).await?;
|
||||
let total = items.len() as i64;
|
||||
let success_count = items
|
||||
.iter()
|
||||
.filter(|item| user_log_status(item).as_str() == "success")
|
||||
.count() as i64;
|
||||
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
|
||||
let (total, success_count, total_cost) = load_user_log_summary(&state, user_id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -273,7 +273,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.bearer_auth(state.runtime_proxy_token())
|
||||
.json(&json!({
|
||||
"model": &state.config.comfyui.agent_model,
|
||||
"messages": [
|
||||
@@ -319,7 +319,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.bearer_auth(state.runtime_proxy_token())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
@@ -362,7 +362,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.bearer_auth(state.runtime_proxy_token())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
|
||||
+2494
-218
File diff suppressed because it is too large
Load Diff
+152
-17
@@ -7,12 +7,13 @@ use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum WorkspaceCaller {
|
||||
@@ -33,9 +34,19 @@ struct AssetQuery {
|
||||
page_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BatchDeleteAssetsRequest {
|
||||
#[serde(default, alias = "publicIds")]
|
||||
public_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/workspace/assets", get(list_admin_assets))
|
||||
.route(
|
||||
"/api/workspace/assets/batch-delete",
|
||||
post(batch_delete_admin_assets),
|
||||
)
|
||||
.route(
|
||||
"/api/workspace/assets/{public_id}",
|
||||
get(get_admin_asset).delete(delete_admin_asset),
|
||||
@@ -45,6 +56,10 @@ pub fn routes() -> Router<AppState> {
|
||||
get(get_admin_asset_file),
|
||||
)
|
||||
.route("/api/user/workspace/assets", get(list_user_assets))
|
||||
.route(
|
||||
"/api/user/workspace/assets/batch-delete",
|
||||
post(batch_delete_user_assets),
|
||||
)
|
||||
.route(
|
||||
"/api/user/workspace/assets/{public_id}",
|
||||
get(get_user_asset).delete(delete_user_asset),
|
||||
@@ -76,7 +91,8 @@ fn resolve_caller(
|
||||
let token = bearer(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
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(
|
||||
state: AppState,
|
||||
caller: WorkspaceCaller,
|
||||
@@ -167,12 +197,14 @@ async fn list_assets(
|
||||
WorkspaceCaller::Admin => requested_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 *
|
||||
FROM workspace_assets
|
||||
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 kind = ?)
|
||||
AND (
|
||||
@@ -183,10 +215,14 @@ async fn list_assets(
|
||||
OR source LIKE ?
|
||||
)
|
||||
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(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
@@ -202,11 +238,12 @@ async fn list_assets(
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*)
|
||||
FROM workspace_assets
|
||||
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 kind = ?)
|
||||
AND (
|
||||
@@ -215,10 +252,14 @@ async fn list_assets(
|
||||
OR prompt LIKE ?
|
||||
OR model LIKE ?
|
||||
OR source LIKE ?
|
||||
)",
|
||||
)
|
||||
)"
|
||||
);
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
@@ -249,17 +290,21 @@ async fn get_asset_row(
|
||||
WorkspaceCaller::Admin => None,
|
||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
let row = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workspace_assets
|
||||
WHERE public_id = ?
|
||||
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",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(&public_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
||||
@@ -286,24 +331,90 @@ async fn delete_asset(
|
||||
WorkspaceCaller::Admin => None,
|
||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
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 (? 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(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
state: AppState,
|
||||
caller: WorkspaceCaller,
|
||||
@@ -318,7 +429,11 @@ async fn get_asset_file(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
||||
let path = PathBuf::from(file_path);
|
||||
let bytes = tokio::fs::read(&path)
|
||||
let file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||
let mime_type = row
|
||||
@@ -334,11 +449,13 @@ async fn get_asset_file(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime_type)
|
||||
.header(header::CONTENT_LENGTH, metadata.len().to_string())
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
||||
)
|
||||
.body(Body::from(bytes))
|
||||
.body(Body::from_stream(ReaderStream::new(file)))
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
||||
}
|
||||
|
||||
@@ -378,6 +495,15 @@ async fn delete_admin_asset(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -413,3 +539,12 @@ async fn delete_user_asset(
|
||||
let caller = resolve_caller(&headers, &state, false)?;
|
||||
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}`;
|
||||
}
|
||||
@@ -1,779 +0,0 @@
|
||||
const BRAND_DEFINITIONS = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
icon: 'openai',
|
||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
||||
rules: [
|
||||
{ keyword: 'gpt', mode: 'startsWith' },
|
||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
||||
{ keyword: 'whisper', mode: 'startsWith' },
|
||||
{ keyword: 'text-embedding', mode: 'startsWith' },
|
||||
{ keyword: 'text-moderation', mode: 'startsWith' },
|
||||
{ keyword: 'davinci', mode: 'startsWith' },
|
||||
{ keyword: 'babbage', mode: 'startsWith' },
|
||||
{ keyword: 'codex-mini', mode: 'startsWith' },
|
||||
{ keyword: 'o1', mode: 'startsWith' },
|
||||
{ keyword: 'o3', mode: 'startsWith' },
|
||||
{ keyword: 'o4', mode: 'startsWith' },
|
||||
{ keyword: 'tts', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Anthropic',
|
||||
icon: 'claude-color',
|
||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
||||
rules: [
|
||||
{ keyword: 'claude', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Google',
|
||||
icon: 'gemini-color',
|
||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
||||
rules: [
|
||||
{ keyword: 'gemini', mode: 'includes' },
|
||||
{ keyword: 'gemma', mode: 'includes' },
|
||||
{ keyword: 'google/', mode: 'includes' },
|
||||
{ keyword: 'palm', mode: 'includes' },
|
||||
{ keyword: 'paligemma', mode: 'includes' },
|
||||
{ keyword: 'shieldgemma', mode: 'includes' },
|
||||
{ keyword: 'recurrentgemma', mode: 'includes' },
|
||||
{ keyword: 'deplot', mode: 'includes' },
|
||||
{ keyword: 'codegemma', mode: 'includes' },
|
||||
{ keyword: 'imagen', mode: 'includes' },
|
||||
{ keyword: 'learnlm', mode: 'includes' },
|
||||
{ keyword: 'aqa', mode: 'includes' },
|
||||
{ keyword: 'veo', mode: 'startsWith' },
|
||||
{ keyword: 'google/', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepSeek',
|
||||
icon: 'deepseek-color',
|
||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
||||
rules: [
|
||||
{ keyword: 'deepseek', mode: 'includes' },
|
||||
{ keyword: 'ds-chat', mode: 'segment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '通义千问',
|
||||
icon: 'qwen-color',
|
||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
||||
rules: [
|
||||
{ keyword: 'qwen', mode: 'includes' },
|
||||
{ keyword: 'qwq', mode: 'includes' },
|
||||
{ keyword: 'tongyi', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '智谱 AI',
|
||||
icon: 'zhipu-color',
|
||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'glm', mode: 'includes' },
|
||||
{ keyword: 'chatglm', mode: 'includes' },
|
||||
{ keyword: 'codegeex', mode: 'includes' },
|
||||
{ keyword: 'cogview', mode: 'includes' },
|
||||
{ keyword: 'cogvideo', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Meta',
|
||||
icon: 'meta-color',
|
||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
||||
rules: [
|
||||
{ keyword: 'llama', mode: 'includes' },
|
||||
{ keyword: 'code-llama', mode: 'includes' },
|
||||
{ keyword: 'codellama', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Mistral',
|
||||
icon: 'mistral-color',
|
||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
||||
rules: [
|
||||
{ keyword: 'mistral', mode: 'includes' },
|
||||
{ keyword: 'mixtral', mode: 'includes' },
|
||||
{ keyword: 'codestral', mode: 'includes' },
|
||||
{ keyword: 'pixtral', mode: 'includes' },
|
||||
{ keyword: 'ministral', mode: 'includes' },
|
||||
{ keyword: 'voxtral', mode: 'includes' },
|
||||
{ keyword: 'magistral', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Moonshot',
|
||||
icon: 'moonshot',
|
||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
||||
rules: [
|
||||
{ keyword: 'moonshot', mode: 'includes' },
|
||||
{ keyword: 'kimi', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '零一万物',
|
||||
icon: 'yi-color',
|
||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
||||
rules: [
|
||||
{ keyword: 'yi-', mode: 'startsWith' },
|
||||
{ keyword: 'yi', mode: 'boundary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '文心一言',
|
||||
icon: 'wenxin-color',
|
||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
||||
rules: [
|
||||
{ keyword: 'ernie', mode: 'includes' },
|
||||
{ keyword: 'eb-', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '讯飞星火',
|
||||
icon: 'spark-color',
|
||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
||||
rules: [
|
||||
{ keyword: 'spark', mode: 'includes' },
|
||||
{ keyword: 'generalv', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '腾讯混元',
|
||||
icon: 'hunyuan-color',
|
||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
||||
rules: [
|
||||
{ keyword: 'hunyuan', mode: 'includes' },
|
||||
{ keyword: 'tencent-hunyuan', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '豆包',
|
||||
icon: 'doubao-color',
|
||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
||||
rules: [
|
||||
{ keyword: 'doubao', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'MiniMax',
|
||||
icon: 'minimax-color',
|
||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
||||
rules: [
|
||||
{ keyword: 'minimax', mode: 'includes' },
|
||||
{ keyword: 'abab', mode: 'includes' },
|
||||
{ keyword: 'mini2.1', mode: 'segment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cohere',
|
||||
icon: 'cohere-color',
|
||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
||||
rules: [
|
||||
{ keyword: 'command', mode: 'includes' },
|
||||
{ keyword: 'c4ai-', mode: 'includes' },
|
||||
{ keyword: 'aya', mode: 'includes' },
|
||||
{ keyword: 'embed-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Microsoft',
|
||||
icon: 'microsoft-color',
|
||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
||||
rules: [
|
||||
{ keyword: 'microsoft/', mode: 'includes' },
|
||||
{ keyword: 'phi-', mode: 'includes' },
|
||||
{ keyword: 'kosmos', mode: 'includes' },
|
||||
{ keyword: 'phi4', mode: 'segment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'xAI',
|
||||
icon: 'xai',
|
||||
color: 'linear-gradient(135deg, #111, #444)',
|
||||
rules: [
|
||||
{ keyword: 'grok', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '阶跃星辰',
|
||||
icon: 'stepfun-color',
|
||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
||||
rules: [
|
||||
{ keyword: 'stepfun', mode: 'includes' },
|
||||
{ keyword: 'step-', mode: 'startsWith' },
|
||||
{ keyword: 'step3', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '百川智能',
|
||||
icon: 'baichuan-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'baichuan', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AI21 Labs',
|
||||
icon: 'ai21-brand-color',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
|
||||
rules: [
|
||||
{ keyword: 'ai21', mode: 'includes' },
|
||||
{ keyword: 'jamba', mode: 'startsWith' },
|
||||
{ keyword: 'jamba', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AI2',
|
||||
icon: 'ai2-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'allenai', mode: 'includes' },
|
||||
{ keyword: 'olmo', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Amazon Nova',
|
||||
icon: 'nova',
|
||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'amazon/nova', mode: 'startsWith' },
|
||||
{ keyword: 'amazon.nova', mode: 'includes' },
|
||||
{ keyword: 'us.amazon.nova', mode: 'includes' },
|
||||
{ keyword: 'nova-', mode: 'startsWith' },
|
||||
{ keyword: 'nova-lite', mode: 'startsWith' },
|
||||
{ keyword: 'nova-pro', mode: 'startsWith' },
|
||||
{ keyword: 'nova-micro', mode: 'startsWith' },
|
||||
{ keyword: 'nova-canvas', mode: 'startsWith' },
|
||||
{ keyword: 'nova-reel', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Stability',
|
||||
icon: 'stability-color',
|
||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
||||
rules: [
|
||||
{ keyword: 'flux', mode: 'includes' },
|
||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
||||
{ keyword: 'stable-diffusion', mode: 'includes' },
|
||||
{ keyword: 'sdxl', mode: 'includes' },
|
||||
{ keyword: 'sd3', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'NVIDIA',
|
||||
icon: 'nvidia-color',
|
||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
||||
rules: [
|
||||
{ keyword: 'nvidia/', mode: 'includes' },
|
||||
{ keyword: 'nvclip', mode: 'includes' },
|
||||
{ keyword: 'nemotron', mode: 'includes' },
|
||||
{ keyword: 'nemoretriever', mode: 'includes' },
|
||||
{ keyword: 'neva', mode: 'includes' },
|
||||
{ keyword: 'riva-translate', mode: 'includes' },
|
||||
{ keyword: 'cosmos', mode: 'includes' },
|
||||
{ keyword: 'nv-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'IBM',
|
||||
icon: 'ibm',
|
||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
||||
rules: [
|
||||
{ keyword: 'ibm/', mode: 'includes' },
|
||||
{ keyword: 'granite', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'BAAI',
|
||||
icon: 'baai',
|
||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
||||
rules: [
|
||||
{ keyword: 'baai/', mode: 'includes' },
|
||||
{ keyword: 'bge-', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ByteDance',
|
||||
icon: 'bytedance-color',
|
||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
||||
rules: [
|
||||
{ keyword: 'bytedance', mode: 'includes' },
|
||||
{ keyword: 'seed-oss', mode: 'includes' },
|
||||
{ keyword: 'kolors', mode: 'includes' },
|
||||
{ keyword: 'kwai', mode: 'includes' },
|
||||
{ keyword: 'kwaipilot', mode: 'includes' },
|
||||
{ keyword: 'wan-', mode: 'startsWith' },
|
||||
{ keyword: 'kat-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'InternLM',
|
||||
icon: 'internlm-color',
|
||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
||||
rules: [
|
||||
{ keyword: 'internlm', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Midjourney',
|
||||
icon: 'midjourney',
|
||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
||||
rules: [
|
||||
{ keyword: 'midjourney', mode: 'includes' },
|
||||
{ keyword: 'mj_', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepL',
|
||||
icon: 'deepl-color',
|
||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
||||
rules: [
|
||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
||||
{ keyword: 'deepl/', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jina AI',
|
||||
icon: 'jina',
|
||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
||||
rules: [
|
||||
{ keyword: 'jina', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Relace',
|
||||
icon: 'relace',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'relace', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Arcee',
|
||||
icon: 'arcee-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
rules: [
|
||||
{ keyword: 'arcee-ai', mode: 'includes' },
|
||||
{ keyword: 'arcee', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AionLabs',
|
||||
icon: 'aionlabs-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'aion-labs', mode: 'includes' },
|
||||
{ keyword: 'aionlabs', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepCogito',
|
||||
icon: 'deepcogito-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
|
||||
rules: [
|
||||
{ keyword: 'deepcogito', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Essential AI',
|
||||
icon: 'essentialai-color',
|
||||
color: 'linear-gradient(135deg, #0f172a, #334155)',
|
||||
rules: [
|
||||
{ keyword: 'essentialai', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Inception',
|
||||
icon: 'inception',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
||||
rules: [
|
||||
{ keyword: 'inception', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Inflection',
|
||||
icon: 'inflection',
|
||||
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'inflection', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Liquid AI',
|
||||
icon: 'liquid',
|
||||
color: 'linear-gradient(135deg, #0f172a, #475569)',
|
||||
rules: [
|
||||
{ keyword: 'liquid', mode: 'includes' },
|
||||
{ keyword: 'lfm-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'LongCat',
|
||||
icon: 'longcat-color',
|
||||
color: 'linear-gradient(135deg, #f97316, #fb7185)',
|
||||
rules: [
|
||||
{ keyword: 'longcat', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Morph',
|
||||
icon: 'morph-color',
|
||||
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
|
||||
rules: [
|
||||
{ keyword: 'morph/', mode: 'includes' },
|
||||
{ keyword: 'morph-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nous Research',
|
||||
icon: 'nousresearch',
|
||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
||||
rules: [
|
||||
{ keyword: 'nousresearch', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Upstage',
|
||||
icon: 'upstage-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'upstage', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Xiaomi MiMo',
|
||||
icon: 'xiaomimimo',
|
||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
||||
rules: [
|
||||
{ keyword: 'xiaomi/mimo', mode: 'includes' },
|
||||
{ keyword: 'xiaomimimo', mode: 'includes' },
|
||||
{ keyword: 'mimo-v', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Z.ai',
|
||||
icon: 'zai',
|
||||
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: '2zai', mode: 'startsWith' },
|
||||
{ keyword: 'z-ai', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SenseNova',
|
||||
icon: 'sensenova-brand-color',
|
||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'sensenova', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Perplexity',
|
||||
icon: 'perplexity-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
||||
rules: [
|
||||
{ keyword: 'perplexity', mode: 'includes' },
|
||||
{ keyword: 'pplx-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
icon: 'openrouter',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'openrouter', mode: 'includes' },
|
||||
{ keyword: 'openrouter-', mode: 'startsWith' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Groq',
|
||||
icon: 'groq',
|
||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
||||
rules: [
|
||||
{ keyword: 'groq', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Fireworks',
|
||||
icon: 'fireworks-color',
|
||||
color: 'linear-gradient(135deg, #fb7185, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'fireworks-ai', mode: 'includes' },
|
||||
{ keyword: 'fireworks', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'DeepInfra',
|
||||
icon: 'deepinfra-color',
|
||||
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
|
||||
rules: [
|
||||
{ keyword: 'deepinfra', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Together AI',
|
||||
icon: 'together-brand-color',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
||||
rules: [
|
||||
{ keyword: 'together.ai', mode: 'includes' },
|
||||
{ keyword: 'together', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Replicate',
|
||||
icon: 'replicate-brand',
|
||||
color: 'linear-gradient(135deg, #111827, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'replicate', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SambaNova',
|
||||
icon: 'sambanova-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
|
||||
rules: [
|
||||
{ keyword: 'sambanova', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cerebras',
|
||||
icon: 'cerebras-brand-color',
|
||||
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
|
||||
rules: [
|
||||
{ keyword: 'cerebras', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ollama',
|
||||
icon: 'ollama',
|
||||
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
|
||||
rules: [
|
||||
{ keyword: 'ollama', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ModelScope',
|
||||
icon: 'modelscope-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
rules: [
|
||||
{ keyword: 'modelscope', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SiliconCloud',
|
||||
icon: 'siliconcloud-color',
|
||||
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
|
||||
rules: [
|
||||
{ keyword: 'siliconcloud', mode: 'includes' },
|
||||
{ keyword: 'siliconflow', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Azure AI',
|
||||
icon: 'azureai-color',
|
||||
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'azureai', mode: 'includes' },
|
||||
{ keyword: 'azure-openai', mode: 'includes' },
|
||||
{ keyword: 'azure/openai', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AWS Bedrock',
|
||||
icon: 'bedrock-color',
|
||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
||||
rules: [
|
||||
{ keyword: 'bedrock', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Vertex AI',
|
||||
icon: 'vertexai-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
|
||||
rules: [
|
||||
{ keyword: 'vertexai', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Google Cloud',
|
||||
icon: 'googlecloud-brand-color',
|
||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
||||
rules: [
|
||||
{ keyword: 'googlecloud', mode: 'includes' },
|
||||
{ keyword: 'google-cloud', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '百度智能云',
|
||||
icon: 'baiducloud-color',
|
||||
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
|
||||
rules: [
|
||||
{ keyword: 'baiducloud', mode: 'includes' },
|
||||
{ keyword: 'qianfan', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '百炼',
|
||||
icon: 'bailian-color',
|
||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
||||
rules: [
|
||||
{ keyword: 'bailian', mode: 'includes' },
|
||||
{ keyword: 'dashscope', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '阿里云',
|
||||
icon: 'alibabacloud-color',
|
||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
||||
rules: [
|
||||
{ keyword: 'alibabacloud', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '火山引擎',
|
||||
icon: 'volcengine-color',
|
||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
||||
rules: [
|
||||
{ keyword: 'volcengine', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '七牛云',
|
||||
icon: 'qiniu-color',
|
||||
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
|
||||
rules: [
|
||||
{ keyword: 'qiniu', mode: 'includes' },
|
||||
],
|
||||
},
|
||||
];
|
||||
function normalizeInput(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
export function stripCommonWrappers(value) {
|
||||
return value
|
||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
||||
.replace(/^re:\s*/g, '')
|
||||
.replace(/^\^+/, '')
|
||||
.replace(/\$+$/, '')
|
||||
.trim();
|
||||
}
|
||||
export function collectBrandCandidates(modelName) {
|
||||
const queue = [];
|
||||
const seen = new Set();
|
||||
const push = (value) => {
|
||||
const normalized = normalizeInput(value);
|
||||
if (!normalized || seen.has(normalized))
|
||||
return;
|
||||
seen.add(normalized);
|
||||
queue.push(normalized);
|
||||
};
|
||||
push(modelName);
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const candidate = queue[index];
|
||||
const cleaned = stripCommonWrappers(candidate);
|
||||
push(cleaned);
|
||||
if (cleaned.includes('/')) {
|
||||
for (const part of cleaned.split('/'))
|
||||
push(part);
|
||||
}
|
||||
if (cleaned.includes(':')) {
|
||||
for (const part of cleaned.split(':'))
|
||||
push(part);
|
||||
}
|
||||
if (cleaned.includes(',')) {
|
||||
for (const part of cleaned.split(','))
|
||||
push(part);
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
function buildMatchContext(modelName) {
|
||||
const candidates = collectBrandCandidates(modelName);
|
||||
const raw = candidates[0] || normalizeInput(modelName);
|
||||
const cleaned = stripCommonWrappers(raw);
|
||||
const segments = Array.from(new Set(candidates
|
||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)));
|
||||
return {
|
||||
raw,
|
||||
cleaned,
|
||||
segments,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
function matchesRule(context, rule) {
|
||||
switch (rule.mode) {
|
||||
case 'includes':
|
||||
return context.raw.includes(rule.keyword)
|
||||
|| context.cleaned.includes(rule.keyword)
|
||||
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
|
||||
case 'startsWith':
|
||||
return context.raw.startsWith(rule.keyword)
|
||||
|| context.cleaned.startsWith(rule.keyword)
|
||||
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|
||||
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
|
||||
case 'segment':
|
||||
return context.segments.includes(rule.keyword);
|
||||
case 'boundary': {
|
||||
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
|
||||
return pattern.test(context.raw)
|
||||
|| pattern.test(context.cleaned)
|
||||
|| context.candidates.some((candidate) => pattern.test(candidate));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
||||
brand,
|
||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
||||
}));
|
||||
export function getAllBrands() {
|
||||
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
|
||||
}
|
||||
export function getAllBrandNames() {
|
||||
return BRAND_DEFINITIONS.map((brand) => brand.name);
|
||||
}
|
||||
function toBrandInfo(brand) {
|
||||
return {
|
||||
name: brand.name,
|
||||
icon: brand.icon,
|
||||
color: brand.color,
|
||||
};
|
||||
}
|
||||
export function getMatchingBrands(modelName) {
|
||||
const context = buildMatchContext(modelName);
|
||||
const matches = [];
|
||||
const seen = new Set();
|
||||
const add = (brand) => {
|
||||
if (seen.has(brand.name))
|
||||
return;
|
||||
seen.add(brand.name);
|
||||
matches.push(toBrandInfo(brand));
|
||||
};
|
||||
for (const definition of BRAND_DEFINITIONS) {
|
||||
if (definition.rules.some((rule) => matchesRule(context, rule))) {
|
||||
add(definition);
|
||||
}
|
||||
}
|
||||
for (const candidate of context.candidates) {
|
||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
||||
if (rule.boundaryRegex.test(candidate)) {
|
||||
add(rule.brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
export function getMatchingBrandNames(modelName) {
|
||||
return getMatchingBrands(modelName).map((brand) => brand.name);
|
||||
}
|
||||
export function getBrand(modelName) {
|
||||
return getMatchingBrands(modelName)[0] || null;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
function normalizeString(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
function normalizeDigest(value) {
|
||||
const digest = normalizeString(value);
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||
}
|
||||
function normalizeStableTag(value) {
|
||||
return normalizeStableVersion(value);
|
||||
}
|
||||
function hasSameImageTag(left, right) {
|
||||
const rawLeft = normalizeString(left);
|
||||
const rawRight = normalizeString(right);
|
||||
if (!rawLeft || !rawRight)
|
||||
return false;
|
||||
const stableLeft = normalizeStableTag(left);
|
||||
const stableRight = normalizeStableTag(right);
|
||||
if (stableLeft && stableRight) {
|
||||
return stableLeft === stableRight;
|
||||
}
|
||||
return rawLeft === rawRight;
|
||||
}
|
||||
export function normalizeStableVersion(value) {
|
||||
const raw = normalizeString(value);
|
||||
if (!raw)
|
||||
return '';
|
||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
||||
if (!match)
|
||||
return '';
|
||||
return [
|
||||
Number.parseInt(match[1], 10),
|
||||
Number.parseInt(match[2], 10),
|
||||
Number.parseInt(match[3], 10),
|
||||
].join('.');
|
||||
}
|
||||
export function compareStableVersions(left, right) {
|
||||
const normalizedLeft = normalizeStableVersion(left);
|
||||
const normalizedRight = normalizeStableVersion(right);
|
||||
if (!normalizedLeft || !normalizedRight)
|
||||
return null;
|
||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] === rightParts[index])
|
||||
continue;
|
||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
export function isSameImageTarget(current, target) {
|
||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
||||
const targetDigest = normalizeDigest(target.digest);
|
||||
if (currentDigest && targetDigest) {
|
||||
return currentDigest === targetDigest;
|
||||
}
|
||||
return hasSameImageTag(current?.imageTag, target.tag);
|
||||
}
|
||||
export function buildUpdateReminderCandidateKey(source, candidate) {
|
||||
const tagName = normalizeString(candidate.tagName);
|
||||
const digest = normalizeDigest(candidate.digest);
|
||||
if (!tagName)
|
||||
return '';
|
||||
if (source === 'docker-hub-tag' && digest) {
|
||||
return `${source}:${tagName}@${digest}`;
|
||||
}
|
||||
return `${source}:${tagName}`;
|
||||
}
|
||||
export function resolveUpdateReminderCandidate(input) {
|
||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
||||
if (githubTag) {
|
||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
||||
return {
|
||||
source: 'github-release',
|
||||
kind: 'new-version',
|
||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
||||
tagName: githubTag,
|
||||
digest: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
||||
if (!dockerTag)
|
||||
return null;
|
||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
||||
return null;
|
||||
}
|
||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
||||
if (dockerVersionCompare === -1) {
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-version',
|
||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
||||
tagName: dockerTag,
|
||||
digest: dockerDigest || null,
|
||||
};
|
||||
}
|
||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-digest',
|
||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
||||
tagName: dockerTag,
|
||||
digest: dockerDigest,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,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
@@ -2,8 +2,9 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||
};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -108,8 +109,11 @@ impl UsageTracker {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
if let Some(max_cost) = max_cost {
|
||||
if used_cost.unwrap_or(0.0) >= max_cost {
|
||||
return Err(AppError::RateLimited);
|
||||
if max_cost <= 0.0 || used_cost.unwrap_or(0.0) >= max_cost {
|
||||
return Err(AppError::RateLimitedWithRetry {
|
||||
message: "下游密钥成本额度已用完".into(),
|
||||
retry_after_secs: 60,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -149,6 +153,215 @@ impl UsageTracker {
|
||||
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<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
@@ -187,10 +400,179 @@ impl UsageTracker {
|
||||
)
|
||||
.execute(db)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -260,7 +642,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
tracker.check_rate_limit(1).await,
|
||||
Err(AppError::RateLimited)
|
||||
Err(AppError::RateLimitedWithRetry { .. })
|
||||
));
|
||||
}
|
||||
|
||||
@@ -279,4 +661,92 @@ mod tests {
|
||||
.unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ impl ComfyUIProcess {
|
||||
let base_url = self.internal_base_url();
|
||||
let mode = if self.config.uses_docker_backend() {
|
||||
"docker"
|
||||
} else if self.config.uses_cloud_backend() {
|
||||
"cloud"
|
||||
} else if self.config.uses_runninghub_backend() {
|
||||
"runninghub"
|
||||
} else if self.config.enabled {
|
||||
"embedded"
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,10 @@ impl CronScheduler {
|
||||
|
||||
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
||||
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(
|
||||
"UPDATE downstream_api_keys
|
||||
SET updated_at = datetime('now')
|
||||
@@ -123,6 +127,7 @@ impl CronScheduler {
|
||||
)
|
||||
.execute(&*db)
|
||||
.await?;
|
||||
crate::proxy::token_router::router::invalidate_route_cache();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ pub mod oauth;
|
||||
pub mod register_captcha;
|
||||
pub mod registration_limiter;
|
||||
pub mod route_rebuild;
|
||||
pub mod runtime_auth;
|
||||
pub mod runtime_logs;
|
||||
pub mod shared;
|
||||
pub mod update_center;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::error::AppResult;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
||||
use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||
@@ -36,13 +36,19 @@ pub struct Notification {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NotificationConfig {
|
||||
telegram_enabled: bool,
|
||||
telegram_bot_token: Option<String>,
|
||||
telegram_chat_id: Option<String>,
|
||||
telegram_thread_id: Option<i64>,
|
||||
wecom_enabled: bool,
|
||||
wecom_webhook: Option<String>,
|
||||
feishu_enabled: bool,
|
||||
feishu_webhook: Option<String>,
|
||||
webhook_enabled: bool,
|
||||
webhook_url: Option<String>,
|
||||
bark_enabled: bool,
|
||||
bark_url: Option<String>,
|
||||
serverchan_enabled: bool,
|
||||
serverchan_key: Option<String>,
|
||||
smtp: SmtpConfig,
|
||||
cooldown_sec: i64,
|
||||
@@ -50,8 +56,10 @@ struct NotificationConfig {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SmtpConfig {
|
||||
enabled: bool,
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
secure: Option<bool>,
|
||||
user: Option<String>,
|
||||
pass: Option<String>,
|
||||
from: Option<String>,
|
||||
@@ -100,6 +108,33 @@ impl NotificationService {
|
||||
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, ¬if, &config)
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(format!("{channel} 通知测试失败: {err}")))?;
|
||||
}
|
||||
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
||||
let rows: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
||||
@@ -112,18 +147,26 @@ impl NotificationService {
|
||||
.collect();
|
||||
|
||||
Ok(NotificationConfig {
|
||||
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
|
||||
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
||||
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
||||
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
|
||||
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
||||
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
|
||||
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
||||
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
|
||||
webhook_url: setting(&map, "notify_webhook_url"),
|
||||
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
|
||||
bark_url: setting(&map, "notify_bark_url"),
|
||||
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
|
||||
serverchan_key: setting(&map, "notify_serverchan_key"),
|
||||
smtp: SmtpConfig {
|
||||
enabled: setting_bool(&map, "notify_smtp_enabled", true),
|
||||
host: setting(&map, "notify_smtp_host"),
|
||||
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"),
|
||||
pass: setting(&map, "notify_smtp_pass"),
|
||||
from: setting(&map, "notify_smtp_from"),
|
||||
@@ -188,25 +231,28 @@ impl NotificationService {
|
||||
|
||||
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
||||
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());
|
||||
}
|
||||
if config.wecom_webhook.is_some() {
|
||||
if config.wecom_enabled && config.wecom_webhook.is_some() {
|
||||
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());
|
||||
}
|
||||
if config.webhook_url.is_some() {
|
||||
if config.webhook_enabled && config.webhook_url.is_some() {
|
||||
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());
|
||||
}
|
||||
if config.serverchan_key.is_some() {
|
||||
if config.serverchan_enabled && config.serverchan_key.is_some() {
|
||||
channels.push("serverchan".to_string());
|
||||
}
|
||||
if config.smtp.is_configured() {
|
||||
if config.smtp.enabled && config.smtp.is_configured() {
|
||||
channels.push("smtp".to_string());
|
||||
}
|
||||
channels
|
||||
@@ -385,6 +431,7 @@ impl SmtpConfig {
|
||||
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
||||
let host = required(self.host.as_deref(), "smtp host")?;
|
||||
let port = self.port.unwrap_or(587);
|
||||
let secure = self.secure.unwrap_or(true);
|
||||
let user = required(self.user.as_deref(), "smtp user")?;
|
||||
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
||||
let from = required(self.from.as_deref(), "smtp from")?;
|
||||
@@ -400,7 +447,12 @@ impl SmtpConfig {
|
||||
))
|
||||
.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)
|
||||
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||
.build();
|
||||
@@ -413,6 +465,17 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<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> {
|
||||
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
||||
}
|
||||
|
||||
@@ -179,7 +179,13 @@ mod tests {
|
||||
fn captcha_is_one_time() {
|
||||
let store = RegisterCaptchaStore::new();
|
||||
let challenge = store.issue(None);
|
||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
||||
let answer = store
|
||||
.inner
|
||||
.lock()
|
||||
.get(&challenge.id)
|
||||
.unwrap()
|
||||
.answer
|
||||
.clone();
|
||||
|
||||
store.verify(&challenge.id, &answer, None).unwrap();
|
||||
assert_eq!(
|
||||
@@ -192,7 +198,13 @@ mod tests {
|
||||
fn captcha_rejects_other_ip() {
|
||||
let store = RegisterCaptchaStore::new();
|
||||
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
||||
let answer = store
|
||||
.inner
|
||||
.lock()
|
||||
.get(&challenge.id)
|
||||
.unwrap()
|
||||
.answer
|
||||
.clone();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
|
||||
@@ -57,7 +57,9 @@ struct AvailRow {
|
||||
/// single transaction so a partial failure doesn't leave `token_routes` and
|
||||
/// `route_channels` out of sync.
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,17 @@ pub async fn enforce_key_request_limit(
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
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 {
|
||||
return Err(AppError::RateLimited);
|
||||
return Err(AppError::RateLimitedWithRetry {
|
||||
message: "下游密钥请求额度已用完".into(),
|
||||
retry_after_secs: 60,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -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
@@ -160,6 +160,23 @@ async fn insert_asset(
|
||||
.execute(&*state.db)
|
||||
.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 {
|
||||
public_id: public_id.to_string(),
|
||||
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(
|
||||
state: &AppState,
|
||||
input: WorkspaceAssetInput,
|
||||
@@ -188,11 +250,7 @@ pub async fn record_remote_asset(
|
||||
.await;
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
match client.get(url).send().await {
|
||||
match state.http.get(url).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let mime_type = response
|
||||
.headers()
|
||||
@@ -307,6 +365,45 @@ pub async fn record_bytes_asset(
|
||||
.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(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
@@ -338,7 +435,10 @@ pub async fn record_image_assets_from_response(
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
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 {
|
||||
Ok(asset) => stored_assets.push(asset),
|
||||
@@ -357,7 +457,10 @@ pub async fn record_image_assets_from_response(
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
remote_url: None,
|
||||
metadata: json!({ "responseItem": item }),
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"responseItem": item,
|
||||
}),
|
||||
};
|
||||
match record_base64_asset(state, input, b64).await {
|
||||
Ok(asset) => stored_assets.push(asset),
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
export declare function parseBatchApiKeys(input: unknown): string[];
|
||||
export declare function buildBatchApiKeyConnectionName(
|
||||
baseName: string | null | undefined,
|
||||
index: number,
|
||||
total: number,
|
||||
): string;
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
buildBatchApiKeyConnectionName,
|
||||
parseBatchApiKeys,
|
||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
||||
Vendored
-40
@@ -1,40 +0,0 @@
|
||||
export type UpdateVersionCandidateLike = {
|
||||
normalizedVersion?: string | null;
|
||||
displayVersion?: string | null;
|
||||
tagName?: string | null;
|
||||
digest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateHelperRuntimeLike = {
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateReminderCandidate = {
|
||||
source: 'github-release' | 'docker-hub-tag';
|
||||
kind: 'new-version' | 'new-digest';
|
||||
candidateKey: string;
|
||||
displayVersion: string;
|
||||
tagName: string;
|
||||
digest: string | null;
|
||||
};
|
||||
|
||||
export declare function normalizeStableVersion(value?: string | null): string;
|
||||
export declare function compareStableVersions(
|
||||
left?: string | null,
|
||||
right?: string | null,
|
||||
): number | null;
|
||||
export declare function isSameImageTarget(
|
||||
current: UpdateHelperRuntimeLike | null | undefined,
|
||||
target: { tag?: string | null; digest?: string | null },
|
||||
): boolean;
|
||||
export declare function buildUpdateReminderCandidateKey(
|
||||
source: 'github-release' | 'docker-hub-tag',
|
||||
candidate: { tagName?: string | null; digest?: string | null },
|
||||
): string;
|
||||
export declare function resolveUpdateReminderCandidate(input: {
|
||||
currentVersion?: string | null;
|
||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateReminderCandidate | null;
|
||||
@@ -1,7 +0,0 @@
|
||||
export {
|
||||
buildUpdateReminderCandidateKey,
|
||||
compareStableVersions,
|
||||
isSameImageTarget,
|
||||
normalizeStableVersion,
|
||||
resolveUpdateReminderCandidate,
|
||||
} from '../server/shared/updateCenterReminder.js';
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
@@ -36,13 +37,13 @@ describe('Login surface', () => {
|
||||
&& node.props.className.includes('brand-mark-canvas')
|
||||
));
|
||||
|
||||
expect(pageText).toContain('BoosAPI');
|
||||
expect(pageText).toContain('中转站的中转站');
|
||||
expect(pageText).toContain('BossAI');
|
||||
expect(pageText).toContain('AI 应用平台');
|
||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
||||
expect(pageText).toContain('统一代理网关');
|
||||
expect(pageText).toContain('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
|
||||
expect(pageText).toContain('统一 API 网关');
|
||||
expect(pageText).toContain('智能路由引擎');
|
||||
expect(pageText).toContain('自动模型发现');
|
||||
expect(pageText).toContain('工作流生产台');
|
||||
expect(lightBrandPanel).toBeTruthy();
|
||||
expect(authStage).toBeTruthy();
|
||||
expect(brandMarkCanvas).toBeTruthy();
|
||||
@@ -61,4 +62,26 @@ describe('Login surface', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+426
-126
@@ -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 { gsap } from 'gsap';
|
||||
import { ToastProvider, useToast } from './components/Toast.js';
|
||||
import SearchModal from './components/SearchModal.js';
|
||||
import NotificationPanel from './components/NotificationPanel.js';
|
||||
import TooltipLayer from './components/TooltipLayer.js';
|
||||
import MotionDiv from './components/AppMotion.js';
|
||||
import { api, userApi } from './api.js';
|
||||
import { clearSession, getSessionToken } from './authSession.js';
|
||||
import {
|
||||
ADMIN_PROFILE_STORAGE_KEY,
|
||||
LEGACY_USER_PROFILE_STORAGE_KEY,
|
||||
LEGACY_THEME_STORAGE_KEY,
|
||||
THEME_MODE_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 PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
||||
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
||||
const Workflows = lazy(() => import('./pages/Workflows.js'));
|
||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||
@@ -54,6 +59,7 @@ type UserProfile = {
|
||||
name: string;
|
||||
avatarSeed: string;
|
||||
avatarStyle: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
type UserAnnouncementPopupRow = {
|
||||
id: number;
|
||||
@@ -63,21 +69,17 @@ type UserAnnouncementPopupRow = {
|
||||
publishedAt?: string | null;
|
||||
firstSeenAt?: string | null;
|
||||
};
|
||||
const DICEBEAR_STYLES = [
|
||||
'pixel-art',
|
||||
'pixel-art-neutral',
|
||||
'bottts',
|
||||
'bottts-neutral',
|
||||
'identicon',
|
||||
'initials',
|
||||
'avataaars',
|
||||
'avataaars-neutral',
|
||||
'personas',
|
||||
'lorelei',
|
||||
'lorelei-neutral',
|
||||
'fun-emoji',
|
||||
const AVATAR_STYLES = [
|
||||
'aurora',
|
||||
'skyline',
|
||||
'lotus',
|
||||
'ember',
|
||||
'mint',
|
||||
'violet',
|
||||
'graphite',
|
||||
'cobalt',
|
||||
] as const;
|
||||
type DicebearStyle = typeof DICEBEAR_STYLES[number];
|
||||
type AvatarStyle = typeof AVATAR_STYLES[number];
|
||||
function resolveStoredThemeMode(): ThemeMode {
|
||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
||||
@@ -99,24 +101,76 @@ function hashString(input: string): number {
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
function pickDicebearStyle(seed: string): DicebearStyle {
|
||||
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
|
||||
return DICEBEAR_STYLES[index];
|
||||
function pickAvatarStyle(seed: string): AvatarStyle {
|
||||
const index = hashString(seed || 'default') % AVATAR_STYLES.length;
|
||||
return AVATAR_STYLES[index];
|
||||
}
|
||||
function buildDicebearAvatarUrl(style: string, seed: string): string {
|
||||
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
|
||||
? style
|
||||
: pickDicebearStyle(seed);
|
||||
const safeSeed = (seed || 'default').trim() || 'default';
|
||||
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
||||
function resolveAvatarStyle(style: string, seed: string): AvatarStyle {
|
||||
return AVATAR_STYLES.includes(style as AvatarStyle)
|
||||
? (style as AvatarStyle)
|
||||
: pickAvatarStyle(seed);
|
||||
}
|
||||
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 {
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
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 raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
const avatarSeed = createRandomAvatarSeed();
|
||||
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string };
|
||||
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
|
||||
const avatarSeed = typeof parsed?.avatarSeed === 'string'
|
||||
@@ -127,17 +181,85 @@ function resolveStoredProfile(): UserProfile {
|
||||
? parsed.avatarStyle.trim()
|
||||
: '';
|
||||
return {
|
||||
name: name || '管理员',
|
||||
name: name || fallbackName,
|
||||
avatarSeed: resolvedSeed,
|
||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||
? avatarStyle
|
||||
: pickDicebearStyle(resolvedSeed),
|
||||
: pickAvatarStyle(resolvedSeed),
|
||||
avatarUrl: normalizeAvatarUrl((parsed as any)?.avatarUrl),
|
||||
};
|
||||
} catch {
|
||||
const avatarSeed = createRandomAvatarSeed();
|
||||
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
open,
|
||||
profile,
|
||||
@@ -151,18 +273,23 @@ function UserProfileModal({
|
||||
onSave: (nextProfile: UserProfile) => void;
|
||||
t: (text: string) => string;
|
||||
}) {
|
||||
const avatarInputId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
||||
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
||||
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
|
||||
const [avatarUploadName, setAvatarUploadName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile.name);
|
||||
setAvatarSeed(profile.avatarSeed);
|
||||
setAvatarStyle(profile.avatarStyle);
|
||||
setAvatarUrl(profile.avatarUrl || '');
|
||||
setAvatarUploadName('');
|
||||
setError('');
|
||||
}, [open, profile]);
|
||||
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
@@ -176,7 +303,29 @@ function UserProfileModal({
|
||||
const handleRandomAvatar = () => {
|
||||
const nextSeed = createRandomAvatarSeed();
|
||||
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 normalizedName = name.trim();
|
||||
@@ -188,14 +337,26 @@ function UserProfileModal({
|
||||
setError(t('用户名最多 24 个字符'));
|
||||
return;
|
||||
}
|
||||
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
||||
if (avatarUrl.trim() && !normalizedAvatarUrl) {
|
||||
setError(t('头像图片读取失败,请重新上传'));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
name: normalizedName,
|
||||
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
|
||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||
? avatarStyle
|
||||
: pickDicebearStyle(avatarSeed),
|
||||
: pickAvatarStyle(avatarSeed),
|
||||
avatarUrl: normalizedAvatarUrl || undefined,
|
||||
});
|
||||
};
|
||||
const previewProfile: UserProfile = {
|
||||
name: name.trim() || profile.name,
|
||||
avatarSeed,
|
||||
avatarStyle,
|
||||
avatarUrl: normalizeAvatarUrl(avatarUrl) || undefined,
|
||||
};
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
@@ -212,15 +373,16 @@ function UserProfileModal({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
||||
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name.trim() || 'avatar'}
|
||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
<div className="profile-avatar-preview-row">
|
||||
<LocalAvatar
|
||||
className="profile-avatar-preview"
|
||||
profile={previewProfile}
|
||||
label={previewProfile.name}
|
||||
/>
|
||||
<div>
|
||||
<strong>{t('头像实时预览')}</strong>
|
||||
<span>{t('会同步显示在右上角和侧边栏。')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
|
||||
@@ -235,12 +397,41 @@ function UserProfileModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('本地上传头像')}</div>
|
||||
<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 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}>
|
||||
{t('换一个随机头像')}
|
||||
</button>
|
||||
{avatarUrl.trim() && (
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleClearAvatarImage}>
|
||||
{t('清除图片头像')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
|
||||
{t('换一个随机头像')}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
@@ -337,7 +528,7 @@ export const sidebarGroups = [
|
||||
{
|
||||
label: '控制台',
|
||||
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: '/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> },
|
||||
@@ -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: '/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: '/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: '/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> },
|
||||
@@ -379,6 +571,7 @@ export const userSidebarGroups = [
|
||||
label: '创作应用',
|
||||
items: [
|
||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/user/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/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> },
|
||||
@@ -387,17 +580,18 @@ export const userSidebarGroups = [
|
||||
},
|
||||
];
|
||||
const topNavItems = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '控制台', to: '/dashboard' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
{ label: '生图助手', to: '/picture-agent' },
|
||||
{ label: '工作流', to: '/workflows' },
|
||||
{ label: '视频助手', to: '/comfyui-agent' },
|
||||
{ label: '工作空间', to: '/workspace' },
|
||||
{ label: '关于', to: '/about' },
|
||||
];
|
||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
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() {
|
||||
return (
|
||||
@@ -410,6 +604,7 @@ function RouteLoadingFallback() {
|
||||
function AppShell() {
|
||||
const { language, toggleLanguage, t } = useI18n();
|
||||
const location = useLocation();
|
||||
const topbarRef = useRef<HTMLElement>(null);
|
||||
const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null);
|
||||
const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -419,7 +614,9 @@ function AppShell() {
|
||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
|
||||
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 [showSearch, setShowSearch] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
@@ -438,9 +635,54 @@ function AppShell() {
|
||||
? (systemPrefersDark ? 'dark' : 'light')
|
||||
: themeMode;
|
||||
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 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(() => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const sync = () => setSystemPrefersDark(media.matches);
|
||||
@@ -560,6 +802,35 @@ function AppShell() {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
@@ -579,14 +850,15 @@ function AppShell() {
|
||||
const handleSaveProfile = (nextProfile: UserProfile) => {
|
||||
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
|
||||
const normalized = {
|
||||
name: nextProfile.name.trim() || t('管理员'),
|
||||
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
||||
avatarSeed: normalizedSeed,
|
||||
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(nextProfile.avatarStyle as AvatarStyle)
|
||||
? nextProfile.avatarStyle
|
||||
: pickDicebearStyle(normalizedSeed),
|
||||
: pickAvatarStyle(normalizedSeed),
|
||||
avatarUrl: normalizeAvatarUrl(nextProfile.avatarUrl) || undefined,
|
||||
};
|
||||
setUserProfile(normalized);
|
||||
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized));
|
||||
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
||||
setShowProfileModal(false);
|
||||
toast.success(t('个人信息已保存'));
|
||||
};
|
||||
@@ -617,19 +889,16 @@ function AppShell() {
|
||||
setAnnouncementPopupPending(false);
|
||||
}
|
||||
};
|
||||
const isUserPage = location.pathname.startsWith('/user/');
|
||||
const isAdminLoginPage = location.pathname === '/boos';
|
||||
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 (userAuthed) return <Navigate to="/user/dashboard" />;
|
||||
if (authed) return <Navigate to="/" />;
|
||||
if (authed) return <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
if (isAdminLoginPage) {
|
||||
if (authed) return <Navigate to="/" />;
|
||||
if (authed) return <Navigate to="/dashboard" />;
|
||||
return (
|
||||
<AdminLoginPage
|
||||
t={t}
|
||||
@@ -638,10 +907,6 @@ function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefaultLoginRoot) {
|
||||
return <Navigate to="/user/login" />;
|
||||
}
|
||||
|
||||
if (!authed && !isUserPage) {
|
||||
if (userAuthed) {
|
||||
return <Navigate to="/user/dashboard" />;
|
||||
@@ -674,18 +939,15 @@ function AppShell() {
|
||||
const currentTopNavItems = isUserPage ? [
|
||||
{ label: '个人中心', to: '/user/dashboard' },
|
||||
{ label: '模型广场', to: '/user/models' },
|
||||
{ label: '用户公告', to: '/user/announcements' },
|
||||
{ label: '工作流', to: '/user/workflows' },
|
||||
{ label: '视频助手', to: '/user/comfyui-agent' },
|
||||
{ label: '工作空间', to: '/user/workspace' },
|
||||
{ label: '关于', to: '/user/about' },
|
||||
] : topNavItems;
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<header ref={topbarRef} className="topbar">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
className="topbar-icon-btn topbar-motion-item"
|
||||
aria-label={t('打开导航')}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type="button"
|
||||
@@ -695,11 +957,16 @@ function AppShell() {
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="topbar-logo">
|
||||
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span className="topbar-logo-text">BoosAPI</span>
|
||||
<div className="topbar-logo topbar-motion-item">
|
||||
<span className="topbar-logo-mark">
|
||||
<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>
|
||||
<nav className="topbar-nav">
|
||||
<nav className="topbar-nav topbar-motion-item">
|
||||
{currentTopNavItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
|
||||
{t(item.label)}
|
||||
@@ -708,20 +975,19 @@ function AppShell() {
|
||||
</nav>
|
||||
<div className="topbar-right">
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
className="topbar-icon-btn topbar-language-btn topbar-motion-item"
|
||||
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
onClick={toggleLanguage}
|
||||
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
|
||||
>
|
||||
{language === 'zh' ? 'EN' : '中'}
|
||||
</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>
|
||||
<span className="topbar-search-label">{t('搜索')}</span>
|
||||
<kbd className="topbar-search-kbd">Ctrl K</kbd>
|
||||
</button>
|
||||
<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>
|
||||
{unreadCount > 0 && (
|
||||
<span className="topbar-badge">
|
||||
@@ -733,7 +999,7 @@ function AppShell() {
|
||||
</div>
|
||||
<div ref={themeMenuRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
className="topbar-icon-btn topbar-motion-item"
|
||||
aria-label={themeMode === 'system'
|
||||
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
||||
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
||||
@@ -779,8 +1045,8 @@ function AppShell() {
|
||||
<div ref={userMenuRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="topbar-avatar"
|
||||
aria-label={displayName}
|
||||
className="topbar-user-trigger topbar-motion-item"
|
||||
aria-label={`${t('账户菜单')}: ${displayName}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showUserMenu}
|
||||
onClick={() => {
|
||||
@@ -788,14 +1054,37 @@ function AppShell() {
|
||||
setShowThemeMenu(false);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
<span className="topbar-user-avatar">
|
||||
<LocalAvatar
|
||||
className="topbar-user-avatar-inner"
|
||||
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>
|
||||
{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
|
||||
className="user-dropdown-item"
|
||||
onClick={() => {
|
||||
@@ -829,8 +1118,8 @@ function AppShell() {
|
||||
closeLabel={t('关闭导航')}
|
||||
>
|
||||
<div className="mobile-drawer-header">
|
||||
<img src="/logo.png" alt="BoosAPI" />
|
||||
<span>BoosAPI</span>
|
||||
<img src="/logo.png" alt="BossAI" />
|
||||
<span>BossAI</span>
|
||||
</div>
|
||||
<nav className="mobile-nav">
|
||||
{currentSidebarGroups.map((group) => (
|
||||
@@ -840,7 +1129,7 @@ function AppShell() {
|
||||
<NavLink
|
||||
key={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' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
@@ -852,7 +1141,7 @@ function AppShell() {
|
||||
))}
|
||||
<div className="mobile-nav-group">
|
||||
<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
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -867,37 +1156,46 @@ function AppShell() {
|
||||
</MobileDrawer>
|
||||
) : (
|
||||
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
{currentSidebarGroups.map((group) => (
|
||||
<div key={group.label} className="sidebar-group">
|
||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'}
|
||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
||||
<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" />
|
||||
</svg>
|
||||
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
||||
</button>
|
||||
<nav className="sidebar-scroll" aria-label={t(isUserPage ? '用户工作台' : '管理控制台')}>
|
||||
{currentSidebarGroups.map((group) => (
|
||||
<div key={group.label} className="sidebar-group">
|
||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
|
||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</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 }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<main className="main-content">
|
||||
<PageTransition>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
||||
<Route path="/dashboard" element={<Dashboard adminName={displayName} />} />
|
||||
<Route path="/sites" element={<Sites />} />
|
||||
<Route path="/site-announcements" element={<SiteAnnouncements />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
@@ -914,6 +1212,7 @@ function AppShell() {
|
||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/money-printer" element={<MoneyPrinter />} />
|
||||
<Route path="/digital-human" element={<DigitalHuman />} />
|
||||
<Route path="/workflows" element={<Workflows />} />
|
||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||
@@ -933,6 +1232,7 @@ function AppShell() {
|
||||
<Route path="/user/usage" element={<UsageRecords />} />
|
||||
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
||||
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/user/money-printer" element={<MoneyPrinter />} />
|
||||
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
||||
<Route path="/user/workflows" element={<Workflows />} />
|
||||
<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/about" element={<About />} />
|
||||
|
||||
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} />
|
||||
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/dashboard"} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PageTransition>
|
||||
|
||||
+158
-37
@@ -322,6 +322,15 @@ function normalizeDownstreamKeyRow(row: any): any {
|
||||
groupName: row.groupName ?? row.group_name ?? null,
|
||||
usedCost: Number(row.usedCost ?? row.used_cost ?? 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,
|
||||
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
||||
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
||||
@@ -383,6 +392,9 @@ function normalizeProxyLogRow(row: any): any {
|
||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||
clientAppName: row.clientAppName ?? row.client_app_name ?? 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,
|
||||
};
|
||||
}
|
||||
@@ -683,6 +695,8 @@ export type RuntimeSettingsPayload = {
|
||||
barkUrl?: string;
|
||||
webhookEnabled?: boolean;
|
||||
barkEnabled?: boolean;
|
||||
feishuEnabled?: boolean;
|
||||
feishuWebhook?: string;
|
||||
serverChanEnabled?: boolean;
|
||||
serverChanKey?: string;
|
||||
telegramEnabled?: boolean;
|
||||
@@ -775,6 +789,9 @@ export type ProxyLogListItem = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
contentSummary?: string | null;
|
||||
requestContent?: string | null;
|
||||
responseContent?: string | null;
|
||||
};
|
||||
|
||||
export type ProxyLogDetail = ProxyLogListItem & {
|
||||
@@ -1083,6 +1100,13 @@ export type WorkspaceAssetsResponse = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type WorkspaceBatchDeleteResponse = {
|
||||
success: boolean;
|
||||
deletedCount: number;
|
||||
deletedIds: string[];
|
||||
failedIds: string[];
|
||||
};
|
||||
|
||||
export type WorkspaceAssetQuery = {
|
||||
source?: string;
|
||||
kind?: string;
|
||||
@@ -1142,6 +1166,69 @@ export type WorkflowSavePayload = {
|
||||
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 = {
|
||||
// Sites
|
||||
getSites: async () => {
|
||||
@@ -1704,43 +1791,6 @@ export const api = {
|
||||
method: "PUT",
|
||||
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) =>
|
||||
request("/api/settings/system-proxy/test", {
|
||||
method: "POST",
|
||||
@@ -2000,6 +2050,11 @@ export const api = {
|
||||
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
||||
deleteWorkspaceAsset: (publicId: string) =>
|
||||
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: () =>
|
||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
@@ -2020,6 +2075,36 @@ export const api = {
|
||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
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 = {
|
||||
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 }) =>
|
||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||
getSummary: () => userRequest("/api/user/summary"),
|
||||
getDashboard: () => userRequest("/api/user/dashboard"),
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
@@ -2069,6 +2155,11 @@ export const userApi = {
|
||||
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
||||
deleteWorkspaceAsset: (publicId: string) =>
|
||||
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: () =>
|
||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||
@@ -2089,4 +2180,34 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||
getWorkflowRun: (publicId: string) =>
|
||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
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" }),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,10 @@ import { clearSession } from './authSession.js';
|
||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||
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 LEGACY_FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||
|
||||
type StorageLike = {
|
||||
getItem?: (key: string) => string | null;
|
||||
@@ -24,5 +27,7 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
||||
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
||||
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
type MotionVariant = 'page' | 'login';
|
||||
|
||||
interface MotionDivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
motionKey?: string;
|
||||
motionVariant?: MotionVariant;
|
||||
}
|
||||
|
||||
const PAGE_TARGET_SELECTOR = [
|
||||
':scope > .console-hero',
|
||||
':scope > .page-header',
|
||||
':scope > .card',
|
||||
':scope > .mobile-card',
|
||||
':scope > .workflow-shell',
|
||||
':scope > .picture-agent-shell',
|
||||
':scope > .workspace-layout',
|
||||
':scope > .dashboard-grid',
|
||||
':scope > .digital-human-layout',
|
||||
':scope > .money-printer-layout',
|
||||
':scope > .about-page',
|
||||
].join(', ');
|
||||
|
||||
const LOGIN_TARGET_SELECTOR = [
|
||||
'.login-brand-header',
|
||||
'.login-brand-copy-block',
|
||||
'.login-compat-line',
|
||||
'.login-capability-row',
|
||||
'.login-auth-eyebrow',
|
||||
'.login-auth-title',
|
||||
'.login-auth-copy',
|
||||
'.login-mode-tabs',
|
||||
'.login-auth-panel form',
|
||||
'.login-auth-footer',
|
||||
].join(', ');
|
||||
|
||||
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] {
|
||||
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
|
||||
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18);
|
||||
}
|
||||
|
||||
export default function MotionDiv({
|
||||
children,
|
||||
className,
|
||||
motionKey,
|
||||
motionVariant = 'page',
|
||||
...props
|
||||
}: MotionDivProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedMotionKey = motionKey || motionVariant;
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add(
|
||||
{
|
||||
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||
isDesktop: '(min-width: 900px)',
|
||||
},
|
||||
(context) => {
|
||||
const conditions = context.conditions || {};
|
||||
if (conditions.reduceMotion) {
|
||||
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targets = selectMotionTargets(root, motionVariant);
|
||||
const distance = motionVariant === 'login' ? 16 : 10;
|
||||
|
||||
const rootTween = gsap.fromTo(
|
||||
root,
|
||||
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.46 : 0.34,
|
||||
ease: 'power2.out',
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
);
|
||||
|
||||
const targetTween = targets.length
|
||||
? gsap.fromTo(
|
||||
targets,
|
||||
{ autoAlpha: 0, y: distance },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
duration: motionVariant === 'login' ? 0.58 : 0.42,
|
||||
ease: 'power2.out',
|
||||
stagger: motionVariant === 'login' ? 0.045 : 0.025,
|
||||
delay: 0.04,
|
||||
clearProps: 'transform,opacity,visibility',
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
return () => {
|
||||
rootTween.kill();
|
||||
targetTween?.kill();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return () => mm.revert();
|
||||
}, [resolvedMotionKey, motionVariant]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['motion-root', className].filter(Boolean).join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,11 @@ interface ModelAnalysisPanelProps {
|
||||
data?: ModelAnalysisData | null;
|
||||
}
|
||||
|
||||
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
|
||||
{ key: 'spend', label: '消耗分布', icon: '💰' },
|
||||
{ key: 'trend', label: '消耗趋势', icon: '📈' },
|
||||
{ key: 'calls', label: '调用分布', icon: '🔄' },
|
||||
{ key: 'rank', label: '排行榜', icon: '🏆' },
|
||||
const tabs: Array<{ key: TabKey; label: string; code: string }> = [
|
||||
{ key: 'spend', label: '消耗分布', code: 'SP' },
|
||||
{ key: 'trend', label: '消耗趋势', code: 'TR' },
|
||||
{ key: 'calls', label: '调用分布', code: 'CL' },
|
||||
{ key: 'rank', label: '排行榜', code: 'RK' },
|
||||
];
|
||||
|
||||
function toSafeNumber(value: unknown): number {
|
||||
@@ -46,6 +46,13 @@ function formatPercent(value: number): string {
|
||||
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() {
|
||||
return (
|
||||
<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 hasData = totals.calls > 0
|
||||
|| spendDistribution.length > 0
|
||||
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
|
||||
|| spendDistribution.some((item) => toSafeNumber(item.spend) > 0)
|
||||
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0)
|
||||
|| callsDistribution.some((item) => toSafeNumber(item.calls) > 0);
|
||||
|
||||
const spendBarSpec = useMemo(() => ({
|
||||
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() }],
|
||||
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' }] } } },
|
||||
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 }],
|
||||
animation: true, background: 'transparent',
|
||||
}), [spendDistribution, labelColor]);
|
||||
@@ -102,7 +110,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
valueField: 'calls', categoryField: 'model',
|
||||
outerRadius: 0.8, innerRadius: 0.55,
|
||||
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 },
|
||||
animation: true,
|
||||
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' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
<span className="pill-tab-code">{tab.code}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export {
|
||||
stripCommonWrappers,
|
||||
type BrandInfo,
|
||||
type BrandMatchContext,
|
||||
} from '../../server/shared/modelBrand.js';
|
||||
} from '../../shared/modelBrand.js';
|
||||
|
||||
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
||||
anthropic: 'claude-color',
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
|
||||
interface SiteDistributionData {
|
||||
siteName: string;
|
||||
platform: string;
|
||||
totalBalance: number;
|
||||
totalSpend: number;
|
||||
accountCount: number;
|
||||
platform?: string | null;
|
||||
totalBalance?: number;
|
||||
totalSpend?: number;
|
||||
accountCount?: number;
|
||||
spend?: number;
|
||||
calls?: number;
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
interface SiteDistributionChartProps {
|
||||
@@ -26,6 +29,13 @@ function safeNumber(value: unknown): number {
|
||||
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() {
|
||||
return (
|
||||
<div
|
||||
@@ -98,8 +108,11 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
return data.map((item) => ({
|
||||
siteName: item.siteName,
|
||||
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),
|
||||
calls: safeNumber(item.calls),
|
||||
}));
|
||||
}, [data, viewMode]);
|
||||
|
||||
@@ -118,7 +131,12 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
outerRadius: 0.8,
|
||||
innerRadius: 0.55,
|
||||
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 },
|
||||
tooltip: {
|
||||
mark: {
|
||||
@@ -149,6 +167,13 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
return String(item.accountCount || 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '调用',
|
||||
value: (datum: unknown) => {
|
||||
const item = coerceDatumRecord(datum);
|
||||
return String(item.calls || 0);
|
||||
},
|
||||
},
|
||||
] as any,
|
||||
},
|
||||
},
|
||||
@@ -159,6 +184,7 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
||||
}, [chartData, hasData, labelColor]);
|
||||
|
||||
const formatValue = (value: number): string => {
|
||||
if (viewMode === 'balance' && value === 0) return '$0.00';
|
||||
if (value >= 1000) return `$${value.toFixed(2)}`;
|
||||
if (value >= 1) return `$${value.toFixed(3)}`;
|
||||
return `$${value.toFixed(6)}`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -7,7 +8,10 @@ import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
interface SiteTrendData {
|
||||
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 {
|
||||
@@ -43,20 +47,26 @@ const COLOR_PALETTE = [
|
||||
|
||||
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
const [metric, setMetric] = useState<Metric>('spend');
|
||||
const labelColor = useThemeLabelColor();
|
||||
|
||||
/* ---------- data transform ---------- */
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
return data.flatMap((d) =>
|
||||
Object.entries(d.sites).map(([site, v]) => ({
|
||||
return data.flatMap((d) => {
|
||||
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,
|
||||
site,
|
||||
value: metric === 'spend' ? v.spend : v.calls,
|
||||
})),
|
||||
);
|
||||
value: metric === 'spend' ? Number(v.spend ?? 0) : Number(v.calls ?? 0),
|
||||
}));
|
||||
});
|
||||
}, [data, metric]);
|
||||
|
||||
const hasPositiveValue = flatData.some((item) => Number(item.value || 0) > 0);
|
||||
|
||||
/* ---------- loading state ---------- */
|
||||
|
||||
if (loading) {
|
||||
@@ -72,7 +82,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
|
||||
/* ---------- empty state ---------- */
|
||||
|
||||
if (!data || data.length === 0 || flatData.length === 0) {
|
||||
if (!data || data.length === 0 || flatData.length === 0 || !hasPositiveValue) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
@@ -80,7 +90,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
</div>
|
||||
<div className="empty-state" style={{ padding: 48 }}>
|
||||
<div className="empty-state-title">暂无趋势数据</div>
|
||||
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
|
||||
<div className="empty-state-desc">所选时间范围内还没有有效的{metric === 'spend' ? '消耗' : '调用'}记录</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -144,22 +154,38 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||
axes: [
|
||||
{
|
||||
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)' } },
|
||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
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 },
|
||||
},
|
||||
],
|
||||
color: COLOR_PALETTE,
|
||||
background: 'transparent',
|
||||
padding: { left: 8, right: 16, top: 8, bottom: 8 },
|
||||
padding: { left: 48, right: 18, top: 12, bottom: 36 },
|
||||
};
|
||||
|
||||
/* ---------- render ---------- */
|
||||
|
||||
@@ -8,11 +8,12 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const targetWindow = window;
|
||||
const query = breakpoint === MOBILE_BREAKPOINT
|
||||
? MOBILE_MEDIA_QUERY
|
||||
: `(max-width: ${breakpoint}px)`;
|
||||
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
|
||||
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
|
||||
const media = typeof targetWindow.matchMedia === 'function' ? targetWindow.matchMedia(query) : null;
|
||||
const update = () => setIsMobile(media ? media.matches : targetWindow.innerWidth <= breakpoint);
|
||||
update();
|
||||
|
||||
if (media && typeof media.addEventListener === 'function') {
|
||||
@@ -24,14 +25,14 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
|
||||
};
|
||||
}
|
||||
|
||||
const addResizeListener = typeof window.addEventListener === 'function';
|
||||
const removeResizeListener = typeof window.removeEventListener === 'function';
|
||||
const addResizeListener = typeof targetWindow.addEventListener === 'function';
|
||||
const removeResizeListener = typeof targetWindow.removeEventListener === 'function';
|
||||
if (!addResizeListener) return;
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
targetWindow.addEventListener('resize', update);
|
||||
return () => {
|
||||
if (removeResizeListener) {
|
||||
window.removeEventListener('resize', update);
|
||||
targetWindow.removeEventListener('resize', update);
|
||||
}
|
||||
};
|
||||
}, [breakpoint]);
|
||||
|
||||
@@ -180,6 +180,18 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'个模型': 'models',
|
||||
'个人信息': 'personal information',
|
||||
'个人信息已保存': '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',
|
||||
'个账号': 'accounts',
|
||||
'跟随系统': '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}',
|
||||
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
|
||||
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
|
||||
'清除图片头像': 'Clear image avatar',
|
||||
'换一个随机头像': 'Change a random avatar',
|
||||
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
|
||||
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
|
||||
@@ -468,6 +481,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'收件人地址': 'Recipient address',
|
||||
'收起': 'close',
|
||||
'收起侧边栏': 'Collapse sidebar',
|
||||
'展开侧边栏': 'Expand sidebar',
|
||||
'收起高级参数': 'Close advanced parameters',
|
||||
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
|
||||
'手动增改路由': 'Manually add and modify routes',
|
||||
|
||||
+82
-1
@@ -7,6 +7,9 @@ const LANGUAGE_STORAGE_KEY = 'app_language';
|
||||
|
||||
const zhToEn: Record<string, string> = {
|
||||
'管理员': 'Admin',
|
||||
'管理控制台': 'Admin Console',
|
||||
'用户工作台': 'User Workspace',
|
||||
'账户菜单': 'Account Menu',
|
||||
'登录令牌无效': 'Invalid admin token',
|
||||
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
|
||||
'当前识别到的管理端 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.',
|
||||
'验证中...': 'Verifying...',
|
||||
'登录': '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',
|
||||
'把分散的 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',
|
||||
@@ -26,10 +37,24 @@ const zhToEn: Record<string, string> = {
|
||||
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
||||
'个人信息': 'Profile',
|
||||
'右上角头像实时预览': 'Top-right avatar live preview',
|
||||
'头像实时预览': 'Avatar live preview',
|
||||
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
|
||||
'用户名': 'Username',
|
||||
'例如:小王': '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:',
|
||||
'头像 · 风格:': 'Avatar · Style: ',
|
||||
'换一个随机头像': 'Randomize Avatar',
|
||||
'清除图片头像': 'Clear Image Avatar',
|
||||
'取消': 'Cancel',
|
||||
'保存': 'Save',
|
||||
'关闭': 'Close',
|
||||
@@ -85,6 +110,62 @@ const zhToEn: Record<string, string> = {
|
||||
'模型测试': 'Model Testing',
|
||||
'关于': 'About',
|
||||
'关于 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',
|
||||
'任务状态已更新': 'Task status updated',
|
||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||
@@ -102,6 +183,7 @@ const zhToEn: Record<string, string> = {
|
||||
'深色模式': 'Dark Mode',
|
||||
'退出登录': 'Sign Out',
|
||||
'收起侧边栏': 'Collapse Sidebar',
|
||||
'展开侧边栏': 'Expand Sidebar',
|
||||
'系统设置': 'System Settings',
|
||||
'站点管理': 'Site Management',
|
||||
'账号管理': 'Connection Management',
|
||||
@@ -321,7 +403,6 @@ const zhToEn: Record<string, string> = {
|
||||
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
|
||||
'零配置嵌入式数据库': 'Zero-config embedded database',
|
||||
'项目链接': '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.',
|
||||
|
||||
// User SaaS translations
|
||||
|
||||
+6298
-110
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -3,12 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<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>
|
||||
<body class="min-h-screen">
|
||||
<script>
|
||||
|
||||
+299
-150
@@ -1,179 +1,328 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
const VERSION = '1.3.0';
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: 'GW', title: '统一模型网关', desc: '一个入口聚合 OpenAI / Claude / Gemini / 方舟等兼容上游,统一鉴权、计费与日志。' },
|
||||
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
||||
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
||||
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
||||
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
|
||||
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
||||
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
|
||||
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
||||
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
|
||||
const HERO_TAGS = [
|
||||
'Unified API Gateway',
|
||||
'Intelligent Routing',
|
||||
'Cloud Workflow',
|
||||
'Workspace Archive',
|
||||
'Multimodal Agents',
|
||||
];
|
||||
|
||||
const TECH_STACK = [
|
||||
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
|
||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' },
|
||||
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
||||
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
||||
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
||||
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
|
||||
const HERO_SIGNALS = [
|
||||
{ value: 'OpenAI', label: '兼容协议' },
|
||||
{ value: 'Claude', label: '消息入口' },
|
||||
{ value: 'Comfy', label: '工作流前端' },
|
||||
];
|
||||
|
||||
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 = [
|
||||
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
|
||||
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
|
||||
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' },
|
||||
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
|
||||
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
|
||||
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
||||
{ path: '/v1/chat/completions', desc: 'OpenAI 对话兼容入口,支持流式与非流式。' },
|
||||
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求和事件。' },
|
||||
{ path: '/v1/responses', desc: 'Responses API 入口,适配支持该协议的上游。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成入口,供生图助手与工作流节点调用。' },
|
||||
{ path: '/v1/videos', desc: '视频任务创建入口,可接火山方舟或 Comfy 视频模板。' },
|
||||
{ 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() {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header" style={{ marginBottom: 14 }}>
|
||||
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
|
||||
</div>
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
{/* Hero card */}
|
||||
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="BoosAPI"
|
||||
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
|
||||
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 (
|
||||
<div ref={rootRef} className="about-page">
|
||||
<section className="about-hero about-motion-item">
|
||||
<div className="about-hero-copy-block">
|
||||
<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>
|
||||
|
||||
<h1>{tr('一个平台,接住模型、工作流和多模态生产。')}</h1>
|
||||
<p>
|
||||
{tr('BossAI 把多站点账号、模型路由、兼容 API、ComfyUI 工作流、创作助手和普通用户工作空间收进同一个自托管控制台。')}
|
||||
</p>
|
||||
|
||||
<div className="about-proof-row" aria-label={tr('核心兼容能力')}>
|
||||
{HERO_SIGNALS.map((item) => (
|
||||
<div key={item.value}>
|
||||
<strong>{item.value}</strong>
|
||||
<span>{tr(item.label)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="about-chip-row">
|
||||
{HERO_TAGS.map((tag) => (
|
||||
<span key={tag} className="about-chip">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{['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>
|
||||
|
||||
{/* Features */}
|
||||
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||
gap: 10,
|
||||
}}>
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.title} style={{
|
||||
display: 'flex', gap: 10, padding: '8px 0',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
<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 style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
|
||||
{tr(f.desc)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-platform-card" aria-label={tr('平台运行结构')}>
|
||||
<div className="about-version">{tr('当前版本')} v{VERSION}</div>
|
||||
<div className="about-orbit">
|
||||
<div className="about-orbit-core">
|
||||
<img src="/logo.png" alt="" />
|
||||
<span>BossAI</span>
|
||||
</div>
|
||||
<div className="about-orbit-card orbit-card-1">API Gateway</div>
|
||||
<div className="about-orbit-card orbit-card-2">Route Engine</div>
|
||||
<div className="about-orbit-card orbit-card-3">Workflow UI</div>
|
||||
<div className="about-orbit-card orbit-card-4">Agent Apps</div>
|
||||
</div>
|
||||
<div className="about-signal-grid" aria-label={tr('平台能力概览')}>
|
||||
<div>
|
||||
<strong>7</strong>
|
||||
<span>{tr('兼容入口')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>5</strong>
|
||||
<span>{tr('工作流后端')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>1</strong>
|
||||
<span>{tr('自托管服务')}</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3>
|
||||
<div style={{ display: 'grid', gap: 9 }}>
|
||||
{ENDPOINTS.map((endpoint) => (
|
||||
<div
|
||||
key={endpoint.path}
|
||||
style={{
|
||||
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)}
|
||||
<section className="about-flow about-motion-item" aria-label={tr('请求处理路径')}>
|
||||
<div className="about-flow-step">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
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)}
|
||||
<section className="about-section about-api-section">
|
||||
<div className="about-section-heading">
|
||||
<h2>{tr('兼容入口')}</h2>
|
||||
</div>
|
||||
<div className="about-definition-list">
|
||||
{ENDPOINTS.map((endpoint) => (
|
||||
<div key={endpoint.path} className="about-definition-row about-endpoint-row">
|
||||
<code>{endpoint.path}</code>
|
||||
<div>{tr(endpoint.desc)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</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 会被保护,后台列表中只展示脱敏值。')}
|
||||
<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 className="about-section about-privacy">
|
||||
<h2>{tr('数据与隐私')}</h2>
|
||||
<p>
|
||||
{tr('BossAI 完全自托管。账号、令牌、路由、日志、用户资产和工作台状态都保存在你的运行环境中,系统 Key 默认脱敏并受保护。')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1339,7 +1339,7 @@ export default function Accounts() {
|
||||
: canAddVerifiedConnection;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="animate-fade-in admin-ops-page accounts-ops-page">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr("连接管理")}</h2>
|
||||
{activeSegment !== "tokens" && (
|
||||
|
||||
@@ -112,6 +112,9 @@ const QUICK_PROMPTS = [
|
||||
'根据已有角色和场景,继续生成 ComfyUI 工作流 JSON',
|
||||
];
|
||||
|
||||
const STREAMING_REASONING_PLACEHOLDER = '正在等待模型返回真实思考过程,收到后会实时替换这里。';
|
||||
const STREAMING_BODY_PLACEHOLDER = '模型正在组织正文和素材步骤...';
|
||||
|
||||
const SEEDANCE_PROMPT_TEMPLATES = [
|
||||
{
|
||||
title: '文生视频',
|
||||
@@ -651,7 +654,7 @@ function renderMarkdown(content: string): string {
|
||||
}
|
||||
|
||||
function processThinkTags(content: string, reasoningContent = '') {
|
||||
if (!content.includes('<think>')) {
|
||||
if (!content || !/<think>/i.test(content)) {
|
||||
return { content, reasoningContent };
|
||||
}
|
||||
const thoughts: string[] = [];
|
||||
@@ -665,7 +668,18 @@ function processThinkTags(content: string, reasoningContent = '') {
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
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');
|
||||
return {
|
||||
content: cleanContent,
|
||||
@@ -821,7 +835,7 @@ export default function ComfyUIAgent() {
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
activeConversationIdRef.current = activeConversationId;
|
||||
@@ -1351,9 +1365,16 @@ export default function ComfyUIAgent() {
|
||||
const hasReasoning = processed.reasoningContent.trim().length > 0;
|
||||
if (!hasReasoning && !isStreaming) return null;
|
||||
return (
|
||||
<details open={isStreaming} style={reasoningBoxStyle}>
|
||||
<details
|
||||
open={isStreaming}
|
||||
style={{
|
||||
...reasoningBoxStyle,
|
||||
...(!hasReasoning && isStreaming ? reasoningPendingBoxStyle : {}),
|
||||
}}
|
||||
>
|
||||
<summary style={reasoningSummaryStyle}>
|
||||
{isStreaming ? '思考中...' : '思考过程'}
|
||||
<span>思考过程</span>
|
||||
{isStreaming ? <em style={reasoningLiveBadgeStyle}>实时</em> : null}
|
||||
</summary>
|
||||
{hasReasoning ? (
|
||||
<div
|
||||
@@ -1361,8 +1382,9 @@ export default function ComfyUIAgent() {
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.reasoningContent) }}
|
||||
/>
|
||||
) : (
|
||||
<div style={reasoningContentStyle}>
|
||||
正在分析你的需求、拆解角色、场景、镜头与后续工作流步骤...
|
||||
<div style={reasoningPendingStyle}>
|
||||
<span style={reasoningPulseDotStyle} />
|
||||
<span>{STREAMING_REASONING_PLACEHOLDER}</span>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
@@ -1717,6 +1739,9 @@ export default function ComfyUIAgent() {
|
||||
{renderReasoning(streaming, true)}
|
||||
{streaming.content ? renderMessageContent(streaming) : 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)}
|
||||
<div style={messageActionsStyle}>
|
||||
<button
|
||||
@@ -2127,6 +2152,9 @@ const reasoningSummaryStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
const reasoningContentStyle: CSSProperties = {
|
||||
@@ -2136,6 +2164,57 @@ const reasoningContentStyle: CSSProperties = {
|
||||
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 = {
|
||||
padding: 16,
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
|
||||
+229
-74
@@ -15,13 +15,22 @@ const SiteTrendChart = lazy(
|
||||
() => 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 {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return "🌙 夜深了";
|
||||
if (hour < 11) return "☀️ 早上好";
|
||||
if (hour < 13) return "👋 中午好";
|
||||
if (hour < 18) return "🌤️ 下午好";
|
||||
return "🌙 晚上好";
|
||||
if (hour < 6) return "夜深了";
|
||||
if (hour < 11) return "早上好";
|
||||
if (hour < 13) return "中午好";
|
||||
if (hour < 18) return "下午好";
|
||||
return "晚上好";
|
||||
}
|
||||
|
||||
function safeNumber(value: unknown): number {
|
||||
@@ -88,7 +97,7 @@ function formatAvailabilityPercent(value: number | null | undefined): string {
|
||||
Number.isNaN(value) ||
|
||||
!Number.isFinite(value)
|
||||
)
|
||||
return "—";
|
||||
return "-";
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
@@ -233,6 +242,7 @@ export default function Dashboard({
|
||||
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
||||
const [siteLoading, setSiteLoading] = useState(true);
|
||||
const [sites, setSites] = useState<any[]>([]);
|
||||
const [chartsReady, setChartsReady] = useState(false);
|
||||
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
||||
Record<string, SiteSpeedState>
|
||||
>({});
|
||||
@@ -318,6 +328,8 @@ export default function Dashboard({
|
||||
loadSiteStats();
|
||||
}, [loadSiteStats]);
|
||||
|
||||
useEffect(() => scheduleDashboardIdleWork(() => setChartsReady(true)), []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let disposed = false;
|
||||
@@ -526,6 +538,111 @@ export default function Dashboard({
|
||||
const siteAvailability = showInactiveSites
|
||||
? [...activeSites, ...inactiveSites]
|
||||
: 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) =>
|
||||
ms <= 500
|
||||
@@ -576,19 +693,28 @@ export default function Dashboard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<h2 className="greeting">
|
||||
{getGreeting() + "\uFF0C" + normalizedAdminName}
|
||||
</h2>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div className="dashboard-page animate-fade-in">
|
||||
<section className="console-hero dashboard-hero">
|
||||
<div className="console-hero-copy">
|
||||
<span className="console-kicker">BossAI Console</span>
|
||||
<h1>{getGreeting()},{normalizedAdminName}</h1>
|
||||
<p>统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。</p>
|
||||
</div>
|
||||
<div className="console-hero-rail" aria-label="实时运营摘要">
|
||||
<div>
|
||||
<span>24h 请求</span>
|
||||
<strong>{Math.round(proxy24hTotal).toLocaleString()}</strong>
|
||||
</div>
|
||||
<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
|
||||
onClick={() => {
|
||||
void load(true);
|
||||
@@ -619,7 +745,62 @@ export default function Dashboard({
|
||||
</svg>
|
||||
</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 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="stat-card animate-slide-up stagger-1">
|
||||
@@ -969,25 +1150,8 @@ export default function Dashboard({
|
||||
</div>
|
||||
|
||||
{/* 站点级分析 */}
|
||||
<div
|
||||
style={{
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<div className="dashboard-section-head">
|
||||
<div className="dashboard-section-title">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
@@ -1004,24 +1168,12 @@ export default function Dashboard({
|
||||
</svg>
|
||||
站点分析
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<div className="dashboard-range-tabs">
|
||||
{[7, 30, 90].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setTrendDays(d)}
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
className={`dashboard-range-tab ${trendDays === d ? "is-active" : ""}`}
|
||||
>
|
||||
{d}天
|
||||
</button>
|
||||
@@ -1029,26 +1181,27 @@ export default function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div className="dashboard-chart-grid">
|
||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteDistributionChart
|
||||
data={siteDistribution}
|
||||
loading={siteLoading}
|
||||
/>
|
||||
</Suspense>
|
||||
{chartsReady ? (
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteDistributionChart
|
||||
data={siteDistribution}
|
||||
loading={siteLoading}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ChartFallback height={320} />
|
||||
)}
|
||||
</div>
|
||||
<div className="chart-panel-enter animate-slide-up stagger-7">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||
</Suspense>
|
||||
{chartsReady ? (
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<ChartFallback height={320} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1190,7 +1343,7 @@ export default function Dashboard({
|
||||
>
|
||||
{site.averageLatencyMs != null
|
||||
? `${site.averageLatencyMs}ms`
|
||||
: "—"}
|
||||
: "暂无"}
|
||||
</span>
|
||||
<span className="site-observability-metric-sep">·</span>
|
||||
<span>{Math.round(site.totalRequests || 0)} 次</span>
|
||||
@@ -1216,7 +1369,7 @@ export default function Dashboard({
|
||||
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
|
||||
bucket.averageLatencyMs != null
|
||||
? `平均响应:${bucket.averageLatencyMs}ms`
|
||||
: "平均响应:—",
|
||||
: "平均响应:暂无",
|
||||
].join(" · ")}
|
||||
data-tooltip-align="start"
|
||||
title={[
|
||||
@@ -1227,7 +1380,7 @@ export default function Dashboard({
|
||||
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
||||
bucket.averageLatencyMs != null
|
||||
? `平均响应 ${bucket.averageLatencyMs}ms`
|
||||
: "平均响应 —",
|
||||
: "平均响应 暂无",
|
||||
].join(" | ")}
|
||||
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
|
||||
/>
|
||||
@@ -1293,6 +1446,8 @@ export default function Dashboard({
|
||||
</div>
|
||||
{insightsLoading && !insightsData ? (
|
||||
<ChartFallback height={260} />
|
||||
) : !chartsReady ? (
|
||||
<ChartFallback height={260} />
|
||||
) : (
|
||||
<Suspense fallback={<ChartFallback height={260} />}>
|
||||
<ModelAnalysisPanel data={insightsData?.modelAnalysis} />
|
||||
@@ -1585,7 +1740,7 @@ export default function Dashboard({
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
{proxy24hTotal > 0
|
||||
? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}`
|
||||
: "—"}
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+605
-272
@@ -1,103 +1,356 @@
|
||||
import React, { CSSProperties, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
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 }> = [
|
||||
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
|
||||
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
|
||||
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
|
||||
const DEFAULT_MAPPING = {
|
||||
prompt: { nodeId: '', field: 'prompt' },
|
||||
script: { nodeId: '', field: 'text' },
|
||||
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 = [
|
||||
'longcat-video-avatar',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'wan2-1-14b-i2v-250225',
|
||||
];
|
||||
function terminal(status: string) {
|
||||
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
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) {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!text) return `HTTP ${response.status}`;
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return value?.error?.message || value?.message || text;
|
||||
} catch {
|
||||
return text;
|
||||
function parseError(response: Response) {
|
||||
return response.text().then((text) => {
|
||||
if (!text) return `HTTP ${response.status}`;
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return value?.error?.message || value?.message || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function graphMapping(workflow?: WorkflowSummary | null) {
|
||||
return workflow?.graph?.metadata?.digitalHuman?.nodeMapping
|
||||
|| workflow?.graph?.digitalHuman?.nodeMapping
|
||||
|| DEFAULT_MAPPING;
|
||||
}
|
||||
|
||||
function mappingValue(mapping: any, key: MappingKey) {
|
||||
const value = mapping?.[key] || DEFAULT_MAPPING[key];
|
||||
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 extractTaskId(value: any): string {
|
||||
return value?.response?.id
|
||||
|| value?.response?.task_id
|
||||
|| value?.response?.data?.id
|
||||
|| value?.response?.data?.task_id
|
||||
|| value?.id
|
||||
|| value?.task_id
|
||||
|| '';
|
||||
function includesAny(value: string, words: string[]) {
|
||||
const text = value.toLowerCase();
|
||||
return words.some((word) => text.includes(word));
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
if (!value) return;
|
||||
void navigator.clipboard?.writeText(value);
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const [mode, setMode] = useState<Mode>('audio');
|
||||
const [model, setModel] = useState(MODEL_PRESETS[0]);
|
||||
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
|
||||
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
|
||||
const [resolution, setResolution] = useState('720p');
|
||||
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
||||
const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
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 [duration, setDuration] = useState(8);
|
||||
const [refImageIndex, setRefImageIndex] = useState(0);
|
||||
const [audioCfg, setAudioCfg] = useState(4.5);
|
||||
const [segments, setSegments] = useState(false);
|
||||
const [multiPerson, setMultiPerson] = useState(false);
|
||||
const [resolution, setResolution] = useState('1080p');
|
||||
const [subtitles, setSubtitles] = useState(true);
|
||||
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 [result, setResult] = useState<any>(null);
|
||||
|
||||
const bodyPreview = useMemo(() => ({
|
||||
model: model.trim(),
|
||||
prompt: [
|
||||
prompt.trim(),
|
||||
script.trim() ? `台词:${script.trim()}` : '',
|
||||
`风格:${voiceStyle}`,
|
||||
].filter(Boolean).join('\n'),
|
||||
mode,
|
||||
ratio,
|
||||
resolution,
|
||||
duration,
|
||||
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
|
||||
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
|
||||
audio_cfg: audioCfg,
|
||||
ref_img_index: refImageIndex,
|
||||
segmented: segments,
|
||||
multi_person: multiPerson,
|
||||
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
|
||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||
const sortedWorkflows = useMemo(() => {
|
||||
return [...workflows].sort((a, b) => {
|
||||
const score = (item: WorkflowSummary) => (templateHasMapping(item) ? 0 : 10)
|
||||
+ (item.isTemplate || item.visibility === 'template' ? 0 : item.visibility === 'public' ? 2 : 4);
|
||||
return score(a) - score(b) || a.name.localeCompare(b.name, 'zh-CN');
|
||||
});
|
||||
}, [workflows]);
|
||||
const mapped = useMemo(() => ({
|
||||
prompt: mappingValue(mapping, 'prompt'),
|
||||
script: mappingValue(mapping, 'script'),
|
||||
avatar: mappingValue(mapping, 'avatar'),
|
||||
referenceAudio: mappingValue(mapping, 'referenceAudio'),
|
||||
voiceReference: mappingValue(mapping, 'voiceReference'),
|
||||
output: mappingValue(mapping, 'output'),
|
||||
}), [mapping]);
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
const missingMapping = [
|
||||
['提示词节点', 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('');
|
||||
setResult(null);
|
||||
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',
|
||||
body: JSON.stringify(bodyPreview),
|
||||
timeoutMs: 300_000,
|
||||
body: form,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
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) {
|
||||
setError(err?.message || '数字人任务创建失败');
|
||||
} 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 (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">数字人</h1>
|
||||
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
|
||||
<div className="digital-human-page animate-fade-in">
|
||||
<section className="console-hero digital-human-hero">
|
||||
<div className="console-hero-copy">
|
||||
<span className="console-kicker">Digital Human</span>
|
||||
<h1>数字人商品讲解</h1>
|
||||
<p>选择 Comfy 工作流模板,上传人物图片或视频、商品文案和参考音频,后台生成真人口播讲解视频并归档到工作空间。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建数字人任务'}
|
||||
<div className="console-hero-rail" aria-label="数字人任务摘要">
|
||||
<div>
|
||||
<span>模板</span>
|
||||
<strong>{sortedWorkflows.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>任务</span>
|
||||
<strong>{jobs.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>音频用途</span>
|
||||
<strong>{AUDIO_ROLES.find((item) => item.value === audioRole)?.label || 'both'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-hero-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
||||
{submitting ? '提交中...' : '生成讲解视频'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
<div style={layoutStyle}>
|
||||
<section style={panelStyle}>
|
||||
<div style={modeGridStyle}>
|
||||
{MODE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setMode(item.value)}
|
||||
style={mode === item.value ? activeModeStyle : modeStyle}
|
||||
>
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="digital-human-layout">
|
||||
<section className="digital-human-panel">
|
||||
<div className="digital-human-section-head">
|
||||
<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
|
||||
key={workflow.publicId}
|
||||
type="button"
|
||||
className={`digital-human-template-card${active ? ' is-active' : ''}`}
|
||||
onClick={() => setTemplateId(workflow.publicId)}
|
||||
>
|
||||
<span className="digital-human-template-top">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</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 style={formGridStyle}>
|
||||
<label style={fieldStyle}>
|
||||
<span>模型</span>
|
||||
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
|
||||
<datalist id="digital-human-models">
|
||||
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
|
||||
</datalist>
|
||||
<h2>3. 商品文案</h2>
|
||||
<label className="digital-human-field">
|
||||
<span>讲解文案</span>
|
||||
<textarea value={productScript} onChange={(event) => setProductScript(event.target.value)} rows={6} />
|
||||
</label>
|
||||
<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 style={fieldStyle}>
|
||||
<label className="digital-human-field">
|
||||
<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="16:9">16:9 横屏</option>
|
||||
<option value="1:1">1:1 方屏</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<label className="digital-human-field">
|
||||
<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="1080p">1080p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>时长(秒)</span>
|
||||
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
|
||||
<label className="digital-human-checkbox">
|
||||
<input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
|
||||
<span>生成字幕</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={fieldStyle}>
|
||||
<span>人物/场景描述</span>
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>台词/脚本</span>
|
||||
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
|
||||
</label>
|
||||
|
||||
<div style={formGridStyle}>
|
||||
{mode !== 'text' && (
|
||||
<label style={fieldStyle}>
|
||||
<span>人物图 URL</span>
|
||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
)}
|
||||
{mode === 'audio' && (
|
||||
<label style={fieldStyle}>
|
||||
<span>音频 URL</span>
|
||||
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
)}
|
||||
<label style={fieldStyle}>
|
||||
<span>口播风格</span>
|
||||
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
|
||||
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>ref_img_index</span>
|
||||
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>audio CFG</span>
|
||||
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={toggleRowStyle}>
|
||||
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
|
||||
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside style={panelStyle}>
|
||||
<h2 style={sideTitleStyle}>任务请求</h2>
|
||||
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
|
||||
|
||||
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
|
||||
{result ? (
|
||||
<>
|
||||
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
|
||||
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||
<div className="empty-state-title">暂无任务</div>
|
||||
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
|
||||
<section className="digital-human-panel">
|
||||
<div className="digital-human-section-head">
|
||||
<div>
|
||||
<h2>节点映射</h2>
|
||||
<p>把页面输入写入对应 Comfy 节点。字段名可按模板节点输入口微调。</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>重新猜测</button>
|
||||
</div>
|
||||
<div className="digital-human-mapping-grid">
|
||||
{([
|
||||
['prompt', '提示词节点'],
|
||||
['script', '文案节点'],
|
||||
['avatar', '人物素材节点'],
|
||||
['referenceAudio', '参考音频节点'],
|
||||
['voiceReference', '音色参考节点(可选)'],
|
||||
['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>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<h2>任务队列</h2>
|
||||
<div className="digital-human-job-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={clear}>清空已完成</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getAccounts: vi.fn(),
|
||||
getAccountsSnapshot: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getDownstreamApiKeyOverview: vi.fn(),
|
||||
getDownstreamApiKeyTrend: 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([
|
||||
{
|
||||
id: 301,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user