Compare commits
3 Commits
main
..
0f9fe4a81d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f9fe4a81d | |||
| 6869504119 | |||
| 3f9f9f7665 |
@@ -66,29 +66,6 @@
|
|||||||
|
|
||||||
## 3. 生产环境 (Production)
|
## 3. 生产环境 (Production)
|
||||||
|
|
||||||
### 单二进制生产包
|
|
||||||
|
|
||||||
推荐用项目脚本生成生产包:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build-and-run.sh --production
|
|
||||||
```
|
|
||||||
|
|
||||||
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
boosapi
|
|
||||||
config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
启动前检查配置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./boosapi --check-config
|
|
||||||
./boosapi --migrate-only
|
|
||||||
./boosapi --config config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 推荐:Docker Compose 部署
|
### 推荐:Docker Compose 部署
|
||||||
|
|
||||||
1. **准备 docker-compose.yml**:
|
1. **准备 docker-compose.yml**:
|
||||||
@@ -184,54 +161,8 @@ config.toml
|
|||||||
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
||||||
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
||||||
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
||||||
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
|
|
||||||
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
|
|
||||||
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
|
|
||||||
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
||||||
|
|
||||||
### Workflow 后端配置
|
|
||||||
|
|
||||||
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
|
|
||||||
|
|
||||||
Comfy Cloud:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "cloud"
|
|
||||||
base_url = "https://cloud.comfy.org"
|
|
||||||
api_key = "your-comfy-cloud-api-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
RunningHub:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "runninghub"
|
|
||||||
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
|
||||||
api_key = "your-runninghub-api-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
外部完整版 ComfyUI:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "external"
|
|
||||||
base_url = "http://127.0.0.1:8188"
|
|
||||||
```
|
|
||||||
|
|
||||||
BoosAPI API-native 执行器:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "boosapi"
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
|
|
||||||
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
|
|
||||||
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 运维操作 (Ops)
|
## 5. 运维操作 (Ops)
|
||||||
@@ -248,8 +179,6 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
|
|||||||
cp data/boosapi.db data/boosapi.db.bak
|
cp data/boosapi.db data/boosapi.db.bak
|
||||||
```
|
```
|
||||||
|
|
||||||
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
|
|
||||||
|
|
||||||
### 查看日志
|
### 查看日志
|
||||||
如果使用 Systemd:
|
如果使用 Systemd:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# BossAI
|
# BoosAPI
|
||||||
|
|
||||||
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<br>
|
<br>
|
||||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||||
<br>
|
<br>
|
||||||
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
|
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。
|
||||||
<br>
|
<br>
|
||||||
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
||||||
</p>
|
</p>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
||||||
|
|
||||||
**BossAI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
||||||
|
|
||||||
支持的上游范围:
|
支持的上游范围:
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
|
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
|
||||||
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
|
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
|
||||||
|
|
||||||
| 痛点 | BossAI 怎么解决 |
|
| 痛点 | BoosAPI 怎么解决 |
|
||||||
|------|-----------------|
|
|------|-----------------|
|
||||||
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
||||||
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
||||||
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
||||||
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
||||||
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BossAI 模型路由 |
|
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
|
||||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||||
|
|
||||||
@@ -65,8 +65,7 @@
|
|||||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||||
- **场景生成**:自动为每个场景生成配图
|
- **场景生成**:自动为每个场景生成配图
|
||||||
- **TTS 语音合成**:对话台词自动转语音
|
- **TTS 语音合成**:对话台词自动转语音
|
||||||
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
|
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI
|
||||||
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
|
|
||||||
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
|
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
|
||||||
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
||||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||||
@@ -156,13 +155,13 @@
|
|||||||
|
|
||||||
## 🖥️ 硬件配置建议
|
## 🖥️ 硬件配置建议
|
||||||
|
|
||||||
BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
||||||
|
|
||||||
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
||||||
|----------|-----|------|------|-----|
|
|----------|-----|------|------|-----|
|
||||||
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
||||||
| BossAI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
||||||
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BossAI 机器不需要;ComfyUI 机器按模型需求配置 |
|
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
|
||||||
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
|
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
|
||||||
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
|
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
|
||||||
|
|
||||||
@@ -175,81 +174,7 @@ BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
|
|||||||
无需 GPU
|
无需 GPU
|
||||||
```
|
```
|
||||||
|
|
||||||
如果图像、视频、语音能力走 BossAI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
||||||
|
|
||||||
## 🎛️ Workflow API 后端模式
|
|
||||||
|
|
||||||
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
|
|
||||||
|
|
||||||
| backend | 用途 |
|
|
||||||
|---------|------|
|
|
||||||
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
|
|
||||||
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
|
|
||||||
| `external` | 代理你自己部署的完整版 ComfyUI |
|
|
||||||
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
|
|
||||||
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
|
|
||||||
|
|
||||||
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
|
|
||||||
|
|
||||||
配置示例:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "cloud"
|
|
||||||
base_url = "https://cloud.comfy.org"
|
|
||||||
api_key = "your-comfy-cloud-api-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "runninghub"
|
|
||||||
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
|
||||||
api_key = "your-runninghub-api-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "external"
|
|
||||||
base_url = "http://127.0.0.1:8188"
|
|
||||||
```
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "boosapi"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 将 Comfy 视频节点包装为 BossAI `/v1/videos`
|
|
||||||
|
|
||||||
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BossAI 可以把一个已保存的视频工作流模板包装成统一视频模型:
|
|
||||||
|
|
||||||
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 <BossAI 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 二进制。
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -371,11 +296,6 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
|
|||||||
|
|
||||||
# 前端静态目录(默认 dist/web)
|
# 前端静态目录(默认 dist/web)
|
||||||
BOOSAPI_WEB_DIR=dist/web
|
BOOSAPI_WEB_DIR=dist/web
|
||||||
|
|
||||||
# Workflow 后端
|
|
||||||
BOOSAPI_COMFYUI__BACKEND=cloud
|
|
||||||
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
|
|
||||||
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ 技术栈
|
## 🏗️ 技术栈
|
||||||
@@ -389,7 +309,7 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
|||||||
| 认证鉴权 | **JWT** + API Key 双模式 |
|
| 认证鉴权 | **JWT** + API Key 双模式 |
|
||||||
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
||||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||||
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
| Workflow Studio | API-native 工作流入口,后续承接节点画布 |
|
||||||
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
| 前端框架 | React 19 + Vite(仅构建工具) |
|
||||||
| 样式 | Tailwind CSS v4 |
|
| 样式 | Tailwind CSS v4 |
|
||||||
@@ -456,19 +376,6 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
|||||||
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||||
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
||||||
|
|
||||||
### 生产单二进制构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build-and-run.sh --production
|
|
||||||
```
|
|
||||||
|
|
||||||
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
boosapi
|
|
||||||
config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+147
-136
@@ -2,195 +2,206 @@
|
|||||||
|
|
||||||
# BoosAPI
|
# BoosAPI
|
||||||
|
|
||||||
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
|
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point**
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
||||||
<br>
|
<br>
|
||||||
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
|
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization.
|
||||||
<br>
|
<br>
|
||||||
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
|
<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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
|
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
|
||||||
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
|
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
|
||||||
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
|
|
||||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
|
|
||||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文</a> |
|
<a href="README.md">中文</a> |
|
||||||
<a href="README_EN.md"><strong>English</strong></a> |
|
<a href="README_EN.md"><strong>English</strong></a>
|
||||||
<a href="DEPLOY.md">Deployment</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Introduction
|
## 📖 Introduction
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Supported upstreams:
|
**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.
|
||||||
|
|
||||||
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
|
### Supported Upstreams
|
||||||
- **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
|
|
||||||
|
|
||||||
## Key Features
|
- **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
|
||||||
|
|
||||||
### Unified API Gateway
|
| 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 |
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### Workflow Studio
|
## ✨ Features
|
||||||
|
|
||||||
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
|
### 🎨 Built-in ComfyUI Chat Assistant
|
||||||
|
|
||||||
| backend | Purpose |
|
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline
|
||||||
|---------|---------|
|
- **Multi-view generation**: front/back/left/right directional image generation
|
||||||
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
|
- **Scene/video generation**: text description directly generates video content
|
||||||
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
|
- **SSE real-time streaming**: generation progress feedback
|
||||||
| `external` | Proxy a full ComfyUI instance you deploy yourself |
|
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
|
||||||
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
|
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
|
||||||
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
|
|
||||||
|
|
||||||
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.
|
### 🌐 Unified Proxy Gateway
|
||||||
|
|
||||||
### Assistants
|
- Compatible with **OpenAI** and **Claude** downstream formats
|
||||||
|
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
|
||||||
|
- SSE streaming with automatic format conversion
|
||||||
|
|
||||||
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
|
### 🧠 Smart Routing Engine
|
||||||
- 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
|
|
||||||
|
|
||||||
## Hardware Guide
|
- 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
|
||||||
|
|
||||||
| Mode | CPU | Memory | Disk | GPU |
|
### 📡 Multi-Platform Management
|
||||||
|------|-----|--------|------|-----|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
Recommended SaaS gateway server:
|
| Platform | Adapter |
|
||||||
|
|----------|---------|
|
||||||
|
| New API | `new-api` |
|
||||||
|
| One API | `one-api` |
|
||||||
|
| OneHub | `onehub` |
|
||||||
|
| DoneHub | `done-hub` |
|
||||||
|
| Veloera | `veloera` |
|
||||||
|
| AnyRouter | `anyrouter` |
|
||||||
|
| Sub2API | `sub2api` |
|
||||||
|
|
||||||
```txt
|
### 👥 Account & Token Management
|
||||||
4 CPU cores
|
|
||||||
8GB RAM
|
|
||||||
50GB SSD
|
|
||||||
No GPU required
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
- 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
|
```bash
|
||||||
cp config.toml.example config.toml
|
mkdir boosapi && cd boosapi
|
||||||
./build-and-run.sh
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
For a production bundle with embedded web assets:
|
Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
||||||
|
|
||||||
```bash
|
---
|
||||||
./build-and-run.sh --production
|
|
||||||
```
|
|
||||||
|
|
||||||
The production output is intended to run with only:
|
## 🏗️ Tech Stack
|
||||||
|
|
||||||
```txt
|
|
||||||
boosapi
|
|
||||||
config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
|
|
||||||
|
|
||||||
## Workflow Backend Configuration
|
|
||||||
|
|
||||||
Comfy Cloud:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "cloud"
|
|
||||||
base_url = "https://cloud.comfy.org"
|
|
||||||
api_key = "your-comfy-cloud-api-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
RunningHub:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "runninghub"
|
|
||||||
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
|
||||||
api_key = "your-runninghub-api-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
External ComfyUI:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "external"
|
|
||||||
base_url = "http://127.0.0.1:8188"
|
|
||||||
```
|
|
||||||
|
|
||||||
BoosAPI API-native executor:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[comfyui]
|
|
||||||
backend = "boosapi"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BOOSAPI_SERVER__HOST=0.0.0.0
|
|
||||||
BOOSAPI_SERVER__PORT=4000
|
|
||||||
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
|
||||||
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
|
||||||
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
|
|
||||||
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Tech |
|
| Layer | Tech |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| Backend | Rust, Axum 0.8, Tokio |
|
| Backend | Fastify |
|
||||||
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
|
| Frontend | React 18 + Vite |
|
||||||
| Proxy engine | Token Router + Proxy Conductor |
|
| Language | TypeScript |
|
||||||
| Protocols | OpenAI / Claude / Gemini compatible transformers |
|
| Styling | Tailwind CSS v4 |
|
||||||
| Frontend | React 19 + Vite |
|
| Database | SQLite, with PostgreSQL migration preparation |
|
||||||
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
|
| Visualization | VChart |
|
||||||
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
|
| Container | Docker + Docker Compose |
|
||||||
| Container | Docker / Docker Compose |
|
| Test | Vitest |
|
||||||
|
|
||||||
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
|
---
|
||||||
|
|
||||||
## Local Development
|
## 🛠️ Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo check
|
npm install
|
||||||
cargo run
|
cargo run
|
||||||
pnpm install
|
npm run dev
|
||||||
pnpm run dev:web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Build checks:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo check
|
npm run build # Build web assets
|
||||||
pnpm run typecheck:web
|
npm test # Run tests
|
||||||
npx vite build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+8
-114
@@ -20,8 +20,6 @@ BACKEND_ONLY=0
|
|||||||
NO_RUN=0
|
NO_RUN=0
|
||||||
FORCE_FRONTEND=0
|
FORCE_FRONTEND=0
|
||||||
SKIP_FRONTEND=0
|
SKIP_FRONTEND=0
|
||||||
PRODUCTION_BUNDLE=0
|
|
||||||
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
|
||||||
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
||||||
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||||
|
|
||||||
@@ -34,28 +32,21 @@ usage() {
|
|||||||
--release 使用 release 模式编译后端(更慢但性能好)
|
--release 使用 release 模式编译后端(更慢但性能好)
|
||||||
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
|
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
|
||||||
--profile <name> 指定构建 profile:dev / release / fast-release
|
--profile <name> 指定构建 profile:dev / release / fast-release
|
||||||
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
|
|
||||||
--prod 同 --production
|
|
||||||
--production-dir <dir>
|
|
||||||
指定生产产物目录(默认:dist/production)
|
|
||||||
--frontend-only 只构建前端
|
--frontend-only 只构建前端
|
||||||
--backend-only 只构建后端
|
--backend-only 只构建后端
|
||||||
--force-frontend 强制重建前端,忽略缓存
|
--force-frontend 强制重建前端,忽略缓存
|
||||||
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
||||||
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
|
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块仍使用 pnpm)
|
||||||
--no-run 构建后不启动服务
|
--no-run 构建后不启动服务
|
||||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||||
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
||||||
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
||||||
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
|
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
./build-and-run.sh --release
|
./build-and-run.sh --release
|
||||||
./build-and-run.sh --fast-release
|
./build-and-run.sh --fast-release
|
||||||
./build-and-run.sh --bun
|
./build-and-run.sh --bun
|
||||||
./build-and-run.sh --production
|
|
||||||
./build-and-run.sh --production --production-dir /opt/boosapi-release
|
|
||||||
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -65,16 +56,6 @@ while [ $# -gt 0 ]; do
|
|||||||
--dev) BUILD_PROFILE="dev" ;;
|
--dev) BUILD_PROFILE="dev" ;;
|
||||||
--release) BUILD_PROFILE="release" ;;
|
--release) BUILD_PROFILE="release" ;;
|
||||||
--fast-release) BUILD_PROFILE="fast-release" ;;
|
--fast-release) BUILD_PROFILE="fast-release" ;;
|
||||||
--production|--prod)
|
|
||||||
PRODUCTION_BUNDLE=1
|
|
||||||
BUILD_PROFILE="release"
|
|
||||||
NO_RUN=1
|
|
||||||
;;
|
|
||||||
--production-dir)
|
|
||||||
shift
|
|
||||||
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
|
|
||||||
PRODUCTION_DIR="$1"
|
|
||||||
;;
|
|
||||||
--profile)
|
--profile)
|
||||||
shift
|
shift
|
||||||
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
||||||
@@ -97,14 +78,6 @@ if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
|||||||
fail "--frontend-only 和 --backend-only 不能同时使用"
|
fail "--frontend-only 和 --backend-only 不能同时使用"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
|
|
||||||
fail "--production 不能和 --frontend-only 同时使用"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
|
||||||
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$BUILD_PROFILE" in
|
case "$BUILD_PROFILE" in
|
||||||
dev|debug) BUILD_PROFILE="dev" ;;
|
dev|debug) BUILD_PROFILE="dev" ;;
|
||||||
release|fast-release) ;;
|
release|fast-release) ;;
|
||||||
@@ -193,36 +166,6 @@ stop_previous_server() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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() {
|
build_frontend() {
|
||||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
log "跳过前端构建(--skip-frontend)"
|
log "跳过前端构建(--skip-frontend)"
|
||||||
@@ -242,7 +185,12 @@ build_frontend() {
|
|||||||
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
|
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
install_frontend_dependencies "$frontend_pm"
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
|
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
|
||||||
|
else
|
||||||
|
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
|
||||||
|
command -v npx >/dev/null 2>&1 || fail "未找到 npx,请先安装 Node.js"
|
||||||
|
fi
|
||||||
|
|
||||||
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
||||||
local fingerprint
|
local fingerprint
|
||||||
@@ -275,36 +223,13 @@ build_frontend() {
|
|||||||
if [ "$frontend_pm" = "bun" ]; then
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
bunx --bun vite build
|
bunx --bun vite build
|
||||||
else
|
else
|
||||||
pnpm exec vite build
|
npx vite build
|
||||||
fi
|
fi
|
||||||
mkdir -p "$ROOT_DIR/dist/web"
|
mkdir -p "$ROOT_DIR/dist/web"
|
||||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||||
ok "前端构建完成 → dist/web/"
|
ok "前端构建完成 → dist/web/"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_workflow_ui_for_embedding() {
|
|
||||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
|
||||||
log "跳过 Workflow UI 构建检查(--skip-frontend)"
|
|
||||||
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}/"
|
|
||||||
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/"
|
|
||||||
}
|
|
||||||
|
|
||||||
build_backend() {
|
build_backend() {
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||||
@@ -339,29 +264,6 @@ build_backend() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
build_production_bundle() {
|
|
||||||
[ -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 前端已嵌入二进制。"
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_started_server() {
|
cleanup_started_server() {
|
||||||
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
|
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
|
||||||
log "停止本次启动的后端进程: $STARTED_PID"
|
log "停止本次启动的后端进程: $STARTED_PID"
|
||||||
@@ -380,18 +282,10 @@ fi
|
|||||||
|
|
||||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||||
build_frontend
|
build_frontend
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
|
||||||
ensure_workflow_ui_for_embedding
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build_backend
|
build_backend
|
||||||
|
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
|
||||||
build_production_bundle
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$NO_RUN" -eq 1 ]; then
|
if [ "$NO_RUN" -eq 1 ]; then
|
||||||
log "跳过启动(--no-run)"
|
log "跳过启动(--no-run)"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ RUN find /workspace/ComfyUI -mindepth 1 \
|
|||||||
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
||||||
|
|
||||||
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
||||||
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
|
|
||||||
|
|
||||||
RUN bash /tmp/install-runninghub-template-nodes.sh \
|
|
||||||
&& rm -f /tmp/install-runninghub-template-nodes.sh
|
|
||||||
|
|
||||||
RUN python - <<'PY'
|
RUN python - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
+1
-3
@@ -35,9 +35,7 @@
|
|||||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
|
||||||
"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 .",
|
"test": "vitest run --root .",
|
||||||
"test:watch": "vitest --root ."
|
"test:watch": "vitest --root ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
#!/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,34 +4,10 @@ import { spawn } from 'child_process';
|
|||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
||||||
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
|
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1';
|
||||||
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
|
|
||||||
const DEFAULT_CLOUD_REF = 'v1.46.2';
|
|
||||||
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
||||||
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
||||||
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
||||||
|
|
||||||
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
|
|
||||||
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
external: {
|
|
||||||
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|
|
||||||
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|
|
||||||
|| DEFAULT_EXTERNAL_REF,
|
|
||||||
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|
|
||||||
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
|
|
||||||
description: 'External/RunningHub compatible frontend',
|
|
||||||
},
|
|
||||||
cloud: {
|
|
||||||
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|
|
||||||
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|
|
||||||
|| DEFAULT_CLOUD_REF,
|
|
||||||
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|
|
||||||
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
|
|
||||||
description: 'Comfy Cloud frontend',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -49,54 +25,23 @@ function run(command, args, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedVariants() {
|
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
|
||||||
if (VARIANT === 'all') return Object.entries(variants);
|
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR;
|
||||||
if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
|
if (REPO) {
|
||||||
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildVariant(name, config) {
|
|
||||||
const ref = config.ref;
|
|
||||||
const dist = config.dist;
|
|
||||||
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
|
|
||||||
|
|
||||||
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
|
|
||||||
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(CACHE_DIR, { recursive: true, force: true });
|
||||||
|
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
|
||||||
if (REPO) {
|
} else if (!hasSubmodule) {
|
||||||
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
|
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
||||||
} 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);
|
||||||
for (const [name, config] of selectedVariants()) {
|
if (hasPnpmLock) {
|
||||||
await buildVariant(name, config);
|
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR });
|
||||||
|
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: DIR });
|
||||||
|
} else {
|
||||||
|
await run('npm', ['ci'], { cwd: DIR });
|
||||||
|
await run('npm', ['run', 'build'], { cwd: DIR });
|
||||||
}
|
}
|
||||||
|
await fs.rm(DIST, { recursive: true, force: true });
|
||||||
|
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
|
||||||
|
console.log(`workflow-ui installed to ${DIST}`);
|
||||||
|
|||||||
-1493
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -47,8 +47,6 @@ pub struct ProxyConfig {
|
|||||||
pub first_byte_timeout_secs: u64,
|
pub first_byte_timeout_secs: u64,
|
||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
pub proxy_log_retention_days: u32,
|
pub proxy_log_retention_days: u32,
|
||||||
#[serde(default)]
|
|
||||||
pub template_proxy_url: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -78,7 +76,6 @@ pub enum ComfyUIBackend {
|
|||||||
Boosapi,
|
Boosapi,
|
||||||
External,
|
External,
|
||||||
Cloud,
|
Cloud,
|
||||||
Runninghub,
|
|
||||||
Docker,
|
Docker,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +111,7 @@ impl ComfyUIConfig {
|
|||||||
pub fn uses_native_backend(&self) -> bool {
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self.backend,
|
self.backend,
|
||||||
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
|
ComfyUIBackend::External | ComfyUIBackend::Cloud
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +119,6 @@ impl ComfyUIConfig {
|
|||||||
matches!(self.backend, ComfyUIBackend::Cloud)
|
matches!(self.backend, ComfyUIBackend::Cloud)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn uses_runninghub_backend(&self) -> bool {
|
|
||||||
matches!(self.backend, ComfyUIBackend::Runninghub)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uses_docker_backend(&self) -> bool {
|
pub fn uses_docker_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::Docker)
|
matches!(self.backend, ComfyUIBackend::Docker)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ pub mod proxy;
|
|||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod template_http;
|
|
||||||
pub mod transformers;
|
pub mod transformers;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
|
|||||||
+1
-385
@@ -15,7 +15,7 @@ use crate::transformers::{
|
|||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::{json, Value};
|
use serde_json::Value;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Resolved upstream info for a given model request.
|
/// Resolved upstream info for a given model request.
|
||||||
@@ -518,23 +518,6 @@ pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppRe
|
|||||||
parse_upstream_json(&text, &upstream_url, status)
|
parse_upstream_json(&text, &upstream_url, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute_video_task_status(
|
|
||||||
state: AppState,
|
|
||||||
route: UpstreamRoute,
|
|
||||||
id: &str,
|
|
||||||
) -> AppResult<Value> {
|
|
||||||
if comfy_video_template_id(&route).is_some() {
|
|
||||||
return comfy_history(&state, id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let suffix = if is_volcengine_ark_base(&route.base_url) {
|
|
||||||
format!("/contents/generations/tasks/{id}")
|
|
||||||
} else {
|
|
||||||
format!("/videos/{id}")
|
|
||||||
};
|
|
||||||
execute_proxy_get(route, &suffix).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
|
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
|
||||||
/// task endpoints as a first-class compatibility target.
|
/// task endpoints as a first-class compatibility target.
|
||||||
pub async fn execute_video_generation(
|
pub async fn execute_video_generation(
|
||||||
@@ -542,10 +525,6 @@ pub async fn execute_video_generation(
|
|||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
if let Some(template_id) = comfy_video_template_id(&route) {
|
|
||||||
return execute_comfy_video_generation(state, route, body, &template_id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_volcengine_ark_base(&route.base_url) {
|
if is_volcengine_ark_base(&route.base_url) {
|
||||||
let body = volcengine_video_create_body(body, &route.model)?;
|
let body = volcengine_video_create_body(body, &route.model)?;
|
||||||
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
|
return execute_proxy_request(state, route, "/contents/generations/tasks", body).await;
|
||||||
@@ -554,76 +533,6 @@ pub async fn execute_video_generation(
|
|||||||
execute_proxy_request(state, route, "/videos", body).await
|
execute_proxy_request(state, route, "/videos", body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
|
||||||
route.base_url
|
|
||||||
.strip_prefix("comfy://")
|
|
||||||
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
|
||||||
.or_else(|| route.model.strip_prefix("comfy://"))
|
|
||||||
.or_else(|| route.model.strip_prefix("comfy:"))
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_comfy_video_generation(
|
|
||||||
state: AppState,
|
|
||||||
route: UpstreamRoute,
|
|
||||||
body: Value,
|
|
||||||
template_id: &str,
|
|
||||||
) -> AppResult<Value> {
|
|
||||||
if !state.config.comfyui.uses_native_backend() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Comfy 视频模型需要配置 comfyui.backend = cloud、external 或 runninghub".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
|
||||||
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
|
|
||||||
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
|
|
||||||
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
|
|
||||||
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
|
|
||||||
mapping = infer_comfy_mapping(&prompt);
|
|
||||||
}
|
|
||||||
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, &body)?;
|
|
||||||
let client_id = format!("boosapi-videos-{}", uuid::Uuid::new_v4().simple());
|
|
||||||
let response = comfy_json(
|
|
||||||
&state,
|
|
||||||
reqwest::Method::POST,
|
|
||||||
"/prompt",
|
|
||||||
Some(json!({
|
|
||||||
"client_id": client_id,
|
|
||||||
"prompt": prompt,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let prompt_id = scalar_string(
|
|
||||||
response
|
|
||||||
.get("prompt_id")
|
|
||||||
.or_else(|| response.get("promptId"))
|
|
||||||
.or_else(|| response.pointer("/data/prompt_id"))
|
|
||||||
.or_else(|| response.pointer("/data/promptId")),
|
|
||||||
)
|
|
||||||
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"id": prompt_id,
|
|
||||||
"object": "video.generation.task",
|
|
||||||
"model": route.model,
|
|
||||||
"status": "queued",
|
|
||||||
"backend": "comfy",
|
|
||||||
"template_id": template_id,
|
|
||||||
"prompt": video_prompt,
|
|
||||||
"response": response,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_volcengine_ark_base(base_url: &str) -> bool {
|
fn is_volcengine_ark_base(base_url: &str) -> bool {
|
||||||
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
|
base_url.contains("ark.cn-beijing.volces.com") || base_url.contains("volces.com/api/v3")
|
||||||
}
|
}
|
||||||
@@ -693,299 +602,6 @@ 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 = http_client();
|
|
||||||
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 history = comfy_json(state, reqwest::Method::GET, &format!("/history/{prompt_id}"), None).await?;
|
|
||||||
Ok(json!({
|
|
||||||
"id": prompt_id,
|
|
||||||
"object": "video.generation.task",
|
|
||||||
"backend": "comfy",
|
|
||||||
"status": if comfy_output_objects(&history) { "succeeded" } else { "running" },
|
|
||||||
"history": history,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn comfy_output_objects(value: &Value) -> bool {
|
|
||||||
match value {
|
|
||||||
Value::Object(object) => {
|
|
||||||
["videos", "gifs", "images"]
|
|
||||||
.iter()
|
|
||||||
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|
|
||||||
|| object.values().any(comfy_output_objects)
|
|
||||||
}
|
|
||||||
Value::Array(items) => items.iter().any(comfy_output_objects),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute an image generation request.
|
/// Execute an image generation request.
|
||||||
pub async fn execute_image_generation(
|
pub async fn execute_image_generation(
|
||||||
_state: AppState,
|
_state: AppState,
|
||||||
|
|||||||
+60
-455
@@ -8,10 +8,8 @@ use axum::{
|
|||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::Row;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -102,62 +100,6 @@ struct IdPath {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct DownstreamUsageQuery {
|
|
||||||
range: Option<String>,
|
|
||||||
#[serde(rename = "timeZone")]
|
|
||||||
time_zone: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct DownstreamKeyUsageRow {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
key: String,
|
|
||||||
description: Option<String>,
|
|
||||||
group_name: Option<String>,
|
|
||||||
tags: Option<String>,
|
|
||||||
enabled: Option<bool>,
|
|
||||||
expires_at: Option<String>,
|
|
||||||
max_cost: Option<f64>,
|
|
||||||
used_cost: Option<f64>,
|
|
||||||
max_requests: Option<i64>,
|
|
||||||
used_requests: Option<i64>,
|
|
||||||
supported_models: Option<String>,
|
|
||||||
allowed_route_ids: Option<String>,
|
|
||||||
site_weight_multipliers: Option<String>,
|
|
||||||
excluded_site_ids: Option<String>,
|
|
||||||
excluded_credential_refs: Option<String>,
|
|
||||||
last_used_at: Option<String>,
|
|
||||||
user_id: Option<i64>,
|
|
||||||
created_at: Option<String>,
|
|
||||||
updated_at: Option<String>,
|
|
||||||
range_requests: i64,
|
|
||||||
range_success_requests: i64,
|
|
||||||
range_failed_requests: i64,
|
|
||||||
range_tokens: i64,
|
|
||||||
range_cost: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct DownstreamAggregateRow {
|
|
||||||
total_requests: i64,
|
|
||||||
success_requests: i64,
|
|
||||||
failed_requests: i64,
|
|
||||||
total_tokens: i64,
|
|
||||||
total_cost: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct DownstreamTrendBucketRow {
|
|
||||||
bucket_start_utc: String,
|
|
||||||
total_requests: i64,
|
|
||||||
success_requests: i64,
|
|
||||||
failed_requests: i64,
|
|
||||||
total_tokens: i64,
|
|
||||||
total_cost: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct DatabaseConnectionRequest {
|
struct DatabaseConnectionRequest {
|
||||||
@@ -186,73 +128,42 @@ async fn list_routes_lite(State(state): State<AppState>) -> AppResult<Json<Value
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn routes_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
async fn routes_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let routes: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
|
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
tr.id,
|
|
||||||
tr.model_pattern,
|
|
||||||
tr.model_mapping,
|
|
||||||
tr.display_name,
|
|
||||||
tr.display_icon,
|
|
||||||
tr.decision_snapshot,
|
|
||||||
tr.decision_refreshed_at,
|
|
||||||
tr.routing_strategy,
|
|
||||||
tr.route_mode,
|
|
||||||
tr.enabled,
|
|
||||||
COUNT(rc.id) AS channel_count,
|
|
||||||
COALESCE(SUM(CASE WHEN COALESCE(rc.enabled, 1) = 1 THEN 1 ELSE 0 END), 0) AS enabled_channel_count,
|
|
||||||
COALESCE(GROUP_CONCAT(DISTINCT s.name), '') AS site_names
|
|
||||||
FROM token_routes tr
|
|
||||||
LEFT JOIN route_channels rc ON rc.route_id = tr.id
|
|
||||||
LEFT JOIN accounts a ON a.id = rc.account_id
|
|
||||||
LEFT JOIN sites s ON s.id = a.site_id
|
|
||||||
GROUP BY tr.id
|
|
||||||
ORDER BY tr.id DESC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
let mut summaries = Vec::new();
|
||||||
let summaries: Vec<Value> = routes
|
for route in routes {
|
||||||
.into_iter()
|
let channel_count: i64 =
|
||||||
.map(|row| {
|
sqlx::query_scalar("SELECT COUNT(*) FROM route_channels WHERE route_id = ?")
|
||||||
let site_names_raw: String = row.try_get("site_names").unwrap_or_default();
|
.bind(route.id)
|
||||||
let site_names: Vec<String> = site_names_raw
|
.fetch_one(&*state.db)
|
||||||
.split(',')
|
.await?;
|
||||||
.map(str::trim)
|
let enabled_channel_count: i64 = sqlx::query_scalar(
|
||||||
.filter(|value| !value.is_empty())
|
"SELECT COUNT(*) FROM route_channels WHERE route_id = ? AND COALESCE(enabled, 1) = 1",
|
||||||
.map(str::to_string)
|
)
|
||||||
.collect();
|
.bind(route.id)
|
||||||
let decision_snapshot_raw: Option<String> = row.try_get("decision_snapshot").ok();
|
.fetch_one(&*state.db)
|
||||||
let decision_snapshot = decision_snapshot_raw
|
.await?;
|
||||||
.as_deref()
|
summaries.push(json!({
|
||||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
"id": route.id,
|
||||||
.unwrap_or(Value::Null);
|
"modelPattern": route.model_pattern,
|
||||||
json!({
|
"displayName": route.display_name,
|
||||||
"id": row.try_get::<i64, _>("id").unwrap_or_default(),
|
"displayIcon": route.display_icon,
|
||||||
"modelPattern": row.try_get::<String, _>("model_pattern").unwrap_or_default(),
|
"routeMode": route.route_mode,
|
||||||
"displayName": row.try_get::<Option<String>, _>("display_name").ok().flatten(),
|
|
||||||
"displayIcon": row.try_get::<Option<String>, _>("display_icon").ok().flatten(),
|
|
||||||
"routeMode": row.try_get::<Option<String>, _>("route_mode").ok().flatten(),
|
|
||||||
"sourceRouteIds": [],
|
"sourceRouteIds": [],
|
||||||
"modelMapping": row.try_get::<Option<String>, _>("model_mapping").ok().flatten(),
|
"modelMapping": route.model_mapping,
|
||||||
"routingStrategy": row.try_get::<Option<String>, _>("routing_strategy").ok().flatten(),
|
"routingStrategy": route.routing_strategy,
|
||||||
"enabled": row.try_get::<Option<bool>, _>("enabled").ok().flatten().unwrap_or(true),
|
"enabled": route.enabled.unwrap_or(true),
|
||||||
"channelCount": row.try_get::<i64, _>("channel_count").unwrap_or_default(),
|
"channelCount": channel_count,
|
||||||
"enabledChannelCount": row.try_get::<i64, _>("enabled_channel_count").unwrap_or_default(),
|
"enabledChannelCount": enabled_channel_count,
|
||||||
"siteNames": site_names,
|
"siteNames": [],
|
||||||
"decisionSnapshot": decision_snapshot,
|
"decisionSnapshot": Value::Null,
|
||||||
"decisionRefreshedAt": row.try_get::<Option<String>, _>("decision_refreshed_at").ok().flatten(),
|
"decisionRefreshedAt": Value::Null,
|
||||||
})
|
}));
|
||||||
})
|
}
|
||||||
.collect();
|
Ok(Json(
|
||||||
Ok(Json(json!({
|
json!({ "success": true, "items": summaries, "data": summaries }),
|
||||||
"success": true,
|
))
|
||||||
"routes": summaries,
|
|
||||||
"items": summaries,
|
|
||||||
"data": summaries,
|
|
||||||
"total": summaries.len(),
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route(
|
async fn create_route(
|
||||||
@@ -464,17 +375,9 @@ async fn clear_route_cooldown(
|
|||||||
|
|
||||||
async fn rebuild_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
async fn rebuild_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let summary = crate::services::route_rebuild::rebuild_routes(&state).await?;
|
let summary = crate::services::route_rebuild::rebuild_routes(&state).await?;
|
||||||
let total_routes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
|
|
||||||
.fetch_one(&*state.db)
|
|
||||||
.await?;
|
|
||||||
let total_channels: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM route_channels")
|
|
||||||
.fetch_one(&*state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"rebuild": summary,
|
"rebuild": summary,
|
||||||
"totalRoutes": total_routes,
|
|
||||||
"totalChannels": total_channels,
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,13 +494,16 @@ async fn reset_downstream_key_usage(
|
|||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn downstream_keys_summary(
|
async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let items: Vec<Value> = sqlx::query_as::<
|
||||||
Query(query): Query<DownstreamUsageQuery>,
|
_,
|
||||||
) -> AppResult<Json<Value>> {
|
crate::db::models::downstream_api_key::DownstreamApiKey,
|
||||||
let (range, since, _bucket_seconds) = resolve_downstream_usage_window(query.range.as_deref());
|
>("SELECT * FROM downstream_api_keys ORDER BY name")
|
||||||
let rows = fetch_downstream_key_usage_rows(&state, since.as_deref()).await?;
|
.fetch_all(&*state.db)
|
||||||
let items: Vec<Value> = rows.into_iter().map(downstream_key_usage_json).collect();
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})))
|
||||||
|
.collect();
|
||||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -616,7 +522,6 @@ async fn downstream_keys_summary(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"range": range,
|
|
||||||
"items": items,
|
"items": items,
|
||||||
"data": items,
|
"data": items,
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -633,327 +538,28 @@ async fn downstream_key_overview(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(IdPath { id }): Path<IdPath>,
|
Path(IdPath { id }): Path<IdPath>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let key = fetch_downstream_key_usage_row(&state, id, None).await?;
|
let key: Option<Value> = sqlx::query_as::<
|
||||||
let item = downstream_key_usage_json(key);
|
_,
|
||||||
let usage = json!({
|
crate::db::models::downstream_api_key::DownstreamApiKey,
|
||||||
"last24h": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::hours(24)))).await?,
|
>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||||
"last7d": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::days(7)))).await?,
|
.bind(id)
|
||||||
"all": downstream_usage_aggregate(&state, id, None).await?,
|
.fetch_optional(&*state.db)
|
||||||
});
|
.await?
|
||||||
Ok(Json(json!({ "success": true, "item": item, "usage": usage })))
|
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})));
|
||||||
|
Ok(Json(json!({ "success": true, "item": key })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn downstream_key_trend(
|
async fn downstream_key_trend(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(IdPath { id }): Path<IdPath>,
|
Path(IdPath { id }): Path<IdPath>,
|
||||||
Query(q): Query<DownstreamUsageQuery>,
|
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> Json<Value> {
|
||||||
let key: Option<(i64, String)> =
|
Json(json!({
|
||||||
sqlx::query_as("SELECT id, name FROM downstream_api_keys WHERE id = ?")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
let Some((key_id, key_name)) = key else {
|
|
||||||
return Err(AppError::NotFound(format!("Downstream key {id} not found")));
|
|
||||||
};
|
|
||||||
|
|
||||||
let (range, since, bucket_seconds) = resolve_downstream_usage_window(q.range.as_deref());
|
|
||||||
let buckets = downstream_usage_trend(&state, key_id, since.as_deref(), bucket_seconds).await?;
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
"success": true,
|
||||||
"range": range,
|
"range": q.get("range").map(String::as_str).unwrap_or("24h"),
|
||||||
"item": { "id": key_id, "name": key_name },
|
"item": { "id": id, "name": "" },
|
||||||
"bucketSeconds": bucket_seconds,
|
"bucketSeconds": 3600,
|
||||||
"timeZone": q.time_zone,
|
"buckets": [],
|
||||||
"buckets": buckets,
|
}))
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_downstream_usage_window(range: Option<&str>) -> (&'static str, Option<String>, i64) {
|
|
||||||
match range.unwrap_or("24h") {
|
|
||||||
"7d" => ("7d", Some(sqlite_utc_since(Duration::days(7))), 86_400),
|
|
||||||
"all" => ("all", None, 86_400),
|
|
||||||
_ => ("24h", Some(sqlite_utc_since(Duration::hours(24))), 3_600),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sqlite_utc_since(duration: Duration) -> String {
|
|
||||||
(Utc::now() - duration)
|
|
||||||
.naive_utc()
|
|
||||||
.format("%Y-%m-%d %H:%M:%S")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_downstream_key_usage_rows(
|
|
||||||
state: &AppState,
|
|
||||||
since: Option<&str>,
|
|
||||||
) -> AppResult<Vec<DownstreamKeyUsageRow>> {
|
|
||||||
let rows = sqlx::query_as::<_, DownstreamKeyUsageRow>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
k.id, k.name, k.key, k.description, k.group_name, k.tags, k.enabled,
|
|
||||||
k.expires_at, k.max_cost, k.used_cost, k.max_requests, k.used_requests,
|
|
||||||
k.supported_models, k.allowed_route_ids, k.site_weight_multipliers,
|
|
||||||
k.excluded_site_ids, k.excluded_credential_refs, k.last_used_at,
|
|
||||||
k.user_id, k.created_at, k.updated_at,
|
|
||||||
COUNT(l.id) AS range_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS range_success_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN l.id IS NOT NULL AND l.status != 'success' THEN 1 ELSE 0 END), 0) AS range_failed_requests,
|
|
||||||
COALESCE(SUM(l.total_tokens), 0) AS range_tokens,
|
|
||||||
COALESCE(SUM(l.estimated_cost), 0.0) AS range_cost
|
|
||||||
FROM downstream_api_keys k
|
|
||||||
LEFT JOIN proxy_logs l
|
|
||||||
ON l.downstream_api_key_id = k.id
|
|
||||||
AND (? IS NULL OR l.created_at >= ?)
|
|
||||||
GROUP BY k.id
|
|
||||||
ORDER BY k.name
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(since)
|
|
||||||
.bind(since)
|
|
||||||
.fetch_all(&*state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_downstream_key_usage_row(
|
|
||||||
state: &AppState,
|
|
||||||
key_id: i64,
|
|
||||||
since: Option<&str>,
|
|
||||||
) -> AppResult<DownstreamKeyUsageRow> {
|
|
||||||
sqlx::query_as::<_, DownstreamKeyUsageRow>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
k.id, k.name, k.key, k.description, k.group_name, k.tags, k.enabled,
|
|
||||||
k.expires_at, k.max_cost, k.used_cost, k.max_requests, k.used_requests,
|
|
||||||
k.supported_models, k.allowed_route_ids, k.site_weight_multipliers,
|
|
||||||
k.excluded_site_ids, k.excluded_credential_refs, k.last_used_at,
|
|
||||||
k.user_id, k.created_at, k.updated_at,
|
|
||||||
COUNT(l.id) AS range_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS range_success_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN l.id IS NOT NULL AND l.status != 'success' THEN 1 ELSE 0 END), 0) AS range_failed_requests,
|
|
||||||
COALESCE(SUM(l.total_tokens), 0) AS range_tokens,
|
|
||||||
COALESCE(SUM(l.estimated_cost), 0.0) AS range_cost
|
|
||||||
FROM downstream_api_keys k
|
|
||||||
LEFT JOIN proxy_logs l
|
|
||||||
ON l.downstream_api_key_id = k.id
|
|
||||||
AND (? IS NULL OR l.created_at >= ?)
|
|
||||||
WHERE k.id = ?
|
|
||||||
GROUP BY k.id
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(since)
|
|
||||||
.bind(since)
|
|
||||||
.bind(key_id)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Downstream key {key_id} not found")))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn downstream_usage_aggregate(
|
|
||||||
state: &AppState,
|
|
||||||
key_id: i64,
|
|
||||||
since: Option<String>,
|
|
||||||
) -> AppResult<Value> {
|
|
||||||
let row: DownstreamAggregateRow = sqlx::query_as(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
COUNT(id) AS total_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS failed_requests,
|
|
||||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
|
||||||
COALESCE(SUM(estimated_cost), 0.0) AS total_cost
|
|
||||||
FROM proxy_logs
|
|
||||||
WHERE downstream_api_key_id = ?
|
|
||||||
AND (? IS NULL OR created_at >= ?)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(key_id)
|
|
||||||
.bind(since.as_deref())
|
|
||||||
.bind(since.as_deref())
|
|
||||||
.fetch_one(&*state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(aggregate_usage_json(
|
|
||||||
row.total_requests,
|
|
||||||
row.success_requests,
|
|
||||||
row.failed_requests,
|
|
||||||
row.total_tokens,
|
|
||||||
row.total_cost,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn downstream_usage_trend(
|
|
||||||
state: &AppState,
|
|
||||||
key_id: i64,
|
|
||||||
since: Option<&str>,
|
|
||||||
bucket_seconds: i64,
|
|
||||||
) -> AppResult<Vec<Value>> {
|
|
||||||
let rows: Vec<DownstreamTrendBucketRow> = sqlx::query_as(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
datetime((CAST(strftime('%s', created_at) AS INTEGER) / ?) * ?, 'unixepoch') AS bucket_start_utc,
|
|
||||||
COUNT(id) AS total_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_requests,
|
|
||||||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS failed_requests,
|
|
||||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
|
||||||
COALESCE(SUM(estimated_cost), 0.0) AS total_cost
|
|
||||||
FROM proxy_logs
|
|
||||||
WHERE downstream_api_key_id = ?
|
|
||||||
AND (? IS NULL OR created_at >= ?)
|
|
||||||
GROUP BY bucket_start_utc
|
|
||||||
ORDER BY bucket_start_utc
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(bucket_seconds)
|
|
||||||
.bind(bucket_seconds)
|
|
||||||
.bind(key_id)
|
|
||||||
.bind(since)
|
|
||||||
.bind(since)
|
|
||||||
.fetch_all(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| {
|
|
||||||
json!({
|
|
||||||
"startUtc": normalize_sqlite_datetime_utc(&row.bucket_start_utc),
|
|
||||||
"totalRequests": row.total_requests,
|
|
||||||
"successRequests": row.success_requests,
|
|
||||||
"failedRequests": row.failed_requests,
|
|
||||||
"successRate": success_rate(row.success_requests, row.total_requests),
|
|
||||||
"totalTokens": row.total_tokens,
|
|
||||||
"totalCost": row.total_cost,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn downstream_key_usage_json(row: DownstreamKeyUsageRow) -> Value {
|
|
||||||
let success_rate = success_rate(row.range_success_requests, row.range_requests);
|
|
||||||
json!({
|
|
||||||
"id": row.id,
|
|
||||||
"name": row.name,
|
|
||||||
"key": row.key,
|
|
||||||
"keyMasked": mask_downstream_key(&row.key),
|
|
||||||
"description": row.description,
|
|
||||||
"groupName": row.group_name,
|
|
||||||
"tags": json_text_array(row.tags.as_deref()),
|
|
||||||
"enabled": row.enabled.unwrap_or(true),
|
|
||||||
"expiresAt": row.expires_at,
|
|
||||||
"maxCost": row.max_cost,
|
|
||||||
"usedCost": row.used_cost.unwrap_or(0.0),
|
|
||||||
"maxRequests": row.max_requests,
|
|
||||||
"usedRequests": row.used_requests.unwrap_or(0),
|
|
||||||
"supportedModels": json_text_array(row.supported_models.as_deref()),
|
|
||||||
"allowedRouteIds": json_i64_array(row.allowed_route_ids.as_deref()),
|
|
||||||
"siteWeightMultipliers": json_text_object(row.site_weight_multipliers.as_deref()),
|
|
||||||
"excludedSiteIds": json_i64_array(row.excluded_site_ids.as_deref()),
|
|
||||||
"excludedCredentialRefs": json_text_array(row.excluded_credential_refs.as_deref()),
|
|
||||||
"lastUsedAt": row.last_used_at,
|
|
||||||
"userId": row.user_id,
|
|
||||||
"createdAt": row.created_at,
|
|
||||||
"updatedAt": row.updated_at,
|
|
||||||
"billing": {
|
|
||||||
"requestLimit": limit_json(row.used_requests.unwrap_or(0) as f64, row.max_requests.map(|v| v as f64)),
|
|
||||||
"costLimit": limit_json(row.used_cost.unwrap_or(0.0), row.max_cost),
|
|
||||||
},
|
|
||||||
"rangeUsage": {
|
|
||||||
"totalRequests": row.range_requests,
|
|
||||||
"successRequests": row.range_success_requests,
|
|
||||||
"failedRequests": row.range_failed_requests,
|
|
||||||
"successRate": success_rate,
|
|
||||||
"totalTokens": row.range_tokens,
|
|
||||||
"totalCost": row.range_cost,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn aggregate_usage_json(
|
|
||||||
total_requests: i64,
|
|
||||||
success_requests: i64,
|
|
||||||
failed_requests: i64,
|
|
||||||
total_tokens: i64,
|
|
||||||
total_cost: f64,
|
|
||||||
) -> Value {
|
|
||||||
json!({
|
|
||||||
"totalRequests": total_requests,
|
|
||||||
"successRequests": success_requests,
|
|
||||||
"failedRequests": failed_requests,
|
|
||||||
"successRate": success_rate(success_requests, total_requests),
|
|
||||||
"totalTokens": total_tokens,
|
|
||||||
"totalCost": total_cost,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn success_rate(success: i64, total: i64) -> Option<f64> {
|
|
||||||
if total <= 0 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(((success as f64 / total as f64) * 10_000.0).round() / 100.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn limit_json(used: f64, max: Option<f64>) -> Value {
|
|
||||||
let percent = max
|
|
||||||
.filter(|value| *value > 0.0)
|
|
||||||
.map(|limit| ((used / limit) * 10_000.0).round() / 100.0);
|
|
||||||
let remaining = max.map(|limit| (limit - used).max(0.0));
|
|
||||||
let exhausted = max.map(|limit| used >= limit).unwrap_or(false);
|
|
||||||
json!({
|
|
||||||
"used": used,
|
|
||||||
"max": max,
|
|
||||||
"remaining": remaining,
|
|
||||||
"percent": percent,
|
|
||||||
"exhausted": exhausted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_downstream_key(key: &str) -> String {
|
|
||||||
let chars: Vec<char> = key.chars().collect();
|
|
||||||
let last4: String = chars[chars.len().saturating_sub(4)..].iter().collect();
|
|
||||||
format!("••••••••{}", last4)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_text_array(raw: Option<&str>) -> Vec<Value> {
|
|
||||||
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
raw.split(',')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(|value| Value::String(value.to_string()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_i64_array(raw: Option<&str>) -> Vec<i64> {
|
|
||||||
json_text_array(raw)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|value| match value {
|
|
||||||
Value::Number(number) => number.as_i64(),
|
|
||||||
Value::String(text) => text.parse::<i64>().ok(),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_text_object(raw: Option<&str>) -> Value {
|
|
||||||
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
||||||
return json!({});
|
|
||||||
};
|
|
||||||
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| json!({}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_sqlite_datetime_utc(value: &str) -> String {
|
|
||||||
if value.ends_with('Z') || value.contains('+') {
|
|
||||||
return value.to_string();
|
|
||||||
}
|
|
||||||
if DateTime::parse_from_rfc3339(value).is_ok() {
|
|
||||||
return value.to_string();
|
|
||||||
}
|
|
||||||
format!("{}Z", value.replace(' ', "T"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auth_info() -> Json<Value> {
|
async fn auth_info() -> Json<Value> {
|
||||||
@@ -980,7 +586,6 @@ async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
|
|||||||
},
|
},
|
||||||
"comfyui": {
|
"comfyui": {
|
||||||
"enabled": state.config.comfyui.enabled,
|
"enabled": state.config.comfyui.enabled,
|
||||||
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
|
||||||
"port": state.config.comfyui.port,
|
"port": state.config.comfyui.port,
|
||||||
"baseUrl": state.config.comfyui.base_url,
|
"baseUrl": state.config.comfyui.base_url,
|
||||||
"externalUrl": state.config.comfyui.external_url,
|
"externalUrl": state.config.comfyui.external_url,
|
||||||
|
|||||||
@@ -409,7 +409,6 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
template_proxy_url: None,
|
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -341,7 +341,6 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
template_proxy_url: None,
|
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -140,10 +140,6 @@ pub async fn create_site(
|
|||||||
return Err(AppError::BadRequest("platform is required".into()));
|
return Err(AppError::BadRequest("platform is required".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let site_url = normalize_site_url(&payload.url)?;
|
|
||||||
let platform = payload.platform.trim().to_string();
|
|
||||||
ensure_site_unique(&state, &platform, &site_url, None).await?;
|
|
||||||
|
|
||||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
@@ -158,8 +154,8 @@ pub async fn create_site(
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
.bind(&site_url)
|
.bind(&payload.url)
|
||||||
.bind(&platform)
|
.bind(&payload.platform)
|
||||||
.bind(&status)
|
.bind(&status)
|
||||||
.bind(&payload.api_key)
|
.bind(&payload.api_key)
|
||||||
.bind(&payload.proxy_url)
|
.bind(&payload.proxy_url)
|
||||||
@@ -221,27 +217,6 @@ pub async fn update_site(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||||
|
|
||||||
if payload
|
|
||||||
.platform
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|platform| platform.trim().is_empty())
|
|
||||||
{
|
|
||||||
return Err(AppError::BadRequest("platform is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_url = match payload.url.as_deref() {
|
|
||||||
Some(url) => normalize_site_url(url)?,
|
|
||||||
None => existing.url.clone(),
|
|
||||||
};
|
|
||||||
let next_platform = payload
|
|
||||||
.platform
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or(existing.platform.as_str())
|
|
||||||
.to_string();
|
|
||||||
ensure_site_unique(&state, &next_platform, &next_url, Some(id)).await?;
|
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -255,8 +230,8 @@ pub async fn update_site(
|
|||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(payload.name.unwrap_or(existing.name))
|
.bind(payload.name.unwrap_or(existing.name))
|
||||||
.bind(next_url)
|
.bind(payload.url.unwrap_or(existing.url))
|
||||||
.bind(next_platform)
|
.bind(payload.platform.unwrap_or(existing.platform))
|
||||||
.bind(payload.status.unwrap_or(existing.status))
|
.bind(payload.status.unwrap_or(existing.status))
|
||||||
.bind(payload.api_key.or(existing.api_key))
|
.bind(payload.api_key.or(existing.api_key))
|
||||||
.bind(payload.proxy_url.or(existing.proxy_url))
|
.bind(payload.proxy_url.or(existing.proxy_url))
|
||||||
@@ -491,45 +466,6 @@ fn normalize_site_url(raw: &str) -> AppResult<String> {
|
|||||||
Ok(trimmed)
|
Ok(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_site_unique(
|
|
||||||
state: &AppState,
|
|
||||||
platform: &str,
|
|
||||||
url: &str,
|
|
||||||
except_id: Option<i64>,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let existing: Option<(i64, String)> = if let Some(id) = except_id {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, name FROM sites WHERE platform = ? AND url = ? AND id != ? LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(platform)
|
|
||||||
.bind(url)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as("SELECT id, name FROM sites WHERE platform = ? AND url = ? LIMIT 1")
|
|
||||||
.bind(platform)
|
|
||||||
.bind(url)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((id, name)) = existing {
|
|
||||||
let display_name = name.trim();
|
|
||||||
let display_name = if display_name.is_empty() {
|
|
||||||
"未命名站点"
|
|
||||||
} else {
|
|
||||||
display_name
|
|
||||||
};
|
|
||||||
return Err(AppError::BadRequest(format!(
|
|
||||||
"该平台下已存在相同 URL 的站点:{}(ID {})",
|
|
||||||
display_name, id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
||||||
let lower = url.to_ascii_lowercase();
|
let lower = url.to_ascii_lowercase();
|
||||||
|
|
||||||
|
|||||||
@@ -1235,7 +1235,6 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
template_proxy_url: None,
|
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-1097
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ mod digital_human;
|
|||||||
mod events;
|
mod events;
|
||||||
mod health;
|
mod health;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
mod money_printer;
|
|
||||||
mod oauth;
|
mod oauth;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
mod picture_agent;
|
mod picture_agent;
|
||||||
@@ -47,7 +46,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(admin::routes(state.clone()))
|
.merge(admin::routes(state.clone()))
|
||||||
.merge(proxy::routes())
|
.merge(proxy::routes())
|
||||||
.merge(picture_agent::routes())
|
.merge(picture_agent::routes())
|
||||||
.merge(money_printer::routes())
|
|
||||||
.merge(comfyui_agent::routes())
|
.merge(comfyui_agent::routes())
|
||||||
.merge(digital_human::routes())
|
.merge(digital_human::routes())
|
||||||
.merge(comfyui::routes())
|
.merge(comfyui::routes())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+34
-35
@@ -1,28 +1,27 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
|
||||||
auth::jwt::validate_jwt,
|
auth::jwt::validate_jwt,
|
||||||
db::models::background_task::BackgroundTask,
|
db::models::background_task::BackgroundTask,
|
||||||
db::models::downstream_api_key::DownstreamApiKey,
|
db::models::downstream_api_key::DownstreamApiKey,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::{HeaderMap, Response, StatusCode, header},
|
http::{header, HeaderMap, Response, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
const TEMPLATE_GALLERY_URL: &str =
|
||||||
"https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html";
|
"https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json";
|
||||||
const LINUX_DO_TEMPLATE_JSON: &str =
|
|
||||||
include_str!("../../assets/picture-agent/linux_do_2036927_prompts_images.with_tags.json");
|
|
||||||
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
|
const TASK_TYPE_PICTURE_GENERATION: &str = "picture-agent-generation";
|
||||||
|
const TEMPLATE_GALLERY_TIMEOUT_SECS: u64 = 30;
|
||||||
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
const PICTURE_GENERATION_TIMEOUT_SECS: u64 = 300;
|
||||||
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
const PICTURE_GENERATION_RETRY_SECS: u64 = 180;
|
||||||
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
const STALE_RUNNING_JOB_MINUTES: i64 = 12;
|
||||||
@@ -225,7 +224,7 @@ async fn status_handler(
|
|||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"keyName": auth.key_name,
|
"keyName": auth.key_name,
|
||||||
"upstreamLabel": "BossAI 本地图片路由",
|
"upstreamLabel": "BoosAPI 本地图片路由",
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +285,6 @@ mod tests {
|
|||||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||||
@@ -346,30 +344,18 @@ async fn templates_handler(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||||
let (templates, local_status) = match parse_gallery_templates(LINUX_DO_TEMPLATE_JSON, limit) {
|
let items = template_client()
|
||||||
Ok(templates) => (templates, json!({ "ok": true, "source": "local-json" })),
|
.get(TEMPLATE_GALLERY_URL)
|
||||||
Err(err) => {
|
.send()
|
||||||
tracing::warn!(%err, "Picture Agent local Linux Do template gallery unavailable");
|
.await
|
||||||
(Vec::new(), json!({ "ok": false, "message": err }))
|
.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}")))?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
let templates: Vec<Value> = items
|
||||||
"success": true,
|
|
||||||
"sourceUrl": TEMPLATE_GALLERY_PAGE_URL,
|
|
||||||
"source": local_status,
|
|
||||||
"templates": templates,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String> {
|
|
||||||
if limit == 0 {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let items = serde_json::from_str::<Vec<GalleryItem>>(raw)
|
|
||||||
.map_err(|err| format!("failed to parse local Linux Do templates: {err}"))?;
|
|
||||||
|
|
||||||
Ok(items
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
let prompt = item.prompt?.trim().to_string();
|
let prompt = item.prompt?.trim().to_string();
|
||||||
@@ -397,14 +383,27 @@ fn parse_gallery_templates(raw: &str, limit: usize) -> Result<Vec<Value>, String
|
|||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"source": {
|
"source": {
|
||||||
"name": "Linux Do GPT Image Gallery",
|
"name": "Linux Do GPT Image Gallery",
|
||||||
"url": TEMPLATE_GALLERY_PAGE_URL,
|
"url": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
||||||
"postNumber": post_number,
|
"postNumber": post_number,
|
||||||
"username": item.username,
|
"username": item.username,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.collect())
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
||||||
|
"templates": templates,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn template_client() -> Client {
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| Client::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn picture_generation_client() -> Client {
|
fn picture_generation_client() -> Client {
|
||||||
|
|||||||
@@ -347,7 +347,12 @@ async fn video_task_handler(
|
|||||||
.unwrap_or("default")
|
.unwrap_or("default")
|
||||||
.to_string();
|
.to_string();
|
||||||
let route = resolve_route(&state, &model).await?;
|
let route = resolve_route(&state, &model).await?;
|
||||||
let result = executors::execute_video_task_status(state.clone(), route, &id).await?;
|
let suffix = if route.base_url.contains("ark.cn-beijing.volces.com") {
|
||||||
|
format!("/contents/generations/tasks/{id}")
|
||||||
|
} else {
|
||||||
|
format!("/videos/{id}")
|
||||||
|
};
|
||||||
|
let result = executors::execute_proxy_get(route, &suffix).await?;
|
||||||
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
|
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
@@ -619,7 +624,6 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<Option<i64>
|
|||||||
if !key.enabled.unwrap_or(true) {
|
if !key.enabled.unwrap_or(true) {
|
||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
UsageTracker::new(state.db.clone()).check_rate_limit(key.id).await?;
|
|
||||||
Ok(Some(key.id))
|
Ok(Some(key.id))
|
||||||
}
|
}
|
||||||
DownstreamAuthResult::Global { .. } => Ok(None),
|
DownstreamAuthResult::Global { .. } => Ok(None),
|
||||||
@@ -1684,7 +1688,6 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
template_proxy_url: None,
|
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-233
@@ -8,8 +8,6 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::Row;
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -126,7 +124,6 @@ async fn dashboard(
|
|||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
let site_availability = build_site_availability(&state).await?;
|
let site_availability = build_site_availability(&state).await?;
|
||||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
|
||||||
|
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
@@ -151,7 +148,6 @@ async fn dashboard(
|
|||||||
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
||||||
},
|
},
|
||||||
"siteAvailability": site_availability,
|
"siteAvailability": site_availability,
|
||||||
"modelAnalysis": model_analysis,
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"sites": sites,
|
"sites": sites,
|
||||||
"accounts": accounts,
|
"accounts": accounts,
|
||||||
@@ -190,139 +186,11 @@ async fn dashboard(
|
|||||||
"generatedAt": payload["generatedAt"],
|
"generatedAt": payload["generatedAt"],
|
||||||
"insights": payload["insights"],
|
"insights": payload["insights"],
|
||||||
"siteAvailability": payload["siteAvailability"],
|
"siteAvailability": payload["siteAvailability"],
|
||||||
"modelAnalysis": payload["modelAnalysis"],
|
|
||||||
}))),
|
}))),
|
||||||
_ => Ok(Json(payload)),
|
_ => Ok(Json(payload)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
|
||||||
let days = days.max(1);
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
COALESCE(NULLIF(model_actual, ''), NULLIF(model_requested, ''), 'unknown') AS model,
|
|
||||||
date(created_at) AS day,
|
|
||||||
COUNT(*) AS calls,
|
|
||||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_calls,
|
|
||||||
COALESCE(SUM(CASE WHEN status != 'success' OR status IS NULL THEN 1 ELSE 0 END), 0) AS failed_calls,
|
|
||||||
COALESCE(SUM(estimated_cost), 0.0) AS spend,
|
|
||||||
COALESCE(SUM(total_tokens), 0) AS tokens,
|
|
||||||
AVG(latency_ms) AS avg_latency_ms
|
|
||||||
FROM proxy_logs
|
|
||||||
WHERE created_at >= datetime('now', ?)
|
|
||||||
GROUP BY model, day
|
|
||||||
ORDER BY day ASC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(format!("-{} days", days - 1))
|
|
||||||
.fetch_all(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut by_model: HashMap<String, (i64, i64, i64, f64, i64, f64, i64)> = HashMap::new();
|
|
||||||
let mut by_day: BTreeMap<String, f64> = BTreeMap::new();
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
let model: String = row
|
|
||||||
.try_get("model")
|
|
||||||
.unwrap_or_else(|_| "unknown".to_string());
|
|
||||||
let day: String = row.try_get("day").unwrap_or_default();
|
|
||||||
let calls: i64 = row.try_get("calls").unwrap_or_default();
|
|
||||||
let success_calls: i64 = row.try_get("success_calls").unwrap_or_default();
|
|
||||||
let failed_calls: i64 = row.try_get("failed_calls").unwrap_or_default();
|
|
||||||
let spend: f64 = row.try_get("spend").unwrap_or_default();
|
|
||||||
let tokens: i64 = row.try_get("tokens").unwrap_or_default();
|
|
||||||
let avg_latency: Option<f64> = row.try_get("avg_latency_ms").ok().flatten();
|
|
||||||
|
|
||||||
let entry = by_model.entry(model).or_insert((0, 0, 0, 0.0, 0, 0.0, 0));
|
|
||||||
entry.0 += calls;
|
|
||||||
entry.1 += success_calls;
|
|
||||||
entry.2 += failed_calls;
|
|
||||||
entry.3 += spend;
|
|
||||||
entry.4 += tokens;
|
|
||||||
if let Some(latency) = avg_latency {
|
|
||||||
entry.5 += latency * calls.max(1) as f64;
|
|
||||||
entry.6 += calls.max(1);
|
|
||||||
}
|
|
||||||
if !day.is_empty() {
|
|
||||||
*by_day.entry(day).or_insert(0.0) += spend;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut models: Vec<(String, (i64, i64, i64, f64, i64, f64, i64))> =
|
|
||||||
by_model.into_iter().collect();
|
|
||||||
models.sort_by(|a, b| b.1 .3.total_cmp(&a.1 .3).then_with(|| b.1 .0.cmp(&a.1 .0)));
|
|
||||||
|
|
||||||
let total_calls: i64 = models.iter().map(|(_, item)| item.0).sum();
|
|
||||||
let total_spend: f64 = models.iter().map(|(_, item)| item.3).sum();
|
|
||||||
let total_tokens: i64 = models.iter().map(|(_, item)| item.4).sum();
|
|
||||||
|
|
||||||
let spend_distribution: Vec<Value> = models
|
|
||||||
.iter()
|
|
||||||
.map(|(model, item)| {
|
|
||||||
json!({
|
|
||||||
"model": model,
|
|
||||||
"calls": item.0,
|
|
||||||
"spend": (item.3 * 1_000_000.0).round() / 1_000_000.0,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let calls_distribution: Vec<Value> = models
|
|
||||||
.iter()
|
|
||||||
.map(|(model, item)| {
|
|
||||||
let share = if total_calls > 0 {
|
|
||||||
item.0 as f64 * 100.0 / total_calls as f64
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
json!({
|
|
||||||
"model": model,
|
|
||||||
"calls": item.0,
|
|
||||||
"share": share,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let call_ranking: Vec<Value> = models
|
|
||||||
.iter()
|
|
||||||
.map(|(model, item)| {
|
|
||||||
let success_rate = if item.0 > 0 {
|
|
||||||
item.1 as f64 * 100.0 / item.0 as f64
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let avg_latency = if item.6 > 0 {
|
|
||||||
(item.5 / item.6 as f64).round() as i64
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
json!({
|
|
||||||
"model": model,
|
|
||||||
"calls": item.0,
|
|
||||||
"successRate": success_rate,
|
|
||||||
"avgLatencyMs": avg_latency,
|
|
||||||
"spend": (item.3 * 1_000_000.0).round() / 1_000_000.0,
|
|
||||||
"tokens": item.4,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let spend_trend: Vec<Value> = by_day
|
|
||||||
.into_iter()
|
|
||||||
.map(|(day, spend)| json!({ "day": day, "spend": spend }))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"totals": {
|
|
||||||
"spend": (total_spend * 1_000_000.0).round() / 1_000_000.0,
|
|
||||||
"calls": total_calls,
|
|
||||||
"tokens": total_tokens,
|
|
||||||
},
|
|
||||||
"spendDistribution": spend_distribution,
|
|
||||||
"spendTrend": spend_trend,
|
|
||||||
"callsDistribution": calls_distribution,
|
|
||||||
"callRanking": call_ranking,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_site_availability(state: &AppState) -> AppResult<Vec<Value>> {
|
async fn build_site_availability(state: &AppState) -> AppResult<Vec<Value>> {
|
||||||
let rows: Vec<(
|
let rows: Vec<(
|
||||||
i64,
|
i64,
|
||||||
@@ -621,70 +489,28 @@ async fn site_distribution(
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Sum per-site usage
|
// Sum per-site usage
|
||||||
let mut rows: Vec<(i64, String, Option<String>, i64, f64, i64, i64, f64)> = sqlx::query_as(
|
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
||||||
"SELECT
|
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0)
|
||||||
s.id,
|
|
||||||
s.name,
|
|
||||||
s.platform,
|
|
||||||
COALESCE(SUM(m.total_calls), 0),
|
|
||||||
COALESCE(SUM(m.total_spend), 0.0),
|
|
||||||
COALESCE(SUM(m.total_tokens), 0),
|
|
||||||
COUNT(DISTINCT a.id),
|
|
||||||
COALESCE(SUM(a.balance), 0.0)
|
|
||||||
FROM sites s
|
FROM sites s
|
||||||
LEFT JOIN accounts a ON a.site_id = s.id
|
|
||||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||||
GROUP BY s.id, s.name, s.platform
|
GROUP BY s.id, s.name
|
||||||
ORDER BY SUM(m.total_spend) DESC",
|
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)
|
.bind(&since_day)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
|
||||||
|
|
||||||
let distribution: Vec<Value> = rows
|
let distribution: Vec<Value> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(|(id, name, calls, spend, tokens)| {
|
||||||
|(id, name, platform, calls, spend, tokens, account_count, balance)| {
|
json!({
|
||||||
json!({
|
"siteId": id,
|
||||||
"siteId": id,
|
"siteName": name,
|
||||||
"siteName": name,
|
"calls": calls,
|
||||||
"platform": platform,
|
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||||
"calls": calls,
|
"tokens": tokens,
|
||||||
"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();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(json!({ "distribution": distribution })))
|
Ok(Json(json!({ "distribution": distribution })))
|
||||||
@@ -702,61 +528,25 @@ async fn site_trend(
|
|||||||
.format("%Y-%m-%d")
|
.format("%Y-%m-%d")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let mut rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
|
let rows: Vec<(String, i64, f64, i64)> = sqlx::query_as(
|
||||||
"SELECT
|
"SELECT local_day, COALESCE(SUM(total_calls), 0), COALESCE(SUM(total_spend), 0.0), COALESCE(SUM(total_tokens), 0)
|
||||||
m.local_day,
|
FROM model_day_usage
|
||||||
s.name,
|
WHERE local_day >= ?
|
||||||
COALESCE(SUM(m.total_calls), 0),
|
GROUP BY local_day
|
||||||
COALESCE(SUM(m.total_spend), 0.0),
|
ORDER BY local_day ASC",
|
||||||
COALESCE(SUM(m.total_tokens), 0)
|
|
||||||
FROM model_day_usage m
|
|
||||||
JOIN sites s ON s.id = m.site_id
|
|
||||||
WHERE m.local_day >= ?
|
|
||||||
GROUP BY m.local_day, s.id, s.name
|
|
||||||
ORDER BY m.local_day ASC, s.name ASC",
|
|
||||||
)
|
)
|
||||||
.bind(&since_day)
|
.bind(&since_day)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if rows.is_empty() {
|
let trend: Vec<Value> = rows
|
||||||
rows = sqlx::query_as(
|
.into_iter()
|
||||||
"SELECT
|
.map(|(day, calls, spend, tokens)| {
|
||||||
date(l.created_at),
|
|
||||||
COALESCE(s.name, '未知站点'),
|
|
||||||
COUNT(l.id),
|
|
||||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
|
||||||
COALESCE(SUM(l.total_tokens), 0)
|
|
||||||
FROM proxy_logs l
|
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
|
||||||
LEFT JOIN sites s ON s.id = a.site_id
|
|
||||||
WHERE date(l.created_at) >= ?
|
|
||||||
GROUP BY date(l.created_at), s.id, s.name
|
|
||||||
ORDER BY date(l.created_at) ASC, s.name ASC",
|
|
||||||
)
|
|
||||||
.bind(&since_day)
|
|
||||||
.fetch_all(&*state.db)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut by_day: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
|
|
||||||
for (day, site, calls, spend, tokens) in rows {
|
|
||||||
by_day.entry(day).or_default().insert(
|
|
||||||
site,
|
|
||||||
json!({
|
json!({
|
||||||
|
"date": day,
|
||||||
"calls": calls,
|
"calls": calls,
|
||||||
"spend": spend,
|
"spend": spend,
|
||||||
"tokens": tokens,
|
"tokens": tokens,
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let trend: Vec<Value> = by_day
|
|
||||||
.into_iter()
|
|
||||||
.map(|(day, sites)| {
|
|
||||||
json!({
|
|
||||||
"date": day,
|
|
||||||
"sites": sites,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
||||||
.route("/api/user/logs", get(get_user_logs))
|
.route("/api/user/logs", get(get_user_logs))
|
||||||
.route("/api/user/summary", get(get_user_summary))
|
.route("/api/user/summary", get(get_user_summary))
|
||||||
.route("/api/user/dashboard", get(get_user_dashboard))
|
|
||||||
.route(
|
.route(
|
||||||
"/api/user/models/marketplace",
|
"/api/user/models/marketplace",
|
||||||
get(get_user_models_marketplace),
|
get(get_user_models_marketplace),
|
||||||
@@ -552,53 +551,6 @@ 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 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 recent_logs = items.into_iter().take(5).collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"user": {
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"email": user.email,
|
|
||||||
"role": user.role,
|
|
||||||
"status": user.status,
|
|
||||||
"createdAt": user.created_at,
|
|
||||||
},
|
|
||||||
"keys": keys,
|
|
||||||
"summary": {
|
|
||||||
"totalRequests": total,
|
|
||||||
"successRequests": success_count,
|
|
||||||
"totalCost": total_cost,
|
|
||||||
},
|
|
||||||
"recentLogs": recent_logs,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user_models_marketplace(
|
async fn get_user_models_marketplace(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|||||||
+205
-2389
File diff suppressed because it is too large
Load Diff
+6
-117
@@ -7,12 +7,12 @@ use axum::{
|
|||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap, Response, StatusCode},
|
http::{header, HeaderMap, Response, StatusCode},
|
||||||
routing::{get, post},
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{collections::HashSet, path::PathBuf};
|
use std::path::PathBuf;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -34,19 +34,9 @@ struct AssetQuery {
|
|||||||
page_size: Option<i64>,
|
page_size: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BatchDeleteAssetsRequest {
|
|
||||||
#[serde(default, alias = "publicIds")]
|
|
||||||
public_ids: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/workspace/assets", get(list_admin_assets))
|
.route("/api/workspace/assets", get(list_admin_assets))
|
||||||
.route(
|
|
||||||
"/api/workspace/assets/batch-delete",
|
|
||||||
post(batch_delete_admin_assets),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/workspace/assets/{public_id}",
|
"/api/workspace/assets/{public_id}",
|
||||||
get(get_admin_asset).delete(delete_admin_asset),
|
get(get_admin_asset).delete(delete_admin_asset),
|
||||||
@@ -56,10 +46,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
get(get_admin_asset_file),
|
get(get_admin_asset_file),
|
||||||
)
|
)
|
||||||
.route("/api/user/workspace/assets", get(list_user_assets))
|
.route("/api/user/workspace/assets", get(list_user_assets))
|
||||||
.route(
|
|
||||||
"/api/user/workspace/assets/batch-delete",
|
|
||||||
post(batch_delete_user_assets),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/user/workspace/assets/{public_id}",
|
"/api/user/workspace/assets/{public_id}",
|
||||||
get(get_user_asset).delete(delete_user_asset),
|
get(get_user_asset).delete(delete_user_asset),
|
||||||
@@ -182,17 +168,12 @@ async fn list_assets(
|
|||||||
WorkspaceCaller::Admin => requested_user_id,
|
WorkspaceCaller::Admin => requested_user_id,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
|
||||||
|
|
||||||
let items = sqlx::query(
|
let items = sqlx::query(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
AND (? IS NULL OR user_id = ?)
|
||||||
AND (
|
|
||||||
source != 'workflow-ui'
|
|
||||||
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
|
||||||
)
|
|
||||||
AND (? IS NULL OR source = ?)
|
AND (? IS NULL OR source = ?)
|
||||||
AND (? IS NULL OR kind = ?)
|
AND (? IS NULL OR kind = ?)
|
||||||
AND (
|
AND (
|
||||||
@@ -205,11 +186,8 @@ async fn list_assets(
|
|||||||
ORDER BY datetime(created_at) DESC, id DESC
|
ORDER BY datetime(created_at) DESC, id DESC
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?",
|
||||||
)
|
)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&kind)
|
.bind(&kind)
|
||||||
@@ -229,11 +207,7 @@ async fn list_assets(
|
|||||||
"SELECT COUNT(*)
|
"SELECT COUNT(*)
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
AND (? IS NULL OR user_id = ?)
|
||||||
AND (
|
|
||||||
source != 'workflow-ui'
|
|
||||||
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
|
||||||
)
|
|
||||||
AND (? IS NULL OR source = ?)
|
AND (? IS NULL OR source = ?)
|
||||||
AND (? IS NULL OR kind = ?)
|
AND (? IS NULL OR kind = ?)
|
||||||
AND (
|
AND (
|
||||||
@@ -244,11 +218,8 @@ async fn list_assets(
|
|||||||
OR source LIKE ?
|
OR source LIKE ?
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&source)
|
.bind(&source)
|
||||||
.bind(&kind)
|
.bind(&kind)
|
||||||
@@ -279,21 +250,17 @@ async fn get_asset_row(
|
|||||||
WorkspaceCaller::Admin => None,
|
WorkspaceCaller::Admin => None,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE public_id = ?
|
WHERE public_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
AND (? IS NULL OR user_id = ?)
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(public_id)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
||||||
@@ -320,20 +287,16 @@ async fn delete_asset(
|
|||||||
WorkspaceCaller::Admin => None,
|
WorkspaceCaller::Admin => None,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE workspace_assets
|
"UPDATE workspace_assets
|
||||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||||
WHERE public_id = ?
|
WHERE public_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
|
AND (? IS NULL OR user_id = ?)",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(public_id)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
@@ -342,62 +305,6 @@ async fn delete_asset(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_assets_batch(
|
|
||||||
state: AppState,
|
|
||||||
caller: WorkspaceCaller,
|
|
||||||
public_ids: Vec<String>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let public_ids = public_ids
|
|
||||||
.into_iter()
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.filter(|value| seen.insert(value.clone()))
|
|
||||||
.take(200)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if public_ids.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("No workspace assets selected".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let scoped_user_id = match caller {
|
|
||||||
WorkspaceCaller::Admin => None,
|
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
|
||||||
};
|
|
||||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
|
||||||
let mut deleted_ids = Vec::new();
|
|
||||||
let mut failed_ids = Vec::new();
|
|
||||||
|
|
||||||
for public_id in public_ids {
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE workspace_assets
|
|
||||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
|
||||||
WHERE public_id = ?
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
|
|
||||||
)
|
|
||||||
.bind(&public_id)
|
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.bind(is_admin)
|
|
||||||
.bind(scoped_user_id)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
if result.rows_affected() > 0 {
|
|
||||||
deleted_ids.push(public_id);
|
|
||||||
} else {
|
|
||||||
failed_ids.push(public_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"deletedCount": deleted_ids.len(),
|
|
||||||
"deletedIds": deleted_ids,
|
|
||||||
"failedIds": failed_ids,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_asset_file(
|
async fn get_asset_file(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
caller: WorkspaceCaller,
|
caller: WorkspaceCaller,
|
||||||
@@ -478,15 +385,6 @@ async fn delete_admin_asset(
|
|||||||
delete_asset(state, caller, public_id).await
|
delete_asset(state, caller, public_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn batch_delete_admin_assets(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<BatchDeleteAssetsRequest>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let caller = resolve_caller(&headers, &state, true)?;
|
|
||||||
delete_assets_batch(state, caller, payload.public_ids).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_user_assets(
|
async fn list_user_assets(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -522,12 +420,3 @@ async fn delete_user_asset(
|
|||||||
let caller = resolve_caller(&headers, &state, false)?;
|
let caller = resolve_caller(&headers, &state, false)?;
|
||||||
delete_asset(state, caller, public_id).await
|
delete_asset(state, caller, public_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn batch_delete_user_assets(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(payload): Json<BatchDeleteAssetsRequest>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let caller = resolve_caller(&headers, &state, false)?;
|
|
||||||
delete_assets_batch(state, caller, payload.public_ids).await
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -108,11 +108,8 @@ impl UsageTracker {
|
|||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
if let Some(max_cost) = max_cost {
|
if let Some(max_cost) = max_cost {
|
||||||
if max_cost <= 0.0 || used_cost.unwrap_or(0.0) >= max_cost {
|
if used_cost.unwrap_or(0.0) >= max_cost {
|
||||||
return Err(AppError::RateLimitedWithRetry {
|
return Err(AppError::RateLimited);
|
||||||
message: "下游密钥成本额度已用完".into(),
|
|
||||||
retry_after_secs: 60,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -263,7 +260,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
tracker.check_rate_limit(1).await,
|
tracker.check_rate_limit(1).await,
|
||||||
Err(AppError::RateLimitedWithRetry { .. })
|
Err(AppError::RateLimited)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,10 +94,6 @@ impl ComfyUIProcess {
|
|||||||
let base_url = self.internal_base_url();
|
let base_url = self.internal_base_url();
|
||||||
let mode = if self.config.uses_docker_backend() {
|
let mode = if self.config.uses_docker_backend() {
|
||||||
"docker"
|
"docker"
|
||||||
} else if self.config.uses_cloud_backend() {
|
|
||||||
"cloud"
|
|
||||||
} else if self.config.uses_runninghub_backend() {
|
|
||||||
"runninghub"
|
|
||||||
} else if self.config.enabled {
|
} else if self.config.enabled {
|
||||||
"embedded"
|
"embedded"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,17 +21,8 @@ pub async fn enforce_key_request_limit(
|
|||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
if let Some(max_requests) = max_requests {
|
if let Some(max_requests) = max_requests {
|
||||||
if max_requests <= 0 {
|
|
||||||
return Err(AppError::RateLimitedWithRetry {
|
|
||||||
message: "下游密钥请求额度已用完".into(),
|
|
||||||
retry_after_secs: 60,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if used_requests.unwrap_or(0) + increment > max_requests {
|
if used_requests.unwrap_or(0) + increment > max_requests {
|
||||||
return Err(AppError::RateLimitedWithRetry {
|
return Err(AppError::RateLimited);
|
||||||
message: "下游密钥请求额度已用完".into(),
|
|
||||||
retry_after_secs: 60,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -36,8 +36,8 @@ describe('Login surface', () => {
|
|||||||
&& node.props.className.includes('brand-mark-canvas')
|
&& node.props.className.includes('brand-mark-canvas')
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(pageText).toContain('BossAI');
|
expect(pageText).toContain('BoosAPI');
|
||||||
expect(pageText).toContain('聚合多模型多模态的 AI 应用平台');
|
expect(pageText).toContain('中转站的中转站');
|
||||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
||||||
expect(pageText).toContain('统一代理网关');
|
expect(pageText).toContain('统一代理网关');
|
||||||
|
|||||||
+17
-96
@@ -7,8 +7,6 @@ import TooltipLayer from './components/TooltipLayer.js';
|
|||||||
import { api, userApi } from './api.js';
|
import { api, userApi } from './api.js';
|
||||||
import { clearSession, getSessionToken } from './authSession.js';
|
import { clearSession, getSessionToken } from './authSession.js';
|
||||||
import {
|
import {
|
||||||
ADMIN_PROFILE_STORAGE_KEY,
|
|
||||||
LEGACY_USER_PROFILE_STORAGE_KEY,
|
|
||||||
LEGACY_THEME_STORAGE_KEY,
|
LEGACY_THEME_STORAGE_KEY,
|
||||||
THEME_MODE_STORAGE_KEY,
|
THEME_MODE_STORAGE_KEY,
|
||||||
USER_PROFILE_STORAGE_KEY,
|
USER_PROFILE_STORAGE_KEY,
|
||||||
@@ -39,7 +37,6 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
|||||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||||
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
|
||||||
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
||||||
const Workflows = lazy(() => import('./pages/Workflows.js'));
|
const Workflows = lazy(() => import('./pages/Workflows.js'));
|
||||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||||
@@ -113,15 +110,13 @@ function buildDicebearAvatarUrl(style: string, seed: string): string {
|
|||||||
const safeSeed = (seed || 'default').trim() || 'default';
|
const safeSeed = (seed || 'default').trim() || 'default';
|
||||||
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
||||||
}
|
}
|
||||||
|
function resolveStoredProfile(): UserProfile {
|
||||||
function defaultProfile(name: string): UserProfile {
|
|
||||||
const avatarSeed = createRandomAvatarSeed();
|
|
||||||
return { name, avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
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 parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string };
|
||||||
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
|
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
|
||||||
const avatarSeed = typeof parsed?.avatarSeed === 'string'
|
const avatarSeed = typeof parsed?.avatarSeed === 'string'
|
||||||
@@ -132,53 +127,17 @@ function parseStoredProfile(raw: string | null, fallbackName: string): UserProfi
|
|||||||
? parsed.avatarStyle.trim()
|
? parsed.avatarStyle.trim()
|
||||||
: '';
|
: '';
|
||||||
return {
|
return {
|
||||||
name: name || fallbackName,
|
name: name || '管理员',
|
||||||
avatarSeed: resolvedSeed,
|
avatarSeed: resolvedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(resolvedSeed),
|
: pickDicebearStyle(resolvedSeed),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
const avatarSeed = createRandomAvatarSeed();
|
||||||
|
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStoredProfile(audience: 'admin' | 'user'): UserProfile {
|
|
||||||
const fallbackName = audience === 'admin' ? '管理员' : '用户';
|
|
||||||
const storageKey = audience === 'admin' ? ADMIN_PROFILE_STORAGE_KEY : USER_PROFILE_STORAGE_KEY;
|
|
||||||
const stored = parseStoredProfile(localStorage.getItem(storageKey), fallbackName);
|
|
||||||
if (stored) return stored;
|
|
||||||
|
|
||||||
if (audience === 'user') {
|
|
||||||
const legacy = parseStoredProfile(localStorage.getItem(LEGACY_USER_PROFILE_STORAGE_KEY), fallbackName);
|
|
||||||
if (legacy && legacy.name !== '管理员') {
|
|
||||||
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(legacy));
|
|
||||||
return legacy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = defaultProfile(fallbackName);
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(next));
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function profileStorageKey(audience: 'admin' | 'user'): string {
|
|
||||||
return audience === 'admin' ? ADMIN_PROFILE_STORAGE_KEY : USER_PROFILE_STORAGE_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
|
|
||||||
const name = username.trim();
|
|
||||||
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
|
|
||||||
const avatarStyle = current?.avatarStyle?.trim() || pickDicebearStyle(avatarSeed);
|
|
||||||
return {
|
|
||||||
name: name || current?.name || fallbackName,
|
|
||||||
avatarSeed,
|
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
|
||||||
? avatarStyle
|
|
||||||
: pickDicebearStyle(avatarSeed),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserProfileModal({
|
function UserProfileModal({
|
||||||
open,
|
open,
|
||||||
profile,
|
profile,
|
||||||
@@ -388,7 +347,6 @@ export const sidebarGroups = [
|
|||||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||||
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
||||||
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||||
{ to: '/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
|
|
||||||
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||||
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||||
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||||
@@ -421,7 +379,6 @@ export const userSidebarGroups = [
|
|||||||
label: '创作应用',
|
label: '创作应用',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||||
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
|
|
||||||
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||||
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||||
@@ -433,7 +390,6 @@ const topNavItems = [
|
|||||||
{ label: '控制台', to: '/' },
|
{ label: '控制台', to: '/' },
|
||||||
{ label: '模型广场', to: '/models' },
|
{ label: '模型广场', to: '/models' },
|
||||||
{ label: '生图助手', to: '/picture-agent' },
|
{ label: '生图助手', to: '/picture-agent' },
|
||||||
{ label: '短视频工厂', to: '/money-printer' },
|
|
||||||
{ label: '工作流', to: '/workflows' },
|
{ label: '工作流', to: '/workflows' },
|
||||||
{ label: '视频助手', to: '/comfyui-agent' },
|
{ label: '视频助手', to: '/comfyui-agent' },
|
||||||
{ label: '工作空间', to: '/workspace' },
|
{ label: '工作空间', to: '/workspace' },
|
||||||
@@ -463,9 +419,7 @@ function AppShell() {
|
|||||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
const isUserPage = location.pathname.startsWith('/user/');
|
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile());
|
||||||
const profileAudience: 'admin' | 'user' = isUserPage ? 'user' : 'admin';
|
|
||||||
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile(profileAudience));
|
|
||||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
@@ -487,9 +441,6 @@ function AppShell() {
|
|||||||
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
|
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
|
||||||
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
||||||
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
|
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
|
||||||
useEffect(() => {
|
|
||||||
setUserProfile(resolveStoredProfile(profileAudience));
|
|
||||||
}, [profileAudience]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const sync = () => setSystemPrefersDark(media.matches);
|
const sync = () => setSystemPrefersDark(media.matches);
|
||||||
@@ -609,35 +560,6 @@ function AppShell() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [location.pathname, userAuthed]);
|
}, [location.pathname, userAuthed]);
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!userAuthed
|
|
||||||
|| !location.pathname.startsWith('/user/')
|
|
||||||
|| ['/user/login', '/user/register'].includes(location.pathname)
|
|
||||||
) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const syncUserProfile = async () => {
|
|
||||||
try {
|
|
||||||
const response = await userApi.me();
|
|
||||||
if (cancelled) return;
|
|
||||||
const user = (response as any)?.user || (response as any)?.data || {};
|
|
||||||
const username = typeof user.username === 'string' ? user.username.trim() : '';
|
|
||||||
if (!username) return;
|
|
||||||
setUserProfile((current) => {
|
|
||||||
if ((current.name || '').trim() === username) return current;
|
|
||||||
const next = profileFromUsername(username, current, '用户');
|
|
||||||
localStorage.setItem(profileStorageKey('user'), JSON.stringify(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Keep the local profile if the user profile endpoint is temporarily unavailable.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void syncUserProfile();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [location.pathname, userAuthed]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||||
@@ -657,14 +579,14 @@ function AppShell() {
|
|||||||
const handleSaveProfile = (nextProfile: UserProfile) => {
|
const handleSaveProfile = (nextProfile: UserProfile) => {
|
||||||
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
|
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
|
||||||
const normalized = {
|
const normalized = {
|
||||||
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
name: nextProfile.name.trim() || t('管理员'),
|
||||||
avatarSeed: normalizedSeed,
|
avatarSeed: normalizedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
|
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
|
||||||
? nextProfile.avatarStyle
|
? nextProfile.avatarStyle
|
||||||
: pickDicebearStyle(normalizedSeed),
|
: pickDicebearStyle(normalizedSeed),
|
||||||
};
|
};
|
||||||
setUserProfile(normalized);
|
setUserProfile(normalized);
|
||||||
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized));
|
||||||
setShowProfileModal(false);
|
setShowProfileModal(false);
|
||||||
toast.success(t('个人信息已保存'));
|
toast.success(t('个人信息已保存'));
|
||||||
};
|
};
|
||||||
@@ -695,6 +617,7 @@ function AppShell() {
|
|||||||
setAnnouncementPopupPending(false);
|
setAnnouncementPopupPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isUserPage = location.pathname.startsWith('/user/');
|
||||||
const isAdminLoginPage = location.pathname === '/boos';
|
const isAdminLoginPage = location.pathname === '/boos';
|
||||||
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
|
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
|
||||||
// The default `/` route now serves the user-facing login; admins land on `/boos`.
|
// The default `/` route now serves the user-facing login; admins land on `/boos`.
|
||||||
@@ -773,8 +696,8 @@ function AppShell() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="topbar-logo">
|
<div className="topbar-logo">
|
||||||
<img src="/logo.png" alt="BossAI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||||
<span className="topbar-logo-text">BossAI</span>
|
<span className="topbar-logo-text">BoosAPI</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="topbar-nav">
|
<nav className="topbar-nav">
|
||||||
{currentTopNavItems.map((item) => (
|
{currentTopNavItems.map((item) => (
|
||||||
@@ -906,8 +829,8 @@ function AppShell() {
|
|||||||
closeLabel={t('关闭导航')}
|
closeLabel={t('关闭导航')}
|
||||||
>
|
>
|
||||||
<div className="mobile-drawer-header">
|
<div className="mobile-drawer-header">
|
||||||
<img src="/logo.png" alt="BossAI" />
|
<img src="/logo.png" alt="BoosAPI" />
|
||||||
<span>BossAI</span>
|
<span>BoosAPI</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mobile-nav">
|
<nav className="mobile-nav">
|
||||||
{currentSidebarGroups.map((group) => (
|
{currentSidebarGroups.map((group) => (
|
||||||
@@ -991,7 +914,6 @@ function AppShell() {
|
|||||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||||
<Route path="/models" element={<Models />} />
|
<Route path="/models" element={<Models />} />
|
||||||
<Route path="/picture-agent" element={<PictureAgent />} />
|
<Route path="/picture-agent" element={<PictureAgent />} />
|
||||||
<Route path="/money-printer" element={<MoneyPrinter />} />
|
|
||||||
<Route path="/digital-human" element={<DigitalHuman />} />
|
<Route path="/digital-human" element={<DigitalHuman />} />
|
||||||
<Route path="/workflows" element={<Workflows />} />
|
<Route path="/workflows" element={<Workflows />} />
|
||||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||||
@@ -1011,7 +933,6 @@ function AppShell() {
|
|||||||
<Route path="/user/usage" element={<UsageRecords />} />
|
<Route path="/user/usage" element={<UsageRecords />} />
|
||||||
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
||||||
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
||||||
<Route path="/user/money-printer" element={<MoneyPrinter />} />
|
|
||||||
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
||||||
<Route path="/user/workflows" element={<Workflows />} />
|
<Route path="/user/workflows" element={<Workflows />} />
|
||||||
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
||||||
|
|||||||
-121
@@ -322,15 +322,6 @@ function normalizeDownstreamKeyRow(row: any): any {
|
|||||||
groupName: row.groupName ?? row.group_name ?? null,
|
groupName: row.groupName ?? row.group_name ?? null,
|
||||||
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
||||||
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
||||||
billing: row.billing ?? null,
|
|
||||||
rangeUsage: row.rangeUsage ?? row.range_usage ?? {
|
|
||||||
totalRequests: 0,
|
|
||||||
successRequests: 0,
|
|
||||||
failedRequests: 0,
|
|
||||||
successRate: null,
|
|
||||||
totalTokens: 0,
|
|
||||||
totalCost: 0,
|
|
||||||
},
|
|
||||||
maxCost: row.maxCost ?? row.max_cost ?? null,
|
maxCost: row.maxCost ?? row.max_cost ?? null,
|
||||||
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
||||||
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
||||||
@@ -1092,13 +1083,6 @@ export type WorkspaceAssetsResponse = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceBatchDeleteResponse = {
|
|
||||||
success: boolean;
|
|
||||||
deletedCount: number;
|
|
||||||
deletedIds: string[];
|
|
||||||
failedIds: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceAssetQuery = {
|
export type WorkspaceAssetQuery = {
|
||||||
source?: string;
|
source?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -1158,44 +1142,6 @@ export type WorkflowSavePayload = {
|
|||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DigitalHumanJob = {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
message?: string | null;
|
|
||||||
error?: string | null;
|
|
||||||
progress: number;
|
|
||||||
progressTotal: number;
|
|
||||||
result?: any;
|
|
||||||
createdAt?: string | null;
|
|
||||||
updatedAt?: string | null;
|
|
||||||
startedAt?: string | null;
|
|
||||||
finishedAt?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DigitalHumanJobResponse = {
|
|
||||||
success: boolean;
|
|
||||||
job: DigitalHumanJob;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DigitalHumanJobsResponse = {
|
|
||||||
success: boolean;
|
|
||||||
jobs: DigitalHumanJob[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MoneyPrinterJob = DigitalHumanJob;
|
|
||||||
|
|
||||||
export type MoneyPrinterJobResponse = {
|
|
||||||
success: boolean;
|
|
||||||
job: MoneyPrinterJob;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MoneyPrinterJobsResponse = {
|
|
||||||
success: boolean;
|
|
||||||
jobs: MoneyPrinterJob[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Sites
|
// Sites
|
||||||
getSites: async () => {
|
getSites: async () => {
|
||||||
@@ -2054,11 +2000,6 @@ export const api = {
|
|||||||
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
||||||
deleteWorkspaceAsset: (publicId: string) =>
|
deleteWorkspaceAsset: (publicId: string) =>
|
||||||
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||||
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
|
|
||||||
request<WorkspaceBatchDeleteResponse>("/api/workspace/assets/batch-delete", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ publicIds }),
|
|
||||||
}),
|
|
||||||
getWorkflowNodeTypes: () =>
|
getWorkflowNodeTypes: () =>
|
||||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||||
@@ -2079,34 +2020,6 @@ export const api = {
|
|||||||
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
request<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/workflow-runs${buildQueryString(params)}`),
|
||||||
getWorkflowRun: (publicId: string) =>
|
getWorkflowRun: (publicId: string) =>
|
||||||
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||||
createDigitalHumanJob: (data: any) =>
|
|
||||||
request<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
}),
|
|
||||||
getDigitalHumanJobs: (params?: { limit?: number }) =>
|
|
||||||
request<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
|
|
||||||
getDigitalHumanJob: (id: string) =>
|
|
||||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
|
||||||
cancelDigitalHumanJob: (id: string) =>
|
|
||||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
|
||||||
clearDigitalHumanJobs: () =>
|
|
||||||
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
|
||||||
createMoneyPrinterJob: (data: any) =>
|
|
||||||
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
}),
|
|
||||||
getMoneyPrinterJobs: (params?: { limit?: number }) =>
|
|
||||||
request<MoneyPrinterJobsResponse>(`/api/money-printer/jobs${buildQueryString(params)}`),
|
|
||||||
getMoneyPrinterJob: (id: string) =>
|
|
||||||
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}`),
|
|
||||||
cancelMoneyPrinterJob: (id: string) =>
|
|
||||||
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
|
||||||
clearMoneyPrinterJobs: () =>
|
|
||||||
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
|
|
||||||
};
|
};
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
register: (data: {
|
register: (data: {
|
||||||
@@ -2134,7 +2047,6 @@ export const userApi = {
|
|||||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||||
getSummary: () => userRequest("/api/user/summary"),
|
getSummary: () => userRequest("/api/user/summary"),
|
||||||
getDashboard: () => userRequest("/api/user/dashboard"),
|
|
||||||
getModelsMarketplace: (options?: {
|
getModelsMarketplace: (options?: {
|
||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
includePricing?: boolean;
|
includePricing?: boolean;
|
||||||
@@ -2157,11 +2069,6 @@ export const userApi = {
|
|||||||
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
||||||
deleteWorkspaceAsset: (publicId: string) =>
|
deleteWorkspaceAsset: (publicId: string) =>
|
||||||
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||||
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
|
|
||||||
userRequest<WorkspaceBatchDeleteResponse>("/api/user/workspace/assets/batch-delete", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ publicIds }),
|
|
||||||
}),
|
|
||||||
getWorkflowNodeTypes: () =>
|
getWorkflowNodeTypes: () =>
|
||||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||||
@@ -2182,32 +2089,4 @@ export const userApi = {
|
|||||||
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
userRequest<{ success: boolean; items: WorkflowRunSummary[] }>(`/api/user/workflow-runs${buildQueryString(params)}`),
|
||||||
getWorkflowRun: (publicId: string) =>
|
getWorkflowRun: (publicId: string) =>
|
||||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||||
createDigitalHumanJob: (data: any) =>
|
|
||||||
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
}),
|
|
||||||
getDigitalHumanJobs: (params?: { limit?: number }) =>
|
|
||||||
userRequest<DigitalHumanJobsResponse>(`/api/digital-human/jobs${buildQueryString(params)}`),
|
|
||||||
getDigitalHumanJob: (id: string) =>
|
|
||||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
|
||||||
cancelDigitalHumanJob: (id: string) =>
|
|
||||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
|
||||||
clearDigitalHumanJobs: () =>
|
|
||||||
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
|
||||||
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,8 +3,6 @@ import { clearSession } from './authSession.js';
|
|||||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||||
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||||
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
|
|
||||||
export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY;
|
|
||||||
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
|
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
|
||||||
|
|
||||||
type StorageLike = {
|
type StorageLike = {
|
||||||
@@ -26,6 +24,5 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
|||||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||||
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
|
||||||
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ interface ModelAnalysisPanelProps {
|
|||||||
data?: ModelAnalysisData | null;
|
data?: ModelAnalysisData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: Array<{ key: TabKey; label: string; code: string }> = [
|
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
|
||||||
{ key: 'spend', label: '消耗分布', code: 'SP' },
|
{ key: 'spend', label: '消耗分布', icon: '💰' },
|
||||||
{ key: 'trend', label: '消耗趋势', code: 'TR' },
|
{ key: 'trend', label: '消耗趋势', icon: '📈' },
|
||||||
{ key: 'calls', label: '调用分布', code: 'CL' },
|
{ key: 'calls', label: '调用分布', icon: '🔄' },
|
||||||
{ key: 'rank', label: '排行榜', code: 'RK' },
|
{ key: 'rank', label: '排行榜', icon: '🏆' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function toSafeNumber(value: unknown): number {
|
function toSafeNumber(value: unknown): number {
|
||||||
@@ -46,13 +46,6 @@ function formatPercent(value: number): string {
|
|||||||
return `${toSafeNumber(value).toFixed(1)}%`;
|
return `${toSafeNumber(value).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPiePercent(_text: unknown, datum: unknown): string {
|
|
||||||
const record = datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
|
|
||||||
const percent = toSafeNumber(record._percent_);
|
|
||||||
if (percent <= 0) return '';
|
|
||||||
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyBlock() {
|
function EmptyBlock() {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state" style={{ padding: 28 }}>
|
<div className="empty-state" style={{ padding: 28 }}>
|
||||||
@@ -78,16 +71,15 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
|||||||
const callRanking = (data?.callRanking || []).slice(0, 10);
|
const callRanking = (data?.callRanking || []).slice(0, 10);
|
||||||
|
|
||||||
const hasData = totals.calls > 0
|
const hasData = totals.calls > 0
|
||||||
|| spendDistribution.some((item) => toSafeNumber(item.spend) > 0)
|
|| spendDistribution.length > 0
|
||||||
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0)
|
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
|
||||||
|| callsDistribution.some((item) => toSafeNumber(item.calls) > 0);
|
|
||||||
|
|
||||||
const spendBarSpec = useMemo(() => ({
|
const spendBarSpec = useMemo(() => ({
|
||||||
type: 'bar' as const,
|
type: 'bar' as const,
|
||||||
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
|
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
|
||||||
xField: 'value', yField: 'model', direction: 'horizontal' as const,
|
xField: 'value', yField: 'model', direction: 'horizontal' as const,
|
||||||
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
|
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
|
||||||
label: { visible: true, position: 'right', formatMethod: (text: unknown) => formatCurrency(Number(text || 0)), style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
|
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
|
||||||
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
|
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
|
||||||
animation: true, background: 'transparent',
|
animation: true, background: 'transparent',
|
||||||
}), [spendDistribution, labelColor]);
|
}), [spendDistribution, labelColor]);
|
||||||
@@ -110,7 +102,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
|||||||
valueField: 'calls', categoryField: 'model',
|
valueField: 'calls', categoryField: 'model',
|
||||||
outerRadius: 0.8, innerRadius: 0.55,
|
outerRadius: 0.8, innerRadius: 0.55,
|
||||||
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
||||||
label: { visible: true, position: 'outside', formatMethod: formatPiePercent, style: { fill: labelColor, fontSize: 12, stroke: 'transparent' } },
|
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
|
||||||
legends: { visible: false },
|
legends: { visible: false },
|
||||||
animation: true,
|
animation: true,
|
||||||
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
|
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
|
||||||
@@ -146,8 +138,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
|||||||
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
>
|
>
|
||||||
<span className="pill-tab-code">{tab.code}</span>
|
{tab.icon} {tab.label}
|
||||||
{tab.label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
|||||||
|
|
||||||
interface SiteDistributionData {
|
interface SiteDistributionData {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
platform?: string | null;
|
platform: string;
|
||||||
totalBalance?: number;
|
totalBalance: number;
|
||||||
totalSpend?: number;
|
totalSpend: number;
|
||||||
accountCount?: number;
|
accountCount: number;
|
||||||
spend?: number;
|
|
||||||
calls?: number;
|
|
||||||
tokens?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteDistributionChartProps {
|
interface SiteDistributionChartProps {
|
||||||
@@ -29,13 +26,6 @@ function safeNumber(value: unknown): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercentLabel(datum: unknown): string {
|
|
||||||
const item = coerceDatumRecord(datum);
|
|
||||||
const percent = safeNumber(item._percent_);
|
|
||||||
if (percent <= 0) return '';
|
|
||||||
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkeletonCircle() {
|
function SkeletonCircle() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -108,11 +98,8 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
return data.map((item) => ({
|
return data.map((item) => ({
|
||||||
siteName: item.siteName,
|
siteName: item.siteName,
|
||||||
platform: item.platform,
|
platform: item.platform,
|
||||||
value: safeNumber(viewMode === 'balance'
|
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
|
||||||
? item.totalBalance
|
|
||||||
: item.totalSpend ?? item.spend),
|
|
||||||
accountCount: safeNumber(item.accountCount),
|
accountCount: safeNumber(item.accountCount),
|
||||||
calls: safeNumber(item.calls),
|
|
||||||
}));
|
}));
|
||||||
}, [data, viewMode]);
|
}, [data, viewMode]);
|
||||||
|
|
||||||
@@ -131,12 +118,7 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
outerRadius: 0.8,
|
outerRadius: 0.8,
|
||||||
innerRadius: 0.55,
|
innerRadius: 0.55,
|
||||||
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
||||||
label: {
|
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
|
||||||
visible: true,
|
|
||||||
position: 'outside',
|
|
||||||
formatMethod: (_text: unknown, datum: unknown) => formatPercentLabel(datum),
|
|
||||||
style: { fill: labelColor, fontSize: 12, stroke: 'transparent' },
|
|
||||||
},
|
|
||||||
legends: { visible: false },
|
legends: { visible: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
mark: {
|
mark: {
|
||||||
@@ -167,13 +149,6 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
return String(item.accountCount || 0);
|
return String(item.accountCount || 0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '调用',
|
|
||||||
value: (datum: unknown) => {
|
|
||||||
const item = coerceDatumRecord(datum);
|
|
||||||
return String(item.calls || 0);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] as any,
|
] as any,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -184,7 +159,6 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
}, [chartData, hasData, labelColor]);
|
}, [chartData, hasData, labelColor]);
|
||||||
|
|
||||||
const formatValue = (value: number): string => {
|
const formatValue = (value: number): string => {
|
||||||
if (viewMode === 'balance' && value === 0) return '$0.00';
|
|
||||||
if (value >= 1000) return `$${value.toFixed(2)}`;
|
if (value >= 1000) return `$${value.toFixed(2)}`;
|
||||||
if (value >= 1) return `$${value.toFixed(3)}`;
|
if (value >= 1) return `$${value.toFixed(3)}`;
|
||||||
return `$${value.toFixed(6)}`;
|
return `$${value.toFixed(6)}`;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { VChart } from '@visactor/react-vchart';
|
import { VChart } from '@visactor/react-vchart';
|
||||||
import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -8,10 +7,7 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
|||||||
|
|
||||||
interface SiteTrendData {
|
interface SiteTrendData {
|
||||||
date: string;
|
date: string;
|
||||||
sites?: Record<string, { spend?: number; calls?: number; tokens?: number }>;
|
sites: Record<string, { spend: number; calls: number }>;
|
||||||
spend?: number;
|
|
||||||
calls?: number;
|
|
||||||
tokens?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteTrendChartProps {
|
interface SiteTrendChartProps {
|
||||||
@@ -47,26 +43,20 @@ const COLOR_PALETTE = [
|
|||||||
|
|
||||||
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||||
const [metric, setMetric] = useState<Metric>('spend');
|
const [metric, setMetric] = useState<Metric>('spend');
|
||||||
const labelColor = useThemeLabelColor();
|
|
||||||
|
|
||||||
/* ---------- data transform ---------- */
|
/* ---------- data transform ---------- */
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
if (!data || data.length === 0) return [];
|
if (!data || data.length === 0) return [];
|
||||||
return data.flatMap((d) => {
|
return data.flatMap((d) =>
|
||||||
const sites = d.sites && Object.keys(d.sites).length > 0
|
Object.entries(d.sites).map(([site, v]) => ({
|
||||||
? d.sites
|
|
||||||
: { 全部站点: { spend: d.spend, calls: d.calls, tokens: d.tokens } };
|
|
||||||
return Object.entries(sites).map(([site, v]) => ({
|
|
||||||
date: d.date,
|
date: d.date,
|
||||||
site,
|
site,
|
||||||
value: metric === 'spend' ? Number(v.spend ?? 0) : Number(v.calls ?? 0),
|
value: metric === 'spend' ? v.spend : v.calls,
|
||||||
}));
|
})),
|
||||||
});
|
);
|
||||||
}, [data, metric]);
|
}, [data, metric]);
|
||||||
|
|
||||||
const hasPositiveValue = flatData.some((item) => Number(item.value || 0) > 0);
|
|
||||||
|
|
||||||
/* ---------- loading state ---------- */
|
/* ---------- loading state ---------- */
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -82,7 +72,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
|||||||
|
|
||||||
/* ---------- empty state ---------- */
|
/* ---------- empty state ---------- */
|
||||||
|
|
||||||
if (!data || data.length === 0 || flatData.length === 0 || !hasPositiveValue) {
|
if (!data || data.length === 0 || flatData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
@@ -90,7 +80,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="empty-state" style={{ padding: 48 }}>
|
<div className="empty-state" style={{ padding: 48 }}>
|
||||||
<div className="empty-state-title">暂无趋势数据</div>
|
<div className="empty-state-title">暂无趋势数据</div>
|
||||||
<div className="empty-state-desc">所选时间范围内还没有有效的{metric === 'spend' ? '消耗' : '调用'}记录</div>
|
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -154,38 +144,22 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
|||||||
axes: [
|
axes: [
|
||||||
{
|
{
|
||||||
orient: 'bottom',
|
orient: 'bottom',
|
||||||
label: {
|
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
||||||
style: { fontSize: 11, fill: labelColor },
|
|
||||||
formatMethod: (value: string) => {
|
|
||||||
const text = String(value || '');
|
|
||||||
if (text.length >= 10) return text.slice(5, 10);
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
orient: 'left',
|
orient: 'left',
|
||||||
label: {
|
label: {
|
||||||
style: { fontSize: 11, fill: labelColor },
|
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
||||||
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: [3, 5], lineWidth: 1 } },
|
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
|
||||||
domainLine: { visible: false },
|
domainLine: { visible: false },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
color: COLOR_PALETTE,
|
color: COLOR_PALETTE,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
padding: { left: 48, right: 18, top: 12, bottom: 36 },
|
padding: { left: 8, right: 16, top: 8, bottom: 8 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- render ---------- */
|
/* ---------- render ---------- */
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'共 ${models.length} 个模型': 'Total ${models.length} models',
|
'共 ${models.length} 个模型': 'Total ${models.length} models',
|
||||||
'关闭': 'closure',
|
'关闭': 'closure',
|
||||||
'关于': 'about',
|
'关于': 'about',
|
||||||
'关于 BossAI': 'About BossAI',
|
'关于 BoosAPI': 'About BoosAPI',
|
||||||
'管理端 IP 白名单': 'Management IP whitelist',
|
'管理端 IP 白名单': 'Management IP whitelist',
|
||||||
'管理员': 'administrator',
|
'管理员': 'administrator',
|
||||||
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
|
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
|
||||||
@@ -259,7 +259,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'拉取分组失败': 'Failed to pull group',
|
'拉取分组失败': 'Failed to pull group',
|
||||||
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
|
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
|
||||||
'冷却中': 'Cooling down',
|
'冷却中': 'Cooling down',
|
||||||
'例如 BossAI': 'For example BossAI',
|
'例如 BoosAPI': 'For example BoosAPI',
|
||||||
'例如: admin@example.com': 'For example: admin@example.com',
|
'例如: admin@example.com': 'For example: admin@example.com',
|
||||||
'例如: smtp.qq.com': 'For example: smtp.qq.com',
|
'例如: smtp.qq.com': 'For example: smtp.qq.com',
|
||||||
'例如: target@example.com': 'For example: target@example.com',
|
'例如: target@example.com': 'For example: target@example.com',
|
||||||
@@ -727,7 +727,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'智能路由引擎': 'Intelligent routing engine',
|
'智能路由引擎': 'Intelligent routing engine',
|
||||||
'智谱 AI': 'Wisdom spectrum AI',
|
'智谱 AI': 'Wisdom spectrum AI',
|
||||||
'中': 'ZH',
|
'中': 'ZH',
|
||||||
'聚合多模型多模态的 AI 应用平台 — 统一接入文本、图像、视频、语音等能力,构建可复用的 AI 工作流和应用。': 'A multimodal AI application platform for many models — unify text, image, video, and voice capabilities to build reusable AI workflows and applications.',
|
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'A transit station of transit stations — Aggregate your New API / One API / OneHub and other AI transit stations registered everywhere into a unified gateway. One API Key, one entrance, automatic model discovery, intelligent routing, and cost optimization.',
|
||||||
'重建路由失败': 'Failed to rebuild route',
|
'重建路由失败': 'Failed to rebuild route',
|
||||||
'重新登录或同步新令牌': 'Log back in or sync new token',
|
'重新登录或同步新令牌': 'Log back in or sync new token',
|
||||||
'重新加载当前站点': 'Reload current site',
|
'重新加载当前站点': 'Reload current site',
|
||||||
@@ -770,7 +770,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'LDOH Cookie 已更新': 'LDOH Cookie updated',
|
'LDOH Cookie 已更新': 'LDOH Cookie updated',
|
||||||
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
|
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
|
||||||
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
|
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
|
||||||
'BossAI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BossAI is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
|
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'boosapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
|
||||||
'model 不能为空': 'model cannot be empty',
|
'model 不能为空': 'model cannot be empty',
|
||||||
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
|
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
|
||||||
'models 必须是非空数组': 'models must be a non-empty array',
|
'models 必须是非空数组': 'models must be a non-empty array',
|
||||||
|
|||||||
+6
-6
@@ -19,8 +19,8 @@ const zhToEn: Record<string, string> = {
|
|||||||
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
|
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
|
||||||
'验证中...': 'Verifying...',
|
'验证中...': 'Verifying...',
|
||||||
'登录': 'Sign In',
|
'登录': 'Sign In',
|
||||||
'聚合多模型多模态的 AI 应用平台': 'A multimodal AI application platform for many models',
|
'中转站的中转站': 'The Hub of Hubs',
|
||||||
'聚合文本、图像、视频、语音等多模态能力,统一接入多模型工作流和 AI 应用。': 'Unify text, image, video, and voice capabilities across multi-model workflows and AI applications.',
|
'把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。': 'Turn fragmented New API / One API / OneHub sites into one unified gateway with auto model discovery, smart routing, and better cost control.',
|
||||||
'兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API',
|
'兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API',
|
||||||
'用户名不能为空': 'Username cannot be empty',
|
'用户名不能为空': 'Username cannot be empty',
|
||||||
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
||||||
@@ -84,7 +84,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'模型操练场': 'Model Playground',
|
'模型操练场': 'Model Playground',
|
||||||
'模型测试': 'Model Testing',
|
'模型测试': 'Model Testing',
|
||||||
'关于': 'About',
|
'关于': 'About',
|
||||||
'关于 BossAI': 'About BossAI',
|
'关于 BoosAPI': 'About BoosAPI',
|
||||||
'站点文档': 'Site Docs',
|
'站点文档': 'Site Docs',
|
||||||
'任务状态已更新': 'Task status updated',
|
'任务状态已更新': 'Task status updated',
|
||||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||||
@@ -221,7 +221,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'代理端点': 'Proxy Endpoints',
|
'代理端点': 'Proxy Endpoints',
|
||||||
'路由行为': 'Routing Behavior',
|
'路由行为': 'Routing Behavior',
|
||||||
'指标口径': 'Metric Notes',
|
'指标口径': 'Metric Notes',
|
||||||
'BossAI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BossAI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
'BoosAPI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BoosAPI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
||||||
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
|
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
|
||||||
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
|
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
|
||||||
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
|
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
|
||||||
@@ -296,7 +296,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'清空中...': 'Clearing...',
|
'清空中...': 'Clearing...',
|
||||||
|
|
||||||
// About page
|
// About page
|
||||||
'聚合多模型多模态的 AI 应用平台 — 统一接入文本、图像、视频、语音等能力,构建可复用的 AI 工作流和应用。': 'A multimodal AI application platform for many models — unify text, image, video, and voice capabilities to build reusable AI workflows and applications.',
|
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'The hub of hubs — aggregate all your New API / One API / OneHub relay sites into one unified gateway. One API Key, one endpoint, with auto model discovery, smart routing, and cost optimization.',
|
||||||
'核心特色': 'Key Features',
|
'核心特色': 'Key Features',
|
||||||
'统一代理网关': 'Unified Proxy Gateway',
|
'统一代理网关': 'Unified Proxy Gateway',
|
||||||
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
|
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
|
||||||
@@ -322,7 +322,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'零配置嵌入式数据库': 'Zero-config embedded database',
|
'零配置嵌入式数据库': 'Zero-config embedded database',
|
||||||
'项目链接': 'Project Links',
|
'项目链接': 'Project Links',
|
||||||
'数据与隐私': 'Data & Privacy',
|
'数据与隐私': 'Data & Privacy',
|
||||||
'BossAI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BossAI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
||||||
|
|
||||||
// User SaaS translations
|
// User SaaS translations
|
||||||
'用户登录': 'User Login',
|
'用户登录': 'User Login',
|
||||||
|
|||||||
+100
-2377
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="BossAI - 聚合多模型多模态的 AI 应用平台" />
|
<meta name="description" content="BoosAPI - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
|
||||||
<title>BossAI</title>
|
<title>BoosAPI</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
+98
-90
@@ -7,10 +7,10 @@ const FEATURES = [
|
|||||||
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
||||||
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
||||||
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
||||||
{ icon: 'WF', title: '工作流画布', desc: '内嵌 ComfyUI 前端,支持 Comfy Cloud、RunningHub、外部 ComfyUI 与 BossAI API-native 执行器。' },
|
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
|
||||||
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||||
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
||||||
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BossAI 模型路由。' },
|
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
|
||||||
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
||||||
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
|
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
|
||||||
];
|
];
|
||||||
@@ -21,8 +21,7 @@ const TECH_STACK = [
|
|||||||
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
||||||
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
||||||
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
||||||
{ name: 'Workflow UI', desc: '内嵌官方 ComfyUI 前端资产,按后端模式代理 Cloud、RunningHub 或外部 ComfyUI。' },
|
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
|
||||||
{ name: 'Multi Backend', desc: '支持 cloud / runninghub / external / BossAI / docker 五种工作流后端配置。' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENDPOINTS = [
|
const ENDPOINTS = [
|
||||||
@@ -35,137 +34,146 @@ const ENDPOINTS = [
|
|||||||
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const WORKFLOW_BACKENDS = [
|
|
||||||
{ name: 'cloud', desc: 'Comfy Cloud API 后端,适合使用官方订阅能力和云端执行。' },
|
|
||||||
{ name: 'runninghub', desc: 'RunningHub API 后端,适合复用 RunningHub 插件、模型与工作流生态。' },
|
|
||||||
{ name: 'external', desc: '连接你自己部署的完整版 ComfyUI,BossAI 负责统一代理与嵌入。' },
|
|
||||||
{ name: 'BossAI', desc: '使用 Rust API-native 工作流执行器,直接调用后台模型路由。' },
|
|
||||||
{ name: 'docker', desc: '本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境。' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
return (
|
||||||
<div className="about-page animate-fade-in">
|
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
|
||||||
<div className="page-header about-header">
|
{/* Header */}
|
||||||
<div>
|
<div className="page-header" style={{ marginBottom: 14 }}>
|
||||||
<h2 className="page-title">{tr('关于 BossAI')}</h2>
|
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
|
||||||
<p className="page-subtitle">{tr('统一网关、模型路由、工作流与多模态助手的运行概览。')}</p>
|
|
||||||
</div>
|
|
||||||
<span className="about-version">v{VERSION}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="about-hero animate-slide-up stagger-1">
|
{/* Hero card */}
|
||||||
<div className="about-brand">
|
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="BossAI"
|
alt="BoosAPI"
|
||||||
className="about-logo"
|
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="about-brand-name">BossAI</div>
|
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
|
||||||
<div className="about-brand-meta">{tr('自托管 AI 模型网关与工作台')}</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="about-hero-copy">
|
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||||
{tr('BossAI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent、Workflow Studio 和外部 API 都能使用后台已经配置好的模型。')}
|
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
|
||||||
</p>
|
</div>
|
||||||
<div className="about-chip-row">
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||||
{['Rust Backend', 'OpenAI Compatible', 'Claude Messages', 'ComfyUI Frontend', 'Comfy Cloud', 'RunningHub'].map((tag) => (
|
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
||||||
<span key={tag} className="about-chip">
|
<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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="about-section animate-slide-up stagger-2">
|
{/* Features */}
|
||||||
<div className="about-section-heading">
|
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
|
||||||
<h3>{tr('核心特色')}</h3>
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
|
||||||
<p>{tr('围绕网关、模型、工作流和运维审计组织能力。')}</p>
|
<div style={{
|
||||||
</div>
|
display: 'grid',
|
||||||
<div className="about-feature-grid">
|
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
{FEATURES.map((f) => (
|
{FEATURES.map((f) => (
|
||||||
<div key={f.title} className="about-feature-row">
|
<div key={f.title} style={{
|
||||||
<span className="about-feature-mark">
|
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}
|
{f.icon}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="about-feature-title">{tr(f.title)}</div>
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
|
||||||
<div className="about-feature-desc">
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
|
||||||
{tr(f.desc)}
|
{tr(f.desc)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<div className="about-two-column animate-slide-up stagger-3">
|
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
|
||||||
<section className="about-section">
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3>
|
||||||
<div className="about-section-heading">
|
<div style={{ display: 'grid', gap: 9 }}>
|
||||||
<h3>{tr('工作流后端模式')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="about-definition-list">
|
|
||||||
{WORKFLOW_BACKENDS.map((backend) => (
|
|
||||||
<div
|
|
||||||
key={backend.name}
|
|
||||||
className="about-definition-row"
|
|
||||||
>
|
|
||||||
<code>
|
|
||||||
{backend.name}
|
|
||||||
</code>
|
|
||||||
<div>
|
|
||||||
{tr(backend.desc)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="about-section">
|
|
||||||
<div className="about-section-heading">
|
|
||||||
<h3>{tr('兼容入口')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="about-definition-list">
|
|
||||||
{ENDPOINTS.map((endpoint) => (
|
{ENDPOINTS.map((endpoint) => (
|
||||||
<div
|
<div
|
||||||
key={endpoint.path}
|
key={endpoint.path}
|
||||||
className="about-definition-row about-endpoint-row"
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(190px, 260px) 1fr',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'start',
|
||||||
|
padding: '9px 0',
|
||||||
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<code>
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-primary)' }}>
|
||||||
{endpoint.path}
|
{endpoint.path}
|
||||||
</code>
|
</code>
|
||||||
<div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.6 }}>
|
||||||
{tr(endpoint.desc)}
|
{tr(endpoint.desc)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="about-section animate-slide-up stagger-4">
|
{/* Tech Stack */}
|
||||||
<div className="about-section-heading">
|
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
|
||||||
<h3>{tr('技术栈')}</h3>
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
|
||||||
</div>
|
<div style={{
|
||||||
<div className="about-tech-grid">
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
{TECH_STACK.map((t) => (
|
{TECH_STACK.map((t) => (
|
||||||
<div key={t.name} className="about-tech-item">
|
<div key={t.name} style={{
|
||||||
<div className="about-tech-name">{t.name}</div>
|
padding: '10px 14px',
|
||||||
<div className="about-tech-desc">
|
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)}
|
{tr(t.desc)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="about-privacy animate-slide-up stagger-5">
|
{/* Privacy */}
|
||||||
<h3>{tr('数据与隐私')}</h3>
|
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
|
||||||
<p>
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
|
||||||
{tr('BossAI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||||
</p>
|
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('About page', () => {
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
const text = collectText(root.root);
|
const text = collectText(root.root);
|
||||||
expect(text).toContain('关于 BossAI');
|
expect(text).toContain('关于 BoosAPI');
|
||||||
expect(text).toContain('核心特色');
|
expect(text).toContain('核心特色');
|
||||||
expect(text).toContain('兼容入口');
|
expect(text).toContain('兼容入口');
|
||||||
expect(text).not.toContain('更新提醒');
|
expect(text).not.toContain('更新提醒');
|
||||||
|
|||||||
@@ -1910,7 +1910,7 @@ export default function Accounts() {
|
|||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
配置 refresh_token 后,BossAI 会在 JWT 临近过期或
|
配置 refresh_token 后,BoosAPI 会在 JWT 临近过期或
|
||||||
401 时自动续期并回写新 token。
|
401 时自动续期并回写新 token。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -952,7 +952,7 @@ export default function ComfyUIAgent() {
|
|||||||
|
|
||||||
const exportWorkflow = () => {
|
const exportWorkflow = () => {
|
||||||
const workflow = latestWorkflow || {
|
const workflow = latestWorkflow || {
|
||||||
name: 'BossAI Video Agent Project',
|
name: 'BoosAPI Video Agent Project',
|
||||||
version: 1,
|
version: 1,
|
||||||
messages: messages
|
messages: messages
|
||||||
.filter((message) => message.role !== 'system')
|
.filter((message) => message.role !== 'system')
|
||||||
|
|||||||
+52
-16
@@ -17,11 +17,11 @@ const SiteTrendChart = lazy(
|
|||||||
|
|
||||||
function getGreeting(): string {
|
function getGreeting(): string {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
if (hour < 6) return "夜深了";
|
if (hour < 6) return "🌙 夜深了";
|
||||||
if (hour < 11) return "早上好";
|
if (hour < 11) return "☀️ 早上好";
|
||||||
if (hour < 13) return "中午好";
|
if (hour < 13) return "👋 中午好";
|
||||||
if (hour < 18) return "下午好";
|
if (hour < 18) return "🌤️ 下午好";
|
||||||
return "晚上好";
|
return "🌙 晚上好";
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeNumber(value: unknown): number {
|
function safeNumber(value: unknown): number {
|
||||||
@@ -88,7 +88,7 @@ function formatAvailabilityPercent(value: number | null | undefined): string {
|
|||||||
Number.isNaN(value) ||
|
Number.isNaN(value) ||
|
||||||
!Number.isFinite(value)
|
!Number.isFinite(value)
|
||||||
)
|
)
|
||||||
return "-";
|
return "—";
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +576,7 @@ export default function Dashboard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-page animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -969,8 +969,25 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 站点级分析 */}
|
{/* 站点级分析 */}
|
||||||
<div className="dashboard-section-head">
|
<div
|
||||||
<div className="dashboard-section-title">
|
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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
@@ -987,12 +1004,24 @@ export default function Dashboard({
|
|||||||
</svg>
|
</svg>
|
||||||
站点分析
|
站点分析
|
||||||
</div>
|
</div>
|
||||||
<div className="dashboard-range-tabs">
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
{[7, 30, 90].map((d) => (
|
{[7, 30, 90].map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
onClick={() => setTrendDays(d)}
|
onClick={() => setTrendDays(d)}
|
||||||
className={`dashboard-range-tab ${trendDays === d ? "is-active" : ""}`}
|
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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{d}天
|
{d}天
|
||||||
</button>
|
</button>
|
||||||
@@ -1000,7 +1029,14 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-chart-grid">
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||||
<Suspense fallback={<ChartFallback height={320} />}>
|
<Suspense fallback={<ChartFallback height={320} />}>
|
||||||
<SiteDistributionChart
|
<SiteDistributionChart
|
||||||
@@ -1154,7 +1190,7 @@ export default function Dashboard({
|
|||||||
>
|
>
|
||||||
{site.averageLatencyMs != null
|
{site.averageLatencyMs != null
|
||||||
? `${site.averageLatencyMs}ms`
|
? `${site.averageLatencyMs}ms`
|
||||||
: "暂无"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
<span className="site-observability-metric-sep">·</span>
|
<span className="site-observability-metric-sep">·</span>
|
||||||
<span>{Math.round(site.totalRequests || 0)} 次</span>
|
<span>{Math.round(site.totalRequests || 0)} 次</span>
|
||||||
@@ -1180,7 +1216,7 @@ export default function Dashboard({
|
|||||||
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
|
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
|
||||||
bucket.averageLatencyMs != null
|
bucket.averageLatencyMs != null
|
||||||
? `平均响应:${bucket.averageLatencyMs}ms`
|
? `平均响应:${bucket.averageLatencyMs}ms`
|
||||||
: "平均响应:暂无",
|
: "平均响应:—",
|
||||||
].join(" · ")}
|
].join(" · ")}
|
||||||
data-tooltip-align="start"
|
data-tooltip-align="start"
|
||||||
title={[
|
title={[
|
||||||
@@ -1191,7 +1227,7 @@ export default function Dashboard({
|
|||||||
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
||||||
bucket.averageLatencyMs != null
|
bucket.averageLatencyMs != null
|
||||||
? `平均响应 ${bucket.averageLatencyMs}ms`
|
? `平均响应 ${bucket.averageLatencyMs}ms`
|
||||||
: "平均响应 暂无",
|
: "平均响应 —",
|
||||||
].join(" | ")}
|
].join(" | ")}
|
||||||
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
|
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
|
||||||
/>
|
/>
|
||||||
@@ -1549,7 +1585,7 @@ export default function Dashboard({
|
|||||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||||
{proxy24hTotal > 0
|
{proxy24hTotal > 0
|
||||||
? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}`
|
? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}`
|
||||||
: "-"}
|
: "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+267
-585
@@ -1,356 +1,103 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { CSSProperties, useMemo, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary, type WorkspaceAsset } from '../api.js';
|
import { fetchSessionResponse } from '../api.js';
|
||||||
import type { SessionAudience } from '../authSession.js';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
|
|
||||||
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
type Mode = 'audio' | 'image' | 'text';
|
||||||
type UploadPurpose = 'avatar' | 'audio';
|
|
||||||
type MappingKey = keyof typeof DEFAULT_MAPPING;
|
|
||||||
|
|
||||||
const DEFAULT_MAPPING = {
|
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
|
||||||
prompt: { nodeId: '', field: 'prompt' },
|
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
|
||||||
script: { nodeId: '', field: 'text' },
|
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
|
||||||
avatar: { nodeId: '', field: 'image' },
|
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
|
||||||
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 节点按文案生成声音。' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function terminal(status: string) {
|
const MODEL_PRESETS = [
|
||||||
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
'longcat-video-avatar',
|
||||||
}
|
'doubao-seedance-1-0-lite-i2v-250428',
|
||||||
|
'doubao-seedance-1-0-lite-t2v-250428',
|
||||||
|
'wan2-1-14b-i2v-250225',
|
||||||
|
];
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
|
||||||
if (status === 'succeeded') return '完成';
|
|
||||||
if (status === 'failed') return '失败';
|
|
||||||
if (status === 'cancelled') return '已取消';
|
|
||||||
if (status === 'running') return '生成中';
|
|
||||||
return '排队中';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseError(response: Response) {
|
async function parseError(response: Response) {
|
||||||
return response.text().then((text) => {
|
const text = await response.text().catch(() => '');
|
||||||
if (!text) return `HTTP ${response.status}`;
|
if (!text) return `HTTP ${response.status}`;
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(text);
|
const value = JSON.parse(text);
|
||||||
return value?.error?.message || value?.message || text;
|
return value?.error?.message || value?.message || text;
|
||||||
} catch {
|
} catch {
|
||||||
return text;
|
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 includesAny(value: string, words: string[]) {
|
function extractTaskId(value: any): string {
|
||||||
const text = value.toLowerCase();
|
return value?.response?.id
|
||||||
return words.some((word) => text.includes(word));
|
|| value?.response?.task_id
|
||||||
|
|| value?.response?.data?.id
|
||||||
|
|| value?.response?.data?.task_id
|
||||||
|
|| value?.id
|
||||||
|
|| value?.task_id
|
||||||
|
|| '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNode(
|
function copyText(value: string) {
|
||||||
nodes: ReturnType<typeof collectPromptNodes>,
|
if (!value) return;
|
||||||
words: string[],
|
void navigator.clipboard?.writeText(value);
|
||||||
inputWords: string[] = [],
|
|
||||||
) {
|
|
||||||
return nodes.find((node) => {
|
|
||||||
const haystack = `${node.classType} ${node.title}`;
|
|
||||||
const inputNames = Object.keys(node.inputs || {}).join(' ');
|
|
||||||
return includesAny(haystack, words) || (inputWords.length > 0 && includesAny(inputNames, inputWords));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
|
|
||||||
if (!workflow) return DEFAULT_MAPPING;
|
|
||||||
const nodes = collectPromptNodes(workflow.graph);
|
|
||||||
const promptNode = findNode(nodes, ['prompt', 'positive', 'textgenerate', 'text generate', 'wan', 'ltx'], ['prompt']);
|
|
||||||
const scriptNode = findNode(nodes, ['script', 'text', 'tts', 'voice', 'audio'], ['text', 'script', 'caption']);
|
|
||||||
const avatarNode = findNode(nodes, ['loadimage', 'image', 'video', 'avatar', 'portrait', 'person'], ['image', 'video', 'avatar']);
|
|
||||||
const audioNode = findNode(nodes, ['audio', 'sound', 'voice', 'tts', 'load audio'], ['audio', 'voice']);
|
|
||||||
const outputNode = findNode(nodes, ['savevideo', 'save video', 'previewvideo', 'video combine', 'vhs', 'output'], ['video', 'filename_prefix'])
|
|
||||||
|| [...nodes].reverse().find((node) => includesAny(`${node.classType} ${node.title}`, ['save', 'preview', 'output']));
|
|
||||||
|
|
||||||
return {
|
|
||||||
prompt: { nodeId: promptNode?.id || '', field: promptNode?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
|
||||||
script: { nodeId: scriptNode?.id || promptNode?.id || '', field: scriptNode?.inputs?.script !== undefined ? 'script' : 'text' },
|
|
||||||
avatar: { nodeId: avatarNode?.id || '', field: avatarNode?.inputs?.video !== undefined ? 'video' : 'image' },
|
|
||||||
referenceAudio: { nodeId: audioNode?.id || '', field: 'audio' },
|
|
||||||
voiceReference: { nodeId: audioNode?.id || '', field: 'audio' },
|
|
||||||
output: { nodeId: outputNode?.id || '', field: outputNode?.inputs?.filename_prefix !== undefined ? 'filename_prefix' : 'video' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function assetFileUrl(asset: WorkspaceAsset, audience: SessionAudience) {
|
|
||||||
if (asset.remoteUrl) return asset.remoteUrl;
|
|
||||||
if (!asset.hasLocalFile) return '';
|
|
||||||
const prefix = audience === 'user' ? '/api/user' : '/api';
|
|
||||||
return `${prefix}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assetName(asset: WorkspaceAsset) {
|
|
||||||
return asset.title || asset.fileName || asset.publicId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatJobTime(value?: string | null) {
|
|
||||||
if (!value) return '';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
return date.toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DigitalHuman() {
|
export default function DigitalHuman() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
|
const [mode, setMode] = useState<Mode>('audio');
|
||||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
const [model, setModel] = useState(MODEL_PRESETS[0]);
|
||||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
|
||||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
||||||
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
|
const [audioUrl, setAudioUrl] = useState('');
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
|
||||||
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
const [resolution, setResolution] = useState('720p');
|
||||||
const [avatarUrl, setAvatarUrl] = useState('');
|
|
||||||
const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
|
|
||||||
const [productScript, setProductScript] = useState('今天给大家介绍这款商品。它的核心优势是上手简单、效果稳定,适合直播间高频讲解和短视频种草。');
|
|
||||||
const [prompt, setPrompt] = useState('真人数字人主播,面向镜头自然讲解商品,口型准确,表情稳定,商业带货风格。');
|
|
||||||
const [audioRole, setAudioRole] = useState<AudioRole>('both');
|
|
||||||
const [ratio, setRatio] = useState('9:16');
|
const [ratio, setRatio] = useState('9:16');
|
||||||
const [resolution, setResolution] = useState('1080p');
|
const [duration, setDuration] = useState(8);
|
||||||
const [subtitles, setSubtitles] = useState(true);
|
const [refImageIndex, setRefImageIndex] = useState(0);
|
||||||
|
const [audioCfg, setAudioCfg] = useState(4.5);
|
||||||
|
const [segments, setSegments] = useState(false);
|
||||||
|
const [multiPerson, setMultiPerson] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
|
||||||
const [assetPicker, setAssetPicker] = useState<UploadPurpose | ''>('');
|
|
||||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
|
||||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
const bodyPreview = useMemo(() => ({
|
||||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
model: model.trim(),
|
||||||
const sortedWorkflows = useMemo(() => {
|
prompt: [
|
||||||
return [...workflows].sort((a, b) => {
|
prompt.trim(),
|
||||||
const score = (item: WorkflowSummary) => (templateHasMapping(item) ? 0 : 10)
|
script.trim() ? `台词:${script.trim()}` : '',
|
||||||
+ (item.isTemplate || item.visibility === 'template' ? 0 : item.visibility === 'public' ? 2 : 4);
|
`风格:${voiceStyle}`,
|
||||||
return score(a) - score(b) || a.name.localeCompare(b.name, 'zh-CN');
|
].filter(Boolean).join('\n'),
|
||||||
});
|
mode,
|
||||||
}, [workflows]);
|
ratio,
|
||||||
const mapped = useMemo(() => ({
|
resolution,
|
||||||
prompt: mappingValue(mapping, 'prompt'),
|
duration,
|
||||||
script: mappingValue(mapping, 'script'),
|
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
|
||||||
avatar: mappingValue(mapping, 'avatar'),
|
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
|
||||||
referenceAudio: mappingValue(mapping, 'referenceAudio'),
|
audio_cfg: audioCfg,
|
||||||
voiceReference: mappingValue(mapping, 'voiceReference'),
|
ref_img_index: refImageIndex,
|
||||||
output: mappingValue(mapping, 'output'),
|
segmented: segments,
|
||||||
}), [mapping]);
|
multi_person: multiPerson,
|
||||||
|
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
|
||||||
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('');
|
|
||||||
try {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
|
||||||
form.append('purpose', purpose);
|
|
||||||
const response = await fetchSessionResponse(audience, '/api/digital-human/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
timeoutMs: 180_000,
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(await parseError(response));
|
|
||||||
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 () => {
|
const submit = async () => {
|
||||||
if (!canSubmit) return;
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const payload = await client.createDigitalHumanJob({
|
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', {
|
||||||
templateId,
|
method: 'POST',
|
||||||
nodeMapping: mapping,
|
body: JSON.stringify(bodyPreview),
|
||||||
avatarUrl: avatarUrl.trim(),
|
timeoutMs: 300_000,
|
||||||
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)]);
|
if (!response.ok) throw new Error(await parseError(response));
|
||||||
|
setResult(await response.json());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '数字人任务创建失败');
|
setError(err?.message || '数字人任务创建失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -358,301 +105,236 @@ export default function DigitalHuman() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancel = async (id: string) => {
|
const taskId = extractTaskId(result);
|
||||||
const payload = await client.cancelDigitalHumanJob(id);
|
|
||||||
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = async () => {
|
|
||||||
await client.clearDigitalHumanJobs();
|
|
||||||
await loadData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const retry = (job: DigitalHumanJob) => {
|
|
||||||
const request = job.result?.request || {};
|
|
||||||
setTemplateId(request.templateId || job.result?.templateId || templateId);
|
|
||||||
setMapping(request.nodeMapping || job.result?.mapping || mapping);
|
|
||||||
setAvatarUrl(request.avatarUrl || '');
|
|
||||||
setReferenceAudioUrl(request.referenceAudioUrl || '');
|
|
||||||
setProductScript(request.productScript || '');
|
|
||||||
setPrompt(request.prompt || prompt);
|
|
||||||
setAudioRole(request.audioRole || 'both');
|
|
||||||
setRatio(request.ratio || '9:16');
|
|
||||||
setResolution(request.resolution || '1080p');
|
|
||||||
setSubtitles(request.subtitles !== false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="digital-human-page animate-fade-in">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">数字人</h1>
|
<h1 className="page-title">数字人</h1>
|
||||||
<p className="page-subtitle">选择 Comfy 工作流模板,上传人物素材和参考音频,生成真人商品讲解视频。</p>
|
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
|
||||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
{submitting ? '创建中...' : '创建数字人任务'}
|
||||||
{submitting ? '提交中...' : '生成讲解视频'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||||
|
|
||||||
<div className="digital-human-layout">
|
<div style={layoutStyle}>
|
||||||
<section className="digital-human-panel">
|
<section style={panelStyle}>
|
||||||
<div className="digital-human-section-head">
|
<div style={modeGridStyle}>
|
||||||
<div>
|
{MODE_OPTIONS.map((item) => (
|
||||||
<h2>1. 选择工作流模板</h2>
|
<button
|
||||||
<p>优先选择已配置数字人节点映射的模板;普通 Comfy 工作流也可以用自动猜测补齐。</p>
|
key={item.value}
|
||||||
</div>
|
type="button"
|
||||||
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>猜测映射</button>
|
onClick={() => setMode(item.value)}
|
||||||
</div>
|
style={mode === item.value ? activeModeStyle : modeStyle}
|
||||||
<div className="digital-human-template-grid">
|
>
|
||||||
{sortedWorkflows.slice(0, 6).map((workflow) => {
|
<strong>{item.label}</strong>
|
||||||
const ready = templateHasMapping(workflow);
|
<span>{item.description}</span>
|
||||||
const active = workflow.publicId === templateId;
|
</button>
|
||||||
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>
|
||||||
|
|
||||||
<h2>3. 商品文案</h2>
|
<div style={formGridStyle}>
|
||||||
<label className="digital-human-field">
|
<label style={fieldStyle}>
|
||||||
<span>讲解文案</span>
|
<span>模型</span>
|
||||||
<textarea value={productScript} onChange={(event) => setProductScript(event.target.value)} rows={6} />
|
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
|
||||||
</label>
|
<datalist id="digital-human-models">
|
||||||
<label className="digital-human-field">
|
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
|
||||||
<span>画面提示</span>
|
</datalist>
|
||||||
<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>
|
||||||
<label className="digital-human-field">
|
<label style={fieldStyle}>
|
||||||
<span>画幅</span>
|
<span>画幅</span>
|
||||||
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
|
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
|
||||||
<option value="9:16">9:16 竖屏</option>
|
<option value="9:16">9:16 竖屏</option>
|
||||||
<option value="16:9">16:9 横屏</option>
|
<option value="16:9">16:9 横屏</option>
|
||||||
<option value="1:1">1:1 方屏</option>
|
<option value="1:1">1:1 方屏</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="digital-human-field">
|
<label style={fieldStyle}>
|
||||||
<span>分辨率</span>
|
<span>分辨率</span>
|
||||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
|
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
|
||||||
<option value="720p">720p</option>
|
<option value="720p">720p</option>
|
||||||
<option value="1080p">1080p</option>
|
<option value="1080p">1080p</option>
|
||||||
<option value="480p">480p</option>
|
<option value="480p">480p</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="digital-human-checkbox">
|
<label style={fieldStyle}>
|
||||||
<input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
|
<span>时长(秒)</span>
|
||||||
<span>生成字幕</span>
|
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>人物/场景描述</span>
|
||||||
|
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
|
||||||
|
</label>
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>台词/脚本</span>
|
||||||
|
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={formGridStyle}>
|
||||||
|
{mode !== 'text' && (
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>人物图 URL</span>
|
||||||
|
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{mode === 'audio' && (
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>音频 URL</span>
|
||||||
|
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>口播风格</span>
|
||||||
|
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
|
||||||
|
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>ref_img_index</span>
|
||||||
|
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
<label style={fieldStyle}>
|
||||||
|
<span>audio CFG</span>
|
||||||
|
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={toggleRowStyle}>
|
||||||
|
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
|
||||||
|
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="digital-human-panel">
|
<aside style={panelStyle}>
|
||||||
<div className="digital-human-section-head">
|
<h2 style={sideTitleStyle}>任务请求</h2>
|
||||||
<div>
|
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
|
||||||
<h2>节点映射</h2>
|
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
|
||||||
<p>把页面输入写入对应 Comfy 节点。字段名可按模板节点输入口微调。</p>
|
|
||||||
</div>
|
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
|
||||||
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>重新猜测</button>
|
{result ? (
|
||||||
</div>
|
<>
|
||||||
<div className="digital-human-mapping-grid">
|
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
|
||||||
{([
|
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
|
||||||
['prompt', '提示词节点'],
|
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
|
||||||
['script', '文案节点'],
|
</>
|
||||||
['avatar', '人物素材节点'],
|
) : (
|
||||||
['referenceAudio', '参考音频节点'],
|
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||||
['voiceReference', '音色参考节点(可选)'],
|
<div className="empty-state-title">暂无任务</div>
|
||||||
['output', '输出视频节点'],
|
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
|
||||||
] as Array<[keyof typeof DEFAULT_MAPPING, string]>).map(([key, label]) => {
|
|
||||||
const item = mapped[key];
|
|
||||||
return (
|
|
||||||
<div key={key} className="digital-human-mapping-row">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input value={item.nodeId} onChange={(event) => updateMapping(key, 'nodeId', event.target.value)} placeholder="Node ID" />
|
|
||||||
<input value={item.field} onChange={(event) => updateMapping(key, 'field', event.target.value)} placeholder="字段名" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{missingMapping.length > 0 && (
|
|
||||||
<div className="alert alert-warning" style={{ marginTop: 12 }}>
|
|
||||||
缺少映射:{missingMapping.join('、')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</aside>
|
||||||
<h2>任务队列</h2>
|
|
||||||
<div className="digital-human-job-actions">
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={clear}>清空已完成</button>
|
|
||||||
</div>
|
|
||||||
<div 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>
|
</div>
|
||||||
|
|
||||||
{assetPicker && (
|
|
||||||
<div className="modal-overlay" onClick={() => setAssetPicker('')}>
|
|
||||||
<div className="digital-human-picker modal-card" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<div>
|
|
||||||
<h3>{assetPicker === 'audio' ? '选择参考音频' : '选择人物素材'}</h3>
|
|
||||||
<p>从工作空间选取已有素材,只有最终产出仍会归档到工作空间。</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setAssetPicker('')}>关闭</button>
|
|
||||||
</div>
|
|
||||||
{loadingAssets ? (
|
|
||||||
<div className="empty-state" style={{ minHeight: 180 }}>
|
|
||||||
<div className="empty-state-title">正在加载素材</div>
|
|
||||||
</div>
|
|
||||||
) : pickerAssets.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ minHeight: 180 }}>
|
|
||||||
<div className="empty-state-title">没有可选素材</div>
|
|
||||||
<div className="empty-state-desc">可以先上传本地文件,或去工作空间确认素材类型。</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="digital-human-picker-grid">
|
|
||||||
{pickerAssets.map((asset) => {
|
|
||||||
const preview = assetFileUrl(asset, audience);
|
|
||||||
return (
|
|
||||||
<button key={asset.publicId} type="button" className="digital-human-picker-item" onClick={() => chooseAsset(asset)}>
|
|
||||||
{asset.kind === 'image' && preview ? <img src={preview} alt="" loading="lazy" /> : null}
|
|
||||||
{asset.kind === 'video' && preview ? <video src={preview} muted preload="metadata" /> : null}
|
|
||||||
{asset.kind === 'audio' ? <span className="digital-human-picker-audio">音频</span> : null}
|
|
||||||
<strong>{assetName(asset)}</strong>
|
|
||||||
<span>{asset.kind} · {asset.source || 'workspace'}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layoutStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 1.25fr) minmax(320px, 0.75fr)',
|
||||||
|
gap: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelStyle: CSSProperties = {
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeGridStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeStyle: CSSProperties = {
|
||||||
|
textAlign: 'left',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeModeStyle: CSSProperties = {
|
||||||
|
...modeStyle,
|
||||||
|
borderColor: 'var(--color-primary)',
|
||||||
|
boxShadow: '0 0 0 2px color-mix(in srgb, var(--color-primary) 15%, transparent)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formGridStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
const textareaStyle: CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
minHeight: 110,
|
||||||
|
resize: 'vertical',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRowStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sideTitleStyle: CSSProperties = {
|
||||||
|
margin: '0 0 10px',
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeStyle: CSSProperties = {
|
||||||
|
maxHeight: 280,
|
||||||
|
overflow: 'auto',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskIdStyle: CSSProperties = {
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import DownstreamKeyEditorModal, {
|
|||||||
} from './downstream-keys/DownstreamKeyEditorModal.js';
|
} from './downstream-keys/DownstreamKeyEditorModal.js';
|
||||||
import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js';
|
import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js';
|
||||||
import {
|
import {
|
||||||
BillingLimitBar,
|
|
||||||
formatCompactTokens,
|
formatCompactTokens,
|
||||||
formatIso,
|
formatIso,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
@@ -45,22 +44,6 @@ type DownstreamApiKeyItem = {
|
|||||||
usedCost: number;
|
usedCost: number;
|
||||||
maxRequests: number | null;
|
maxRequests: number | null;
|
||||||
usedRequests: number;
|
usedRequests: number;
|
||||||
billing?: {
|
|
||||||
requestLimit?: {
|
|
||||||
used: number;
|
|
||||||
max: number | null;
|
|
||||||
remaining: number | null;
|
|
||||||
percent: number | null;
|
|
||||||
exhausted: boolean;
|
|
||||||
};
|
|
||||||
costLimit?: {
|
|
||||||
used: number;
|
|
||||||
max: number | null;
|
|
||||||
remaining: number | null;
|
|
||||||
percent: number | null;
|
|
||||||
exhausted: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
supportedModels: string[];
|
supportedModels: string[];
|
||||||
allowedRouteIds: number[];
|
allowedRouteIds: number[];
|
||||||
siteWeightMultipliers: Record<number, number>;
|
siteWeightMultipliers: Record<number, number>;
|
||||||
@@ -1104,7 +1087,6 @@ export default function DownstreamKeys() {
|
|||||||
<SummaryMetric label="已选中" value={String(selectedIds.length)} />
|
<SummaryMetric label="已选中" value={String(selectedIds.length)} />
|
||||||
<SummaryMetric label="请求数" value={totals.requests.toLocaleString()} />
|
<SummaryMetric label="请求数" value={totals.requests.toLocaleString()} />
|
||||||
<SummaryMetric label="累计成本" value={formatMoney(totals.cost)} />
|
<SummaryMetric label="累计成本" value={formatMoney(totals.cost)} />
|
||||||
<SummaryMetric label="全量成本" value={formatMoney(visibleItems.reduce((sum, item) => sum + Number(item.usedCost || 0), 0))} />
|
|
||||||
<SummaryMetric label="筛选状态" value={statusOptions.find((item) => item.value === status)?.label || '全部状态'} />
|
<SummaryMetric label="筛选状态" value={statusOptions.find((item) => item.value === status)?.label || '全部状态'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1210,16 +1192,7 @@ export default function DownstreamKeys() {
|
|||||||
<MobileField label="模型" value={summarizeModelLimit(row.supportedModels || [])} stacked />
|
<MobileField label="模型" value={summarizeModelLimit(row.supportedModels || [])} stacked />
|
||||||
<MobileField label="群组" value={summarizeRouteLimit(row.allowedRouteIds || [], routeMap)} stacked />
|
<MobileField label="群组" value={summarizeRouteLimit(row.allowedRouteIds || [], routeMap)} stacked />
|
||||||
<MobileField label="倍率" value={summarizeSiteWeightMultipliers(row.siteWeightMultipliers || {})} stacked />
|
<MobileField label="倍率" value={summarizeSiteWeightMultipliers(row.siteWeightMultipliers || {})} stacked />
|
||||||
<MobileField
|
<MobileField label="额度" value={`${row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()} / ${row.maxCost == null ? '成本不限' : formatMoney(row.maxCost)}`} stacked />
|
||||||
label="计费额度"
|
|
||||||
value={(
|
|
||||||
<div style={{ display: 'grid', gap: 10, minWidth: 220 }}>
|
|
||||||
<BillingLimitBar label="请求" used={row.usedRequests || 0} max={row.maxRequests} formatter={(value) => Math.trunc(value).toLocaleString()} />
|
|
||||||
<BillingLimitBar label="成本" used={row.usedCost || 0} max={row.maxCost} formatter={formatMoney} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
stacked
|
|
||||||
/>
|
|
||||||
<MobileField label="用量" value={`${(row.rangeUsage?.totalRequests || 0).toLocaleString()} 请求 · ${formatCompactTokens(row.rangeUsage?.totalTokens || 0)}`} stacked />
|
<MobileField label="用量" value={`${(row.rangeUsage?.totalRequests || 0).toLocaleString()} 请求 · ${formatCompactTokens(row.rangeUsage?.totalTokens || 0)}`} stacked />
|
||||||
<MobileField label="最近使用" value={formatIso(row.lastUsedAt)} stacked />
|
<MobileField label="最近使用" value={formatIso(row.lastUsedAt)} stacked />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
@@ -1236,7 +1209,7 @@ export default function DownstreamKeys() {
|
|||||||
</th>
|
</th>
|
||||||
<th>密钥信息</th>
|
<th>密钥信息</th>
|
||||||
<th>授权范围</th>
|
<th>授权范围</th>
|
||||||
<th style={{ minWidth: 220 }}>计费额度</th>
|
<th style={{ textAlign: 'right' }}>额度</th>
|
||||||
<th style={{ textAlign: 'right' }}>用量</th>
|
<th style={{ textAlign: 'right' }}>用量</th>
|
||||||
<th>最近使用</th>
|
<th>最近使用</th>
|
||||||
<th style={{ textAlign: 'right' }}>操作</th>
|
<th style={{ textAlign: 'right' }}>操作</th>
|
||||||
@@ -1278,12 +1251,10 @@ export default function DownstreamKeys() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>倍率:<span style={{ color: 'var(--color-text-primary)' }}>{summarizeSiteWeightMultipliers(row.siteWeightMultipliers || {})}</span></div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>倍率:<span style={{ color: 'var(--color-text-primary)' }}>{summarizeSiteWeightMultipliers(row.siteWeightMultipliers || {})}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontVariantNumeric: 'tabular-nums', minWidth: 220 }}>
|
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()}</div>
|
||||||
<BillingLimitBar label="请求" used={row.usedRequests || 0} max={row.maxRequests} formatter={(value) => Math.trunc(value).toLocaleString()} />
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>{row.maxCost == null ? '成本不限' : `成本 ${formatMoney(row.maxCost)}`}</div>
|
||||||
<BillingLimitBar label="成本" used={row.usedCost || 0} max={row.maxCost} formatter={formatMoney} />
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>{row.expiresAt ? `到期 ${formatIso(row.expiresAt)}` : '永久有效'}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{row.expiresAt ? `到期 ${formatIso(row.expiresAt)}` : '永久有效'}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatCompactTokens(row.rangeUsage?.totalTokens || 0)}</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatCompactTokens(row.rangeUsage?.totalTokens || 0)}</div>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function getLatencyBadgeClass(latency: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatLatency(latency: number | null): string {
|
function formatLatency(latency: number | null): string {
|
||||||
return isKnownLatency(latency) ? `${latency}ms` : '暂无';
|
return isKnownLatency(latency) ? `${latency}ms` : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSuccessBadgeClass(rate: number | null) {
|
function getSuccessBadgeClass(rate: number | null) {
|
||||||
@@ -710,7 +710,7 @@ export default function Models() {
|
|||||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||||
data-tooltip={tr('成功率')}
|
data-tooltip={tr('成功率')}
|
||||||
>
|
>
|
||||||
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '暂无'}
|
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -847,7 +847,7 @@ export default function Models() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>{tr('延迟')}</span>
|
<span style={{ color: 'var(--color-text-muted)' }}>{tr('延迟')}</span>
|
||||||
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums' }}>
|
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{a.latency != null ? `${a.latency}ms` : '暂无'}
|
{a.latency != null ? `${a.latency}ms` : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
|
||||||
@@ -859,7 +859,7 @@ export default function Models() {
|
|||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{a.tokens.length > 0 ? a.tokens.map((t) => (
|
{a.tokens.length > 0 ? a.tokens.map((t) => (
|
||||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||||
)) : <span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>暂无</span>}
|
)) : <span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>—</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -885,12 +885,12 @@ export default function Models() {
|
|||||||
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{a.tokens.length > 0 ? a.tokens.map(t => (
|
{a.tokens.length > 0 ? a.tokens.map(t => (
|
||||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||||
)) : <span style={{ color: 'var(--color-text-muted)' }}>暂无</span>}
|
)) : <span style={{ color: 'var(--color-text-muted)' }}>—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{a.latency != null ? (
|
{a.latency != null ? (
|
||||||
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
|
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
|
||||||
) : '暂无'}
|
) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
|
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -964,7 +964,7 @@ export default function Models() {
|
|||||||
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
|
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
|
||||||
style={{ fontSize: 12, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontSize: 12, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
|
||||||
>
|
>
|
||||||
{m.successRate != null ? `${m.successRate}%` : '暂无'}
|
{m.successRate != null ? `${m.successRate}%` : '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td onClick={e => e.stopPropagation()}>
|
<td onClick={e => e.stopPropagation()}>
|
||||||
@@ -1058,10 +1058,10 @@ export default function Models() {
|
|||||||
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
{a.tokens.length > 0 ? a.tokens.map(t => (
|
{a.tokens.length > 0 ? a.tokens.map(t => (
|
||||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-info'}`}>{t.name}</span>
|
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-info'}`}>{t.name}</span>
|
||||||
)) : '暂无'}
|
)) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: 8, color: a.latency != null ? getMetricColor(a.latency) : 'var(--color-text-muted)' }}>
|
<td style={{ padding: 8, color: a.latency != null ? getMetricColor(a.latency) : 'var(--color-text-muted)' }}>
|
||||||
{a.latency != null ? `${a.latency}ms` : '暂无'}
|
{a.latency != null ? `${a.latency}ms` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: 8 }}>${(a.balance || 0).toFixed(2)}</td>
|
<td style={{ padding: 8 }}>${(a.balance || 0).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { api, userApi, type MoneyPrinterJob, type WorkflowSummary } from '../api.js';
|
|
||||||
import type { SessionAudience } from '../authSession.js';
|
|
||||||
|
|
||||||
const STYLE_PRESETS = [
|
|
||||||
'爆款短视频,节奏紧凑,适合口播',
|
|
||||||
'商品种草,真实体验,强转化',
|
|
||||||
'知识科普,信息密度高,画面干净',
|
|
||||||
'电影感广告,镜头运动丰富,高级质感',
|
|
||||||
];
|
|
||||||
|
|
||||||
function terminal(status: string) {
|
|
||||||
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
|
||||||
if (status === 'succeeded') return '完成';
|
|
||||||
if (status === 'failed') return '失败';
|
|
||||||
if (status === 'cancelled') return '已取消';
|
|
||||||
if (status === 'running') return '生成中';
|
|
||||||
return '排队中';
|
|
||||||
}
|
|
||||||
|
|
||||||
function progress(job: MoneyPrinterJob) {
|
|
||||||
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
|
||||||
if (!value) return '';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
return date.toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobAssets(job: MoneyPrinterJob) {
|
|
||||||
return Array.isArray(job.result?.assets) ? job.result.assets : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function planValue(job: MoneyPrinterJob, key: string) {
|
|
||||||
return job.result?.script?.plan?.[key] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowPromptFromGraph(graph: any) {
|
|
||||||
return graph?.prompt || graph?.workflow || graph?.comfyPrompt || graphToComfyPrompt(graph) || graph || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mappingFromGraph(graph: any) {
|
|
||||||
return graph?.metadata?.moneyPrinter?.nodeMapping
|
|
||||||
|| graph?.moneyPrinter?.nodeMapping
|
|
||||||
|| graph?.metadata?.digitalHuman?.nodeMapping
|
|
||||||
|| graph?.digitalHuman?.nodeMapping
|
|
||||||
|| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferMappingFromPrompt(prompt: any) {
|
|
||||||
const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt) ? Object.entries(prompt as Record<string, any>) : [];
|
|
||||||
const find = (classWords: string[], inputWords: string[] = []) => {
|
|
||||||
return nodes.find(([, node]) => {
|
|
||||||
const classType = String(node?.class_type || node?.type || '').toLowerCase();
|
|
||||||
const inputs = Object.keys(node?.inputs || {}).join(' ').toLowerCase();
|
|
||||||
return classWords.some((word) => classType.includes(word)) || inputWords.some((word) => inputs.includes(word));
|
|
||||||
})?.[0] || '';
|
|
||||||
};
|
|
||||||
const promptNode = find(['text', 'prompt', 'wan', 'ltx', 'seedance', 'video'], ['prompt', 'positive', 'text']);
|
|
||||||
const negativeNode = find(['negative'], ['negative']);
|
|
||||||
const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']);
|
|
||||||
return {
|
|
||||||
prompt: { nodeId: String(promptNode), field: prompt?.[String(promptNode)]?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
|
||||||
negativePrompt: { nodeId: String(negativeNode), field: 'text' },
|
|
||||||
ratio: { nodeId: String(videoNode), field: 'ratio' },
|
|
||||||
resolution: { nodeId: String(videoNode), field: 'resolution' },
|
|
||||||
duration: { nodeId: String(videoNode), field: 'duration' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeParam(classType: string, field: string, value: any) {
|
|
||||||
if (classType === 'KSampler' && field === 'cfg') {
|
|
||||||
return typeof value === 'number' && value >= 0 && value <= 50 ? value : 8;
|
|
||||||
}
|
|
||||||
if (classType === 'KSampler' && field === 'denoise') {
|
|
||||||
const numeric = typeof value === 'number' ? value : Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return 1;
|
|
||||||
return numeric > 1 ? Math.min(1, numeric / 100) : Math.max(0, numeric);
|
|
||||||
}
|
|
||||||
if (classType === 'KSampler' && field === 'sampler_name') return typeof value === 'string' ? value : 'euler';
|
|
||||||
if (classType === 'KSampler' && field === 'scheduler') return typeof value === 'string' ? value : 'normal';
|
|
||||||
if (classType === 'KSampler' && field === 'seed') return Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
|
|
||||||
if (classType === 'EmptyLatentImage' && ['width', 'height'].includes(field)) {
|
|
||||||
const numeric = Number(value);
|
|
||||||
return numeric >= 64 && numeric <= 4096 ? numeric : 1024;
|
|
||||||
}
|
|
||||||
if (classType === 'EmptyLatentImage' && field === 'batch_size') {
|
|
||||||
const numeric = Number(value);
|
|
||||||
return numeric >= 1 && numeric <= 16 ? numeric : 1;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function graphToComfyPrompt(graph: any) {
|
|
||||||
if (!Array.isArray(graph?.nodes)) return null;
|
|
||||||
const prompt: Record<string, any> = {};
|
|
||||||
for (const node of graph.nodes) {
|
|
||||||
const id = String(node?.id || '');
|
|
||||||
const classType = String(node?.type || node?.class_type || '');
|
|
||||||
if (!id || !classType) continue;
|
|
||||||
const params = node?.params && typeof node.params === 'object' ? node.params : {};
|
|
||||||
const inputs: Record<string, any> = {};
|
|
||||||
for (const [field, value] of Object.entries(params)) {
|
|
||||||
inputs[field] = normalizeParam(classType, field, value);
|
|
||||||
}
|
|
||||||
prompt[id] = { class_type: classType, inputs };
|
|
||||||
}
|
|
||||||
for (const edge of Array.isArray(graph?.edges) ? graph.edges : []) {
|
|
||||||
const from = String(edge?.from || '');
|
|
||||||
const to = String(edge?.to || '');
|
|
||||||
const [fromId] = from.split('.');
|
|
||||||
const [toId, toField] = to.split('.');
|
|
||||||
if (fromId && toId && toField && prompt[toId]?.inputs) {
|
|
||||||
prompt[toId].inputs[toField] = [fromId, 0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const firstByType = (type: string) => Object.entries(prompt).find(([, node]) => node.class_type === type)?.[0] || '';
|
|
||||||
const allByType = (type: string) => Object.entries(prompt).filter(([, node]) => node.class_type === type).map(([id]) => id);
|
|
||||||
const checkpoint = firstByType('CheckpointLoaderSimple');
|
|
||||||
const latent = firstByType('EmptyLatentImage');
|
|
||||||
const ksampler = firstByType('KSampler');
|
|
||||||
const textNodes = allByType('CLIPTextEncode');
|
|
||||||
if (ksampler) {
|
|
||||||
if (checkpoint) prompt[ksampler].inputs.model = [checkpoint, 0];
|
|
||||||
if (latent) prompt[ksampler].inputs.latent_image = [latent, 0];
|
|
||||||
if (textNodes[0]) prompt[ksampler].inputs.positive = [textNodes[0], 0];
|
|
||||||
if (textNodes[1] || textNodes[0]) prompt[ksampler].inputs.negative = [textNodes[1] || textNodes[0], 0];
|
|
||||||
}
|
|
||||||
const vaeDecode = firstByType('VAEDecode');
|
|
||||||
if (vaeDecode) {
|
|
||||||
if (ksampler) prompt[vaeDecode].inputs.samples = [ksampler, 0];
|
|
||||||
if (checkpoint) prompt[vaeDecode].inputs.vae = [checkpoint, 2];
|
|
||||||
}
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateLabel(workflow: WorkflowSummary) {
|
|
||||||
if (workflow.isTemplate || workflow.visibility === 'template') return '官方模板';
|
|
||||||
if (workflow.visibility === 'public') return '共享模板';
|
|
||||||
return '私有工作流';
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowTimestampLabel(workflow: WorkflowSummary) {
|
|
||||||
const value = workflow.updatedAt || workflow.createdAt;
|
|
||||||
if (!value) return '';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
return date.toLocaleString('zh-CN', {
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowOptionLabel(workflow: WorkflowSummary) {
|
|
||||||
const name = workflow.name?.trim() || '未命名工作流';
|
|
||||||
const time = workflowTimestampLabel(workflow);
|
|
||||||
const suffix = workflow.publicId ? workflow.publicId.slice(0, 8) : String(workflow.id);
|
|
||||||
return `${name} · ${templateLabel(workflow)}${time ? ` · ${time}` : ''} · ${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workflowLooksVideoCapable(workflow: WorkflowSummary) {
|
|
||||||
const graphText = JSON.stringify(workflow.graph || {}).toLowerCase();
|
|
||||||
return [
|
|
||||||
'video',
|
|
||||||
'wan',
|
|
||||||
'ltx',
|
|
||||||
'seedance',
|
|
||||||
'kling',
|
|
||||||
'runway',
|
|
||||||
'hailuo',
|
|
||||||
'pixverse',
|
|
||||||
'image_to_video',
|
|
||||||
'text_to_video',
|
|
||||||
'savevideo',
|
|
||||||
'video combine',
|
|
||||||
'vhs_videocombine',
|
|
||||||
'output.preview_video',
|
|
||||||
'boosapi.text_to_video',
|
|
||||||
'boosapi.image_to_video',
|
|
||||||
].some((keyword) => graphText.includes(keyword));
|
|
||||||
}
|
|
||||||
|
|
||||||
function moneyPrinterWorkflows(items: WorkflowSummary[]) {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return items
|
|
||||||
.filter((workflow) => workflowLooksVideoCapable(workflow))
|
|
||||||
.filter((workflow) => {
|
|
||||||
const key = workflow.publicId || String(workflow.id);
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aTemplate = a.isTemplate || a.visibility === 'template' || a.visibility === 'public';
|
|
||||||
const bTemplate = b.isTemplate || b.visibility === 'template' || b.visibility === 'public';
|
|
||||||
if (aTemplate !== bTemplate) return aTemplate ? -1 : 1;
|
|
||||||
return String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MoneyPrinter() {
|
|
||||||
const location = useLocation();
|
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
|
||||||
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
|
|
||||||
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
|
||||||
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
|
|
||||||
const [scriptModel, setScriptModel] = useState('gpt-5.4');
|
|
||||||
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
|
|
||||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi');
|
|
||||||
const [style, setStyle] = useState(STYLE_PRESETS[0]);
|
|
||||||
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
|
|
||||||
const [duration, setDuration] = useState(30);
|
|
||||||
const [ratio, setRatio] = useState('9:16');
|
|
||||||
const [resolution, setResolution] = useState('1080p');
|
|
||||||
const [requirements, setRequirements] = useState('画面要有科技感,突出统一网关、多模型、多模态和工作流能力。');
|
|
||||||
const [comfyPrompt, setComfyPrompt] = useState('');
|
|
||||||
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
|
||||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
|
||||||
const [templateId, setTemplateId] = useState('');
|
|
||||||
const [useAdvancedComfy, setUseAdvancedComfy] = useState(false);
|
|
||||||
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
|
||||||
const payload = await client.getMoneyPrinterJobs({ limit: 30 });
|
|
||||||
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const loadWorkflows = useCallback(async () => {
|
|
||||||
const payload = await client.getWorkflows({ include_public: true });
|
|
||||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
||||||
const videoItems = moneyPrinterWorkflows(items);
|
|
||||||
setWorkflows(videoItems);
|
|
||||||
setTemplateId((current) => {
|
|
||||||
if (current && videoItems.some((item) => item.publicId === current)) return current;
|
|
||||||
return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId
|
|
||||||
|| videoItems[0]?.publicId
|
|
||||||
|| '';
|
|
||||||
});
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([loadJobs(), loadWorkflows()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
|
|
||||||
}, [loadJobs, loadWorkflows]);
|
|
||||||
|
|
||||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedWorkflow || videoBackend !== 'comfy' || !useAdvancedComfy) return;
|
|
||||||
const prompt = workflowPromptFromGraph(selectedWorkflow.graph);
|
|
||||||
if (prompt) setComfyPrompt(JSON.stringify(prompt, null, 2));
|
|
||||||
const mapping = mappingFromGraph(selectedWorkflow.graph);
|
|
||||||
setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2));
|
|
||||||
}, [selectedWorkflow?.publicId, videoBackend]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!jobs.some((job) => !terminal(job.status))) return;
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
client.getMoneyPrinterJobs({ limit: 30 })
|
|
||||||
.then((payload) => setJobs(payload.jobs || []))
|
|
||||||
.catch(() => {});
|
|
||||||
}, 5000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [client, jobs]);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!topic.trim() || submitting) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const payload = await client.createMoneyPrinterJob({
|
|
||||||
topic: topic.trim(),
|
|
||||||
scriptModel: scriptModel.trim(),
|
|
||||||
videoBackend,
|
|
||||||
videoModel: videoModel.trim(),
|
|
||||||
style,
|
|
||||||
audience: audienceText,
|
|
||||||
duration,
|
|
||||||
ratio,
|
|
||||||
resolution,
|
|
||||||
requirements,
|
|
||||||
language: '中文',
|
|
||||||
...(videoBackend === 'comfy' ? {
|
|
||||||
templateId: templateId || undefined,
|
|
||||||
comfyPrompt: comfyPrompt.trim() ? JSON.parse(comfyPrompt) : undefined,
|
|
||||||
comfyNodeMapping: comfyNodeMapping.trim() ? JSON.parse(comfyNodeMapping) : undefined,
|
|
||||||
} : {}),
|
|
||||||
});
|
|
||||||
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || '短视频任务创建失败');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = async (id: string) => {
|
|
||||||
const payload = await client.cancelMoneyPrinterJob(id);
|
|
||||||
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = async () => {
|
|
||||||
await client.clearMoneyPrinterJobs();
|
|
||||||
await loadJobs();
|
|
||||||
};
|
|
||||||
|
|
||||||
const retry = (job: MoneyPrinterJob) => {
|
|
||||||
const request = job.result?.request || {};
|
|
||||||
setTopic(request.topic || job.result?.topic || topic);
|
|
||||||
setScriptModel(request.scriptModel || scriptModel);
|
|
||||||
setVideoBackend(request.videoBackend || videoBackend);
|
|
||||||
setVideoModel(request.videoModel || request.model || videoModel);
|
|
||||||
setStyle(request.style || style);
|
|
||||||
setAudienceText(request.audience || audienceText);
|
|
||||||
setDuration(Number(request.duration || duration));
|
|
||||||
setRatio(request.ratio || ratio);
|
|
||||||
setResolution(request.resolution || resolution);
|
|
||||||
setRequirements(request.requirements || requirements);
|
|
||||||
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
|
|
||||||
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
|
|
||||||
setTemplateId(request.templateId || templateId);
|
|
||||||
setUseAdvancedComfy(Boolean(request.templateId));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="money-printer-page animate-fade-in">
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">短视频工厂</h1>
|
|
||||||
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BossAI 视频模型生产成片。</p>
|
|
||||||
</div>
|
|
||||||
<div className="page-actions">
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting || !topic.trim()}>
|
|
||||||
{submitting ? '提交中...' : '生成短视频'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
|
||||||
|
|
||||||
<div className="money-printer-layout">
|
|
||||||
<section className="money-printer-panel">
|
|
||||||
<div className="money-printer-hero">
|
|
||||||
<span>MoneyPrinterTurbo inside</span>
|
|
||||||
<strong>一句主题,自动拆成脚本、分镜、视频提示词和成片任务。</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>短视频主题</span>
|
|
||||||
<textarea value={topic} onChange={(event) => setTopic(event.target.value)} rows={4} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="money-printer-form-grid">
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>脚本模型</span>
|
|
||||||
<input value={scriptModel} onChange={(event) => setScriptModel(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>视频后端</span>
|
|
||||||
<select value={videoBackend} onChange={(event) => setVideoBackend(event.target.value as 'comfy' | 'boosapi')}>
|
|
||||||
<option value="boosapi">BoosAPI /v1/videos</option>
|
|
||||||
<option value="comfy">Comfy API 模板</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>视频模型</span>
|
|
||||||
<input value={videoModel} onChange={(event) => setVideoModel(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>时长</span>
|
|
||||||
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} />
|
|
||||||
</label>
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>画幅</span>
|
|
||||||
<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 className="money-printer-field">
|
|
||||||
<span>分辨率</span>
|
|
||||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
|
|
||||||
<option value="1080p">1080p</option>
|
|
||||||
<option value="720p">720p</option>
|
|
||||||
<option value="480p">480p</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>风格</span>
|
|
||||||
<select value={style} onChange={(event) => setStyle(event.target.value)}>
|
|
||||||
{STYLE_PRESETS.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>目标受众</span>
|
|
||||||
<input value={audienceText} onChange={(event) => setAudienceText(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>额外要求</span>
|
|
||||||
<textarea value={requirements} onChange={(event) => setRequirements(event.target.value)} rows={4} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{videoBackend === 'comfy' && (
|
|
||||||
<div className="money-printer-comfy-box">
|
|
||||||
<div className="money-printer-section-head">
|
|
||||||
<div>
|
|
||||||
<h2>Comfy API 工作流</h2>
|
|
||||||
<p>Comfy/Comfy Cloud 使用原生 /api/prompt 工作流接口,不是 OpenAI /v1 兼容接口。请选择真实视频模板,系统只注入提示词、画幅、分辨率和时长。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>工作流模板</span>
|
|
||||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
|
||||||
<option value="">请选择模板</option>
|
|
||||||
{workflows.map((workflow) => (
|
|
||||||
<option key={workflow.publicId} value={workflow.publicId}>
|
|
||||||
{workflowOptionLabel(workflow)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{selectedWorkflow ? (
|
|
||||||
<div className="money-printer-template-meta">
|
|
||||||
<strong>{selectedWorkflow.name}</strong>
|
|
||||||
<span>{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '这个模板没有内置节点映射,系统会尝试自动推断。'}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>Comfy prompt JSON</span>
|
|
||||||
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' />
|
|
||||||
</label>
|
|
||||||
<label className="money-printer-field">
|
|
||||||
<span>节点映射 JSON</span>
|
|
||||||
<textarea value={comfyNodeMapping} onChange={(event) => setComfyNodeMapping(event.target.value)} rows={7} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="money-printer-panel">
|
|
||||||
<div className="money-printer-section-head">
|
|
||||||
<div>
|
|
||||||
<h2>任务队列</h2>
|
|
||||||
<p>脚本、视频生成和工作空间归档都会在后台执行。</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void clear()}>清空已完成</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="money-printer-job-list">
|
|
||||||
{jobs.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ minHeight: 220 }}>
|
|
||||||
<div className="empty-state-title">暂无短视频任务</div>
|
|
||||||
<div className="empty-state-desc">提交主题后,系统会自动调用 BossAI 内置模型生成脚本和视频。</div>
|
|
||||||
</div>
|
|
||||||
) : jobs.map((job) => {
|
|
||||||
const percent = progress(job);
|
|
||||||
const assets = jobAssets(job);
|
|
||||||
return (
|
|
||||||
<article key={job.id} className="money-printer-job-card">
|
|
||||||
<div className="money-printer-job-head">
|
|
||||||
<div>
|
|
||||||
<strong>{job.title || '短视频工厂任务'}</strong>
|
|
||||||
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
|
|
||||||
</div>
|
|
||||||
<em>{statusLabel(job.status)}</em>
|
|
||||||
</div>
|
|
||||||
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
|
|
||||||
<div className="money-printer-job-meta">
|
|
||||||
<span>进度 {percent}%</span>
|
|
||||||
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
|
|
||||||
</div>
|
|
||||||
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
|
|
||||||
{job.error && <div className="money-printer-job-error">{job.error}</div>}
|
|
||||||
{assets.length > 0 && (
|
|
||||||
<div className="money-printer-assets">
|
|
||||||
{assets.map((asset: any) => (
|
|
||||||
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
|
|
||||||
成片 · {asset.publicId || asset.status}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="money-printer-job-buttons">
|
|
||||||
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)}>取消</button>}
|
|
||||||
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -108,7 +108,7 @@ export default function Monitors() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="page-title">{tr('监控内嵌')}</h2>
|
<h2 className="page-title">{tr('监控内嵌')}</h2>
|
||||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
在 BossAI 内查看外部站点监控页面。
|
在 BoosAPI 内查看外部站点监控页面。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
|||||||
@@ -2313,16 +2313,16 @@ export default function OAuthManagement() {
|
|||||||
|
|
||||||
{renderGuideCard(
|
{renderGuideCard(
|
||||||
'本地部署',
|
'本地部署',
|
||||||
'BossAI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
'BoosAPI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||||||
<div className="oauth-guide-copy">
|
<div className="oauth-guide-copy">
|
||||||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BossAI。
|
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BoosAPI。
|
||||||
</div>,
|
</div>,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSession.instructions.sshTunnelCommand
|
{activeSession.instructions.sshTunnelCommand
|
||||||
? renderGuideCard(
|
? renderGuideCard(
|
||||||
'云端部署',
|
'云端部署',
|
||||||
'BossAI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
'BoosAPI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
||||||
<div className="oauth-guide-block-list">
|
<div className="oauth-guide-block-list">
|
||||||
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
|
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
|
||||||
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ type PromptTemplate = {
|
|||||||
title: string;
|
title: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
size: string;
|
size: string;
|
||||||
image?: string | null;
|
image: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
source?: {
|
source?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -76,6 +76,7 @@ type GalleryItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
|
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
|
||||||
|
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
|
||||||
const TEMPLATE_LIMIT = 144;
|
const TEMPLATE_LIMIT = 144;
|
||||||
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
|
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
|
||||||
const ASPECT_OPTIONS = [
|
const ASPECT_OPTIONS = [
|
||||||
@@ -105,6 +106,7 @@ const STYLE_PRESETS = [
|
|||||||
prompt: 'modern Chinese illustration style, elegant linework, vivid but balanced colors, poster composition',
|
prompt: 'modern Chinese illustration style, elegant linework, vivid but balanced colors, poster composition',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function makeId() {
|
function makeId() {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -280,6 +282,39 @@ function isPictureModel(model: string) {
|
|||||||
return !chatKeywords.some((keyword) => normalized.includes(keyword));
|
return !chatKeywords.some((keyword) => normalized.includes(keyword));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeGalleryItems(items: GalleryItem[], limit = TEMPLATE_LIMIT): PromptTemplate[] {
|
||||||
|
return items
|
||||||
|
.map<PromptTemplate | null>((item) => {
|
||||||
|
const prompt = (item.prompt || '').trim();
|
||||||
|
const image = (item.images || []).find((value) => value.startsWith('http'))
|
||||||
|
|| (item.image_host_url || '').trim();
|
||||||
|
if (Array.from(prompt).length < 20 || !image.startsWith('http')) return null;
|
||||||
|
return {
|
||||||
|
id: item.post_number ? `linux-do-${item.post_number}` : `linux-do-${templateTitle(prompt)}`,
|
||||||
|
title: templateTitle(prompt, item.post_number),
|
||||||
|
tag: templateTag(prompt),
|
||||||
|
size: templateSize(prompt),
|
||||||
|
image,
|
||||||
|
prompt,
|
||||||
|
source: {
|
||||||
|
name: 'Linux Do GPT Image Gallery',
|
||||||
|
url: 'https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html',
|
||||||
|
postNumber: item.post_number,
|
||||||
|
username: item.username,
|
||||||
|
},
|
||||||
|
} satisfies PromptTemplate;
|
||||||
|
})
|
||||||
|
.filter((value): value is PromptTemplate => value !== null)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGalleryTemplatesDirectly(limit = TEMPLATE_LIMIT): Promise<PromptTemplate[]> {
|
||||||
|
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
|
||||||
|
const items = await response.json();
|
||||||
|
return normalizeGalleryItems(Array.isArray(items) ? items : [], limit);
|
||||||
|
}
|
||||||
|
|
||||||
export default function PictureAgent() {
|
export default function PictureAgent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
@@ -330,13 +365,6 @@ export default function PictureAgent() {
|
|||||||
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
|
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
|
||||||
[jobs],
|
[jobs],
|
||||||
);
|
);
|
||||||
const submitDisabledHint = useMemo(() => {
|
|
||||||
if (loading) return '任务正在提交';
|
|
||||||
if (!model.trim()) return '正在加载或等待选择生图模型';
|
|
||||||
if (!prompt.trim()) return '请输入提示词';
|
|
||||||
if (mode === 'edit' && !sourceFile) return '图像编辑需要先上传参考图';
|
|
||||||
return '';
|
|
||||||
}, [loading, mode, model, prompt, sourceFile]);
|
|
||||||
|
|
||||||
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
|
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
|
||||||
return fetchSessionResponse(audience, url, options);
|
return fetchSessionResponse(audience, url, options);
|
||||||
@@ -378,82 +406,53 @@ export default function PictureAgent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBootError('');
|
setBootError('');
|
||||||
setStatus((current) => current || {
|
|
||||||
success: true,
|
|
||||||
mode: 'local',
|
|
||||||
upstreamLabel: 'BossAI 本地图片路由',
|
|
||||||
keyName: '加载密钥中',
|
|
||||||
});
|
|
||||||
setModel((current) => current || 'gpt-image-2');
|
|
||||||
setTemplates([]);
|
|
||||||
setTemplatesError('');
|
|
||||||
setTemplatesLoading(true);
|
setTemplatesLoading(true);
|
||||||
|
setTemplatesError('');
|
||||||
try {
|
try {
|
||||||
const [statusResult, modelsResult, jobsResult] = await Promise.allSettled([
|
const [statusRes, modelsRes, templatesRes, jobsRes] = await Promise.all([
|
||||||
authedFetch('/api/picture-agent/status', { timeoutMs: 8_000 }),
|
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
|
||||||
authedFetch('/api/picture-agent/models', { timeoutMs: 8_000 }),
|
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
|
||||||
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 8_000 }),
|
authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 30_000 }),
|
||||||
|
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 }),
|
||||||
]);
|
]);
|
||||||
|
if (!statusRes.ok) throw new Error(await parseError(statusRes));
|
||||||
|
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
|
||||||
|
const nextStatus = await statusRes.json();
|
||||||
|
const nextModels = await modelsRes.json();
|
||||||
|
let nextTemplates: PromptTemplate[] = [];
|
||||||
|
if (templatesRes.ok) {
|
||||||
|
const templatePayload = await templatesRes.json();
|
||||||
|
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
nextTemplates = await loadGalleryTemplatesDirectly(TEMPLATE_LIMIT);
|
||||||
|
} catch {
|
||||||
|
setTemplatesError(await parseError(templatesRes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nextJobs: PictureJob[] = [];
|
||||||
|
if (jobsRes.ok) {
|
||||||
|
const jobsPayload = await jobsRes.json();
|
||||||
|
nextJobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : [];
|
||||||
|
}
|
||||||
if (ignore) return;
|
if (ignore) return;
|
||||||
|
const availableModels = Array.isArray(nextModels?.models)
|
||||||
if (statusResult.status === 'fulfilled') {
|
? nextModels.models.filter((item: string) => isPictureModel(item))
|
||||||
if (!statusResult.value.ok) {
|
: [];
|
||||||
throw new Error(await parseError(statusResult.value));
|
setStatus(nextStatus);
|
||||||
}
|
setModels(availableModels);
|
||||||
setStatus(await statusResult.value.json());
|
setTemplates(nextTemplates);
|
||||||
} else {
|
setJobs(nextJobs);
|
||||||
throw new Error(statusResult.reason?.message || '生图助手状态加载失败');
|
hydrateImagesFromJobs(nextJobs);
|
||||||
}
|
setModel((current) => {
|
||||||
|
if (current && isPictureModel(current)) return current;
|
||||||
if (modelsResult.status === 'fulfilled') {
|
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
|
||||||
if (!modelsResult.value.ok) {
|
? nextModels.defaultModel
|
||||||
throw new Error(await parseError(modelsResult.value));
|
: availableModels[0];
|
||||||
}
|
return fallback || 'gpt-image-2';
|
||||||
const nextModels = await modelsResult.value.json();
|
});
|
||||||
if (ignore) return;
|
|
||||||
const availableModels = Array.isArray(nextModels?.models)
|
|
||||||
? nextModels.models.filter((item: string) => isPictureModel(item))
|
|
||||||
: [];
|
|
||||||
setModels(availableModels);
|
|
||||||
setModel((current) => {
|
|
||||||
if (current && isPictureModel(current)) return current;
|
|
||||||
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
|
|
||||||
? nextModels.defaultModel
|
|
||||||
: availableModels[0];
|
|
||||||
return fallback || 'gpt-image-2';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(modelsResult.reason?.message || '生图模型加载失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobsResult.status === 'fulfilled' && jobsResult.value.ok) {
|
|
||||||
const jobsPayload = await jobsResult.value.json();
|
|
||||||
if (ignore) return;
|
|
||||||
const nextJobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : [];
|
|
||||||
setJobs(nextJobs);
|
|
||||||
hydrateImagesFromJobs(nextJobs);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
|
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const templatesRes = await authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 10_000 });
|
|
||||||
if (ignore) return;
|
|
||||||
if (!templatesRes.ok) {
|
|
||||||
setTemplatesError(await parseError(templatesRes));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const templatePayload = await templatesRes.json();
|
|
||||||
if (ignore) return;
|
|
||||||
const nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
|
|
||||||
if (nextTemplates.length) {
|
|
||||||
setTemplates(nextTemplates);
|
|
||||||
}
|
|
||||||
setTemplatesError('');
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!ignore) setTemplatesError(err?.message || '本地 Linux Do 模板库加载失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!ignore) setTemplatesLoading(false);
|
if (!ignore) setTemplatesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -721,7 +720,7 @@ export default function PictureAgent() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">生图助手</h1>
|
<h1 className="page-title">生图助手</h1>
|
||||||
<p className="page-subtitle">
|
<p className="page-subtitle">
|
||||||
{modeLabel} · {status?.upstreamLabel || 'BossAI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
@@ -857,9 +856,6 @@ export default function PictureAgent() {
|
|||||||
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
||||||
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||||
</button>
|
</button>
|
||||||
{!canSubmit && submitDisabledHint ? (
|
|
||||||
<div className="picture-agent-submit-hint">{submitDisabledHint}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="picture-agent-job-summary">
|
<div className="picture-agent-job-summary">
|
||||||
<div>
|
<div>
|
||||||
@@ -876,15 +872,7 @@ export default function PictureAgent() {
|
|||||||
<div className="picture-agent-template-strip">
|
<div className="picture-agent-template-strip">
|
||||||
<div className="picture-agent-template-title">
|
<div className="picture-agent-template-title">
|
||||||
<strong>参考模板</strong>
|
<strong>参考模板</strong>
|
||||||
<span>
|
<span>{templatesLoading ? '正在刷新模板...' : '每次打开自动获取 Linux Do GPT Image Gallery'}</span>
|
||||||
{templatesLoading && templates.length === 0
|
|
||||||
? '正在加载参考模板...'
|
|
||||||
: templatesLoading
|
|
||||||
? '正在读取本地 Linux Do 模板库'
|
|
||||||
: templatesError
|
|
||||||
? '本地模板库加载失败'
|
|
||||||
: '来自本地 Linux Do GPT Image Gallery'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="picture-agent-template-list">
|
<div className="picture-agent-template-list">
|
||||||
{templatesError && templates.length === 0 ? (
|
{templatesError && templates.length === 0 ? (
|
||||||
@@ -899,14 +887,7 @@ export default function PictureAgent() {
|
|||||||
onClick={() => applyTemplate(template)}
|
onClick={() => applyTemplate(template)}
|
||||||
title={template.prompt}
|
title={template.prompt}
|
||||||
>
|
>
|
||||||
{template.image ? (
|
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
|
||||||
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
|
|
||||||
) : (
|
|
||||||
<div className="picture-agent-template-placeholder">
|
|
||||||
<strong>{template.tag}</strong>
|
|
||||||
<small>{template.size}</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span>
|
<span>
|
||||||
<strong>{template.title}</strong>
|
<strong>{template.title}</strong>
|
||||||
<em>{template.tag}{template.source?.postNumber ? ` · #${template.source.postNumber}` : ''}</em>
|
<em>{template.tag}{template.source?.postNumber ? ` · #${template.source.postNumber}` : ''}</em>
|
||||||
|
|||||||
@@ -1799,7 +1799,7 @@ export default function Settings() {
|
|||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
|
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
|
||||||
<div style={settingsModernDescriptionStyle}>
|
<div style={settingsModernDescriptionStyle}>
|
||||||
默认采用 HTTP 优先。只有这里开启后,BossAI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
默认采用 HTTP 优先。只有这里开启后,BoosAPI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={settingsModernPillRowStyle}>
|
<div style={settingsModernPillRowStyle}>
|
||||||
@@ -1813,7 +1813,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<label style={settingsModernToggleStyle}>
|
<label style={settingsModernToggleStyle}>
|
||||||
<div style={settingsModernToggleCopyStyle}>
|
<div style={settingsModernToggleCopyStyle}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BossAI 到 Codex 上游使用 WebSocket</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 到 Codex 上游使用 WebSocket</span>
|
||||||
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||||
仅在下游 Codex 客户端已同步开启 `/v1/responses` websocket 时启用;否则仍按 HTTP 优先执行。
|
仅在下游 Codex 客户端已同步开启 `/v1/responses` websocket 时启用;否则仍按 HTTP 优先执行。
|
||||||
</span>
|
</span>
|
||||||
@@ -1896,7 +1896,7 @@ export default function Settings() {
|
|||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
|
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
|
||||||
<div style={settingsModernDescriptionStyle}>
|
<div style={settingsModernDescriptionStyle}>
|
||||||
默认关闭。开启后,BossAI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
默认关闭。开启后,BoosAPI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={settingsModernPillRowStyle}>
|
<div style={settingsModernPillRowStyle}>
|
||||||
@@ -1922,7 +1922,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<label style={settingsModernToggleStyle}>
|
<label style={settingsModernToggleStyle}>
|
||||||
<div style={settingsModernToggleCopyStyle}>
|
<div style={settingsModernToggleCopyStyle}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BossAI 后台主动批量测活</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 后台主动批量测活</span>
|
||||||
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||||
首次从关闭切换到开启时,需要手动输入确认语句;关闭时可直接保存。
|
首次从关闭切换到开启时,需要手动输入确认语句;关闭时可直接保存。
|
||||||
</span>
|
</span>
|
||||||
@@ -2599,7 +2599,7 @@ export default function Settings() {
|
|||||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
|
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
|
||||||
重新初始化系统会清空当前 BossAI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BossAI 数据,然后切回默认 SQLite。
|
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
|
||||||
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
|
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @Author: 橘子
|
* @Author: 橘子
|
||||||
* @Project_description: BossAI 站点管理页
|
* @Project_description: BoosAPI 站点管理页
|
||||||
* @Description: 代码是我抄的,不会也是真的
|
* @Description: 代码是我抄的,不会也是真的
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|||||||
@@ -410,14 +410,7 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
|
const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
|
||||||
const createdChannels = res?.rebuild?.createdChannels ?? 0;
|
const createdChannels = res?.rebuild?.createdChannels ?? 0;
|
||||||
const reusedRoutes = Number(res?.rebuild?.reusedRoutes ?? 0);
|
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
|
||||||
const totalRoutes = Number(res?.totalRoutes ?? 0);
|
|
||||||
const totalChannels = Number(res?.totalChannels ?? 0);
|
|
||||||
toast.success(
|
|
||||||
totalRoutes > 0
|
|
||||||
? `自动重建完成(共 ${totalRoutes} 条路由 / ${totalChannels} 个通道,新增 ${createdRoutes} / ${createdChannels},复用 ${reusedRoutes})`
|
|
||||||
: `自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`,
|
|
||||||
);
|
|
||||||
invalidateChannels();
|
invalidateChannels();
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -1099,7 +1099,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
<input
|
<input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
placeholder="例如 BossAI"
|
placeholder="例如 BoosAPI"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+101
-131
@@ -29,32 +29,12 @@ interface UserSummary {
|
|||||||
|
|
||||||
interface LogItem {
|
interface LogItem {
|
||||||
id: number;
|
id: number;
|
||||||
model_requested?: string;
|
model_requested: string;
|
||||||
model_actual?: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
latency_ms?: number | null;
|
latency_ms: number;
|
||||||
estimated_cost?: number | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMoney(value?: number | null) {
|
|
||||||
const amount = Number(value || 0);
|
|
||||||
if (amount >= 1) return `$${amount.toFixed(3)}`;
|
|
||||||
return `$${amount.toFixed(6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLatency(value?: number | null) {
|
|
||||||
const ms = Number(value || 0);
|
|
||||||
if (!ms) return '暂无';
|
|
||||||
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateLabel(value?: string) {
|
|
||||||
const date = value ? new Date(value) : null;
|
|
||||||
if (!date || Number.isNaN(date.getTime())) return '时间未知';
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
@@ -77,23 +57,44 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const dashboardRes: any = await userApi.getDashboard();
|
const profileRes = await userApi.me();
|
||||||
setProfile(dashboardRes.user || null);
|
setProfile(profileRes.user);
|
||||||
setKeys(Array.isArray(dashboardRes.keys) ? dashboardRes.keys : []);
|
|
||||||
setSummary(dashboardRes.summary || { totalRequests: 0, successRequests: 0, totalCost: 0 });
|
|
||||||
setRecentLogs(Array.isArray(dashboardRes.recentLogs) ? dashboardRes.recentLogs : []);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '加载失败');
|
setError(err?.message || '加载失败');
|
||||||
if (String(err?.message || '').includes('401') || String(err?.message || '').includes('Unauthorized')) {
|
if (String(err?.message || '').includes('401') || String(err?.message || '').includes('Unauthorized')) {
|
||||||
clearSession('user', localStorage);
|
clearSession('user', localStorage);
|
||||||
navigate('/user/login', { replace: true });
|
navigate('/user/login', { replace: true });
|
||||||
}
|
}
|
||||||
setKeys([]);
|
|
||||||
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
|
||||||
setRecentLogs([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [keysRes, summaryRes, logsRes] = await Promise.allSettled([
|
||||||
|
userApi.getApiKeys(),
|
||||||
|
userApi.getSummary(),
|
||||||
|
userApi.getLogs({ limit: 5 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (keysRes.status === 'fulfilled') {
|
||||||
|
const payload: any = keysRes.value;
|
||||||
|
setKeys(Array.isArray(payload) ? payload : (payload.keys || []));
|
||||||
|
} else {
|
||||||
|
setKeys([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryRes.status === 'fulfilled') {
|
||||||
|
setSummary(summaryRes.value as UserSummary);
|
||||||
|
} else {
|
||||||
|
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logsRes.status === 'fulfilled') {
|
||||||
|
const payload: any = logsRes.value;
|
||||||
|
setRecentLogs(Array.isArray(payload?.items) ? payload.items : []);
|
||||||
|
} else {
|
||||||
|
setRecentLogs([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
}, [checkAuth, navigate]);
|
}, [checkAuth, navigate]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
@@ -106,26 +107,16 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="user-dashboard-page">
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', minHeight: '400px' }}>
|
||||||
<div className="user-dashboard-skeleton">
|
<div className="spinner" />
|
||||||
<div className="skeleton" style={{ width: 220, height: 28 }} />
|
|
||||||
<div className="user-dashboard-stat-grid">
|
|
||||||
{[0, 1, 2].map((item) => (
|
|
||||||
<div key={item} className="card user-dashboard-stat">
|
|
||||||
<div className="skeleton" style={{ width: 92, height: 14, marginBottom: 12 }} />
|
|
||||||
<div className="skeleton" style={{ width: 130, height: 30 }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="user-dashboard-page">
|
<div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<div className="alert alert-error">
|
<div className="card" style={{ padding: 20, color: 'var(--color-danger)' }}>
|
||||||
{error || '用户信息加载失败'}
|
{error || '用户信息加载失败'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,90 +126,69 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
const successRate = summary && summary.totalRequests > 0
|
const successRate = summary && summary.totalRequests > 0
|
||||||
? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1)
|
? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1)
|
||||||
: '0.0';
|
: '0.0';
|
||||||
const failedRequests = Math.max(0, (summary?.totalRequests || 0) - (summary?.successRequests || 0));
|
|
||||||
const activeKeyCount = keys.filter((key) => key.enabled).length;
|
|
||||||
const userInitial = (profile.username || profile.email || 'U').trim().slice(0, 1).toUpperCase();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-dashboard-page animate-fade-in">
|
<div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<header className="user-dashboard-hero">
|
<header style={{ marginBottom: '24px' }}>
|
||||||
<div className="user-dashboard-identity">
|
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px 0' }}>你好, {profile.username}</h1>
|
||||||
<div className="user-dashboard-avatar">{userInitial}</div>
|
<p style={{ color: 'var(--color-text-muted)', margin: 0 }}>欢迎回到 BoosAPI 用户中心。</p>
|
||||||
<div>
|
|
||||||
<h1 className="page-title">你好,{profile.username}</h1>
|
|
||||||
<p className="page-subtitle">这里汇总你的模型调用、密钥状态和创作入口。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-dashboard-header-actions">
|
|
||||||
<Link to="/user/models" className="btn btn-ghost">模型广场</Link>
|
|
||||||
<Link to="/user/usage" className="btn btn-primary">使用记录</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="user-dashboard-stat-grid">
|
{/* Summary Cards */}
|
||||||
<div className="card user-dashboard-stat">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '32px' }}>
|
||||||
<div className="user-dashboard-stat-label">总请求次数</div>
|
<div className="card" style={{ padding: '20px' }}>
|
||||||
<div className="user-dashboard-stat-value">{summary?.totalRequests || 0}</div>
|
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总请求次数</div>
|
||||||
<div className="user-dashboard-stat-note">{failedRequests} 次失败</div>
|
<div style={{ fontSize: '28px', fontWeight: 700 }}>{summary?.totalRequests || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card user-dashboard-stat">
|
<div className="card" style={{ padding: '20px' }}>
|
||||||
<div className="user-dashboard-stat-label">请求成功率</div>
|
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>请求成功率</div>
|
||||||
<div className="user-dashboard-stat-value is-success">{successRate}%</div>
|
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--color-success)' }}>{successRate}%</div>
|
||||||
<div className="user-dashboard-stat-note">{summary?.successRequests || 0} 次成功</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card user-dashboard-stat">
|
<div className="card" style={{ padding: '20px' }}>
|
||||||
<div className="user-dashboard-stat-label">总消耗金额</div>
|
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总消耗金额</div>
|
||||||
<div className="user-dashboard-stat-value">{formatMoney(summary?.totalCost)}</div>
|
<div style={{ fontSize: '28px', fontWeight: 700 }}>${summary?.totalCost ? summary.totalCost.toFixed(4) : '0.0000'}</div>
|
||||||
<div className="user-dashboard-stat-note">{activeKeyCount}/{keys.length} 个密钥启用</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-dashboard-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
|
||||||
<div className="user-dashboard-column">
|
{/* Left Column: Recent Logs & Quick Entries */}
|
||||||
<section className="card user-dashboard-panel">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
<div className="user-dashboard-panel-head">
|
{/* Quick Entries */}
|
||||||
<h2>快速入口</h2>
|
<section className="card" style={{ padding: '20px' }}>
|
||||||
</div>
|
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
|
||||||
<div className="user-dashboard-entry-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
<Link to="/user/workflows" className="user-dashboard-entry">
|
<Link to="/user/workflows" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||||
<svg width="24" height="24" 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>
|
<svg width="24" height="24" 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>
|
||||||
<span>工作流</span>
|
<span>工作流</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/user/comfyui-agent" className="user-dashboard-entry">
|
<Link to="/user/comfyui-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||||
<svg width="24" height="24" 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>
|
<svg width="24" height="24" 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>
|
||||||
<span>视频助手</span>
|
<span>视频助手</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/user/picture-agent" className="user-dashboard-entry">
|
|
||||||
<svg width="24" height="24" 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>
|
|
||||||
<span>生图助手</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/user/workspace" className="user-dashboard-entry">
|
|
||||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg>
|
|
||||||
<span>工作空间</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card user-dashboard-panel">
|
{/* Recent Records */}
|
||||||
<div className="user-dashboard-panel-head">
|
<section className="card" style={{ padding: '20px' }}>
|
||||||
<h2>最近记录</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
<Link to="/user/usage" className="btn btn-link btn-link-primary">查看全部</Link>
|
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>最近记录</h2>
|
||||||
|
<Link to="/user/usage" style={{ fontSize: '13px', color: 'var(--color-primary)', textDecoration: 'none' }}>查看全部</Link>
|
||||||
</div>
|
</div>
|
||||||
{recentLogs.length === 0 ? (
|
{recentLogs.length === 0 ? (
|
||||||
<div className="user-dashboard-empty">暂无记录</div>
|
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>暂无记录</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="user-dashboard-list">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{recentLogs.map(log => (
|
{recentLogs.map(log => (
|
||||||
<div key={log.id} className="user-dashboard-row">
|
<div key={log.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '10px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="user-dashboard-row-title">{log.model_actual || log.model_requested || '未知模型'}</div>
|
<div style={{ fontSize: '14px', fontWeight: 500 }}>{log.model_requested}</div>
|
||||||
<div className="user-dashboard-row-meta">{formatDateLabel(log.created_at)}</div>
|
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{new Date(log.created_at).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-dashboard-row-side">
|
<div style={{ textAlign: 'right' }}>
|
||||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}>
|
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
|
||||||
{log.status === 'success' ? '成功' : '失败'}
|
{log.status === 'success' ? '成功' : '失败'}
|
||||||
</span>
|
</span>
|
||||||
<div>{formatLatency(log.latency_ms)}</div>
|
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', marginTop: '2px' }}>{log.latency_ms}ms</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -227,59 +197,59 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-dashboard-column">
|
{/* Right Column: API Keys */}
|
||||||
<section className="card user-dashboard-panel">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
<div className="user-dashboard-panel-head">
|
<section className="card" style={{ padding: '20px' }}>
|
||||||
<h2>API 密钥</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
|
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>API 密钥</h2>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-dashboard-list">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<div className="user-dashboard-empty">尚未创建密钥</div>
|
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>尚未创建密钥</div>
|
||||||
) : (
|
) : (
|
||||||
keys.slice(0, 3).map(key => (
|
keys.slice(0, 3).map(key => (
|
||||||
<div key={key.id} className="user-dashboard-key-row">
|
<div key={key.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', borderRadius: '8px', background: 'var(--color-bg)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="user-dashboard-row-title">{key.name}</div>
|
<div style={{ fontSize: '14px', fontWeight: 500 }}>{key.name}</div>
|
||||||
<div className="user-dashboard-key-value">
|
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
|
||||||
{key.key ? `${key.key.slice(0, 12)}...` : '••••••••'}
|
{key.key ? `${key.key.slice(0, 12)}...` : '********'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`}>
|
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
|
||||||
{key.enabled ? '启用' : '禁用'}
|
{key.enabled ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{keys.length > 3 && (
|
{keys.length > 3 && (
|
||||||
<div className="user-dashboard-more">
|
<div style={{ textAlign: 'center', fontSize: '12px', color: 'var(--color-text-muted)' }}>
|
||||||
还有 {keys.length - 3} 个密钥
|
还有 {keys.length - 3} 个密钥...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card user-dashboard-panel">
|
{/* Account Info */}
|
||||||
<div className="user-dashboard-panel-head">
|
<section className="card" style={{ padding: '20px' }}>
|
||||||
<h2>账号信息</h2>
|
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>账号信息</h2>
|
||||||
</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
|
||||||
<div className="user-dashboard-profile-list">
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<span style={{ color: 'var(--color-text-muted)' }}>邮箱</span>
|
||||||
<span>邮箱</span>
|
|
||||||
<span>{profile.email}</span>
|
<span>{profile.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<span>账号角色</span>
|
<span style={{ color: 'var(--color-text-muted)' }}>账号角色</span>
|
||||||
<span className="badge badge-success">{profile.role === 'admin' ? '管理员' : '普通用户'}</span>
|
<span className="badge badge-success">{profile.role === 'admin' ? '管理员' : '普通用户'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<span>注册时间</span>
|
<span style={{ color: 'var(--color-text-muted)' }}>注册时间</span>
|
||||||
<span>{new Date(profile.createdAt).toLocaleDateString()}</span>
|
<span>{new Date(profile.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-dashboard-profile-actions">
|
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
|
||||||
<button className="btn btn-ghost" onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
|
<button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
|
||||||
<button className="btn btn-ghost btn-link-danger" onClick={handleLogout}>退出登录</button>
|
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={handleLogout}>退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+75
-61
@@ -9,11 +9,9 @@ const DOCKER_WORKFLOW_URL = '/api/comfyui/proxy/';
|
|||||||
|
|
||||||
export default function Workflows() {
|
export default function Workflows() {
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
||||||
const [workflowUrl, setWorkflowUrl] = React.useState(LOCAL_WORKFLOW_URL);
|
const [workflowUrl, setWorkflowUrl] = React.useState(LOCAL_WORKFLOW_URL);
|
||||||
const [backendLabel, setBackendLabel] = React.useState('');
|
const [isCloud, setIsCloud] = React.useState(false);
|
||||||
const [reloadKey, setReloadKey] = React.useState(0);
|
|
||||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
const openFullscreen = () => {
|
const openFullscreen = () => {
|
||||||
@@ -22,13 +20,8 @@ export default function Workflows() {
|
|||||||
|
|
||||||
const reloadFrame = () => {
|
const reloadFrame = () => {
|
||||||
if (iframeRef.current) {
|
if (iframeRef.current) {
|
||||||
setLoading(true);
|
|
||||||
iframeRef.current.src = workflowUrl;
|
iframeRef.current.src = workflowUrl;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setLoading(true);
|
|
||||||
setMessage('正在重新检查工作流入口...');
|
|
||||||
setReloadKey((value) => value + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -37,71 +30,57 @@ export default function Workflows() {
|
|||||||
const loadRuntime = async () => {
|
const loadRuntime = async () => {
|
||||||
const runtimeResponse = await fetch('/api/workflow-ui/runtime', { signal: controller.signal });
|
const runtimeResponse = await fetch('/api/workflow-ui/runtime', { signal: controller.signal });
|
||||||
const runtime = runtimeResponse.ok ? ((await runtimeResponse.json()) as WorkflowRuntime) : {};
|
const runtime = runtimeResponse.ok ? ((await runtimeResponse.json()) as WorkflowRuntime) : {};
|
||||||
|
const cloudMode = runtime.mode === 'cloud';
|
||||||
const dockerMode = runtime.mode === 'docker';
|
const dockerMode = runtime.mode === 'docker';
|
||||||
const targetUrl = dockerMode ? DOCKER_WORKFLOW_URL : LOCAL_WORKFLOW_URL;
|
const targetUrl = dockerMode ? DOCKER_WORKFLOW_URL : LOCAL_WORKFLOW_URL;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBackendLabel(resolveBackendLabel(runtime.mode));
|
setIsCloud(cloudMode);
|
||||||
setWorkflowUrl(targetUrl);
|
setWorkflowUrl(targetUrl);
|
||||||
const response = await fetch(targetUrl, { signal: controller.signal });
|
const response = await fetch(targetUrl, { signal: controller.signal });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setReady(response.ok);
|
setReady(response.ok);
|
||||||
setLoading(false);
|
|
||||||
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||||
};
|
};
|
||||||
void loadRuntime().catch((error) => {
|
void loadRuntime().catch((error) => {
|
||||||
if (cancelled || error?.name === 'AbortError') return;
|
if (cancelled || error?.name === 'AbortError') return;
|
||||||
setReady(false);
|
setReady(false);
|
||||||
setLoading(false);
|
|
||||||
setMessage('工作流入口不可用,请检查服务配置');
|
setMessage('工作流入口不可用,请检查服务配置');
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [reloadKey]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workflow-shell">
|
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="workflow-toolbar">
|
<div style={toolbarStyle}>
|
||||||
<div className="workflow-toolbar-main">
|
<div style={toolbarTitleStyle}>工作流</div>
|
||||||
<div className="workflow-title">工作流</div>
|
{!isCloud && workflowUrl === DOCKER_WORKFLOW_URL && <div style={cloudBadgeStyle}>Docker 完整版 ComfyUI</div>}
|
||||||
{backendLabel && <div className="workflow-backend-badge">{backendLabel}</div>}
|
{isCloud && <div style={cloudBadgeStyle}>Comfy Cloud API 后端</div>}
|
||||||
</div>
|
<div style={toolbarActionsStyle}>
|
||||||
<div className="workflow-actions">
|
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
||||||
<button type="button" onClick={reloadFrame} disabled={!ready} className="btn btn-link workflow-action-btn">
|
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={openFullscreen} className="btn btn-primary workflow-primary-btn">
|
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
|
||||||
新窗口打开
|
新窗口打开
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<div className="workflow-frame-wrap">
|
<iframe
|
||||||
{loading && (
|
ref={iframeRef}
|
||||||
<div className="workflow-frame-loading" aria-live="polite">
|
src={workflowUrl}
|
||||||
<div className="workflow-loading-bar" />
|
title="Workflow Studio"
|
||||||
<span>正在连接工作流前端...</span>
|
allow="clipboard-read; clipboard-write"
|
||||||
</div>
|
referrerPolicy="no-referrer"
|
||||||
)}
|
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
||||||
<iframe
|
/>
|
||||||
ref={iframeRef}
|
|
||||||
src={workflowUrl}
|
|
||||||
title="Workflow Studio"
|
|
||||||
allow="clipboard-read; clipboard-write"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onLoad={() => setLoading(false)}
|
|
||||||
className="workflow-frame"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="workflow-empty">
|
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
|
||||||
<div className="workflow-empty-panel">
|
<div>
|
||||||
<div className="workflow-empty-title">Workflow Studio</div>
|
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
|
||||||
<div className="workflow-empty-desc">{message}</div>
|
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
|
||||||
<button type="button" onClick={reloadFrame} className="btn btn-soft-primary workflow-empty-btn">
|
|
||||||
重新检查
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -109,19 +88,54 @@ export default function Workflows() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBackendLabel(mode?: string) {
|
const toolbarStyle: React.CSSProperties = {
|
||||||
switch (mode) {
|
minHeight: 44,
|
||||||
case 'cloud':
|
display: 'flex',
|
||||||
return 'Comfy Cloud API 后端';
|
alignItems: 'center',
|
||||||
case 'runninghub':
|
justifyContent: 'space-between',
|
||||||
return 'RunningHub API 后端';
|
gap: 12,
|
||||||
case 'external':
|
padding: '8px 12px',
|
||||||
return '外部 ComfyUI API 后端';
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
case 'docker':
|
background: 'var(--color-surface)',
|
||||||
return 'Docker 完整版 ComfyUI';
|
};
|
||||||
case 'boosapi':
|
|
||||||
return 'BoosAPI 工作流执行器';
|
const cloudBadgeStyle: React.CSSProperties = {
|
||||||
default:
|
marginRight: 'auto',
|
||||||
return '';
|
border: '1px solid var(--color-border)',
|
||||||
}
|
borderRadius: 999,
|
||||||
}
|
padding: '3px 8px',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarTitleStyle: React.CSSProperties = {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarActionsStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarButtonStyle: React.CSSProperties = {
|
||||||
|
minHeight: 30,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '5px 10px',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryToolbarButtonStyle: React.CSSProperties = {
|
||||||
|
...toolbarButtonStyle,
|
||||||
|
borderColor: 'var(--color-primary)',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|||||||
+325
-149
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
|
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
|
||||||
import type { SessionAudience } from '../authSession.js';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
@@ -34,7 +34,6 @@ function formatBytes(value?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceLabel(value: string) {
|
function sourceLabel(value: string) {
|
||||||
if (value === 'workflow-ui') return '工作流';
|
|
||||||
return SOURCE_OPTIONS.find((item) => item.value === value)?.label || value || '未知来源';
|
return SOURCE_OPTIONS.find((item) => item.value === value)?.label || value || '未知来源';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,18 +86,10 @@ export default function Workspace() {
|
|||||||
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
|
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
|
||||||
const [loadedLocalPreviews, setLoadedLocalPreviews] = useState<Set<string>>(() => new Set());
|
const [loadedLocalPreviews, setLoadedLocalPreviews] = useState<Set<string>>(() => new Set());
|
||||||
const [selectedId, setSelectedId] = useState('');
|
const [selectedId, setSelectedId] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const objectUrlsRef = useRef<Set<string>>(new Set());
|
const objectUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
|
const selected = items.find((item) => item.publicId === selectedId) || items[0] || null;
|
||||||
const visibleIds = useMemo(() => items.map((item) => item.publicId), [items]);
|
|
||||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
||||||
const selectedVisibleCount = useMemo(
|
|
||||||
() => visibleIds.filter((publicId) => selectedIdSet.has(publicId)).length,
|
|
||||||
[selectedIdSet, visibleIds],
|
|
||||||
);
|
|
||||||
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
|
|
||||||
const sourceStats = useMemo(() => {
|
const sourceStats = useMemo(() => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
items.forEach((item) => counts.set(item.source, (counts.get(item.source) || 0) + 1));
|
items.forEach((item) => counts.set(item.source, (counts.get(item.source) || 0) + 1));
|
||||||
@@ -118,7 +109,6 @@ export default function Workspace() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setSelectedIds([]);
|
|
||||||
}, [kind, query, source]);
|
}, [kind, query, source]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -226,7 +216,6 @@ export default function Workspace() {
|
|||||||
} else {
|
} else {
|
||||||
await api.deleteWorkspaceAsset(asset.publicId);
|
await api.deleteWorkspaceAsset(asset.publicId);
|
||||||
}
|
}
|
||||||
setSelectedIds((current) => current.filter((publicId) => publicId !== asset.publicId));
|
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '删除失败');
|
setError(err?.message || '删除失败');
|
||||||
@@ -262,64 +251,23 @@ export default function Workspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectedAsset = useCallback((publicId: string) => {
|
|
||||||
setSelectedIds((current) => (
|
|
||||||
current.includes(publicId)
|
|
||||||
? current.filter((item) => item !== publicId)
|
|
||||||
: [...current, publicId]
|
|
||||||
));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSelectVisible = useCallback(() => {
|
|
||||||
setSelectedIds((current) => {
|
|
||||||
const visibleSet = new Set(visibleIds);
|
|
||||||
if (allVisibleSelected) {
|
|
||||||
return current.filter((publicId) => !visibleSet.has(publicId));
|
|
||||||
}
|
|
||||||
const next = new Set(current);
|
|
||||||
visibleIds.forEach((publicId) => next.add(publicId));
|
|
||||||
return Array.from(next);
|
|
||||||
});
|
|
||||||
}, [allVisibleSelected, visibleIds]);
|
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
|
||||||
if (selectedIds.length === 0) return;
|
|
||||||
if (!window.confirm(`删除已选的 ${selectedIds.length} 个成品?`)) return;
|
|
||||||
try {
|
|
||||||
const response = audience === 'user'
|
|
||||||
? await userApi.batchDeleteWorkspaceAssets(selectedIds)
|
|
||||||
: await api.batchDeleteWorkspaceAssets(selectedIds);
|
|
||||||
const deletedIds = Array.isArray(response.deletedIds) ? response.deletedIds : [];
|
|
||||||
setSelectedIds((current) => current.filter((publicId) => !deletedIds.includes(publicId)));
|
|
||||||
if (selectedId && deletedIds.includes(selectedId)) {
|
|
||||||
setSelectedId('');
|
|
||||||
}
|
|
||||||
if (response.failedIds?.length) {
|
|
||||||
setError(`已删除 ${response.deletedCount || deletedIds.length} 个,${response.failedIds.length} 个未找到或无权限`);
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || '批量删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPreview = (asset: WorkspaceAsset, compact = false) => {
|
const renderPreview = (asset: WorkspaceAsset, compact = false) => {
|
||||||
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||||
const isLargeLocal = Boolean(asset.hasLocalFile && Number(asset.sizeBytes || 0) > AUTO_PREVIEW_LIMIT_BYTES);
|
const isLargeLocal = Boolean(asset.hasLocalFile && Number(asset.sizeBytes || 0) > AUTO_PREVIEW_LIMIT_BYTES);
|
||||||
if (asset.kind === 'image' && preview) {
|
if (asset.kind === 'image' && preview) {
|
||||||
return <img src={preview} alt="" className="workspace-media" loading="lazy" />;
|
return <img src={preview} alt="" style={mediaStyle} loading="lazy" />;
|
||||||
}
|
}
|
||||||
if (asset.kind === 'video' && preview) {
|
if (asset.kind === 'video' && preview) {
|
||||||
return <video src={preview} className="workspace-media" muted controls={!compact} preload="metadata" />;
|
return <video src={preview} style={mediaStyle} muted controls={!compact} preload="metadata" />;
|
||||||
}
|
}
|
||||||
if (asset.kind === 'audio' && preview) {
|
if (asset.kind === 'audio' && preview) {
|
||||||
return compact ? <div className="workspace-placeholder">音频</div> : <audio src={preview} controls className="workspace-audio" />;
|
return compact ? <div style={placeholderStyle}>音频</div> : <audio src={preview} controls style={{ width: '100%' }} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="workspace-placeholder">
|
<div style={placeholderStyle}>
|
||||||
<span>{kindLabel(asset.kind)}</span>
|
<span>{kindLabel(asset.kind)}</span>
|
||||||
{asset.hasLocalFile && canRenderPreview(asset) ? (
|
{asset.hasLocalFile && canRenderPreview(asset) ? (
|
||||||
<span className="workspace-placeholder-hint">
|
<span style={placeholderHintStyle}>
|
||||||
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
|
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -330,12 +278,12 @@ export default function Workspace() {
|
|||||||
const hasMore = items.length < total;
|
const hasMore = items.length < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-page animate-fade-in">
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">工作空间</h1>
|
<h1 className="page-title">工作空间</h1>
|
||||||
<p className="page-subtitle">
|
<p className="page-subtitle">
|
||||||
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'},只归档最终产出。工作流上传素材和预置模板资源不会进入这里。
|
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
@@ -345,52 +293,52 @@ export default function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="workspace-summary">
|
<div style={summaryStyle}>
|
||||||
<div className="workspace-summary-item">
|
<div style={summaryItemStyle}>
|
||||||
<span className="workspace-summary-label">当前结果</span>
|
<span style={summaryLabelStyle}>当前结果</span>
|
||||||
<strong className="workspace-summary-value">{total}</strong>
|
<strong style={summaryValueStyle}>{total}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-summary-item">
|
<div style={summaryItemStyle}>
|
||||||
<span className="workspace-summary-label">本地文件</span>
|
<span style={summaryLabelStyle}>本地文件</span>
|
||||||
<strong className="workspace-summary-value">{localCount}</strong>
|
<strong style={summaryValueStyle}>{localCount}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-summary-item">
|
<div style={summaryItemStyle}>
|
||||||
<span className="workspace-summary-label">主要来源</span>
|
<span style={summaryLabelStyle}>主要来源</span>
|
||||||
<strong className="workspace-summary-value workspace-summary-value-small">
|
<strong style={{ ...summaryValueStyle, fontSize: 14 }}>
|
||||||
{sourceStats.length > 0 ? sourceStats.map(([name, count]) => `${sourceLabel(name)} ${count}`).join(' / ') : '暂无'}
|
{sourceStats.length > 0 ? sourceStats.map(([name, count]) => `${sourceLabel(name)} ${count}`).join(' · ') : '暂无'}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||||
|
|
||||||
<div className="workspace-layout">
|
<div style={layoutStyle}>
|
||||||
<section>
|
<section>
|
||||||
<div className="workspace-toolbar">
|
<div style={toolbarStyle}>
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="搜索标题、提示词、模型或来源"
|
placeholder="搜索标题、提示词、模型或来源"
|
||||||
className="workspace-search"
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<div className="workspace-filter-row">
|
<div style={filterRowStyle}>
|
||||||
{SOURCE_OPTIONS.map((option) => (
|
{SOURCE_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={`workspace-chip ${source === option.value ? 'is-active' : ''}`}
|
style={source === option.value ? activeChipStyle : chipStyle}
|
||||||
onClick={() => setSource(option.value)}
|
onClick={() => setSource(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-filter-row">
|
<div style={filterRowStyle}>
|
||||||
{KIND_OPTIONS.map((option) => (
|
{KIND_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={`workspace-chip ${kind === option.value ? 'is-active' : ''}`}
|
style={kind === option.value ? activeChipStyle : chipStyle}
|
||||||
onClick={() => setKind(option.value)}
|
onClick={() => setKind(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -399,32 +347,7 @@ export default function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
<div style={gridStyle}>
|
||||||
<div className="workspace-batch-bar">
|
|
||||||
<span>已选 {selectedIds.length} 个</span>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={toggleSelectVisible}>
|
|
||||||
{allVisibleSelected ? '取消全选当前页' : '全选当前页'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={() => setSelectedIds([])}
|
|
||||||
disabled={selectedIds.length === 0}
|
|
||||||
>
|
|
||||||
清空选择
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={() => void handleBatchDelete()}
|
|
||||||
disabled={selectedIds.length === 0 || loading}
|
|
||||||
>
|
|
||||||
批量删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="workspace-grid">
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
|
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
|
||||||
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
|
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
|
||||||
@@ -433,51 +356,38 @@ export default function Workspace() {
|
|||||||
) : items.map((asset) => {
|
) : items.map((asset) => {
|
||||||
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||||
const active = selected?.publicId === asset.publicId;
|
const active = selected?.publicId === asset.publicId;
|
||||||
const checked = selectedIdSet.has(asset.publicId);
|
|
||||||
return (
|
return (
|
||||||
<article
|
<button
|
||||||
|
type="button"
|
||||||
key={asset.publicId}
|
key={asset.publicId}
|
||||||
className={`workspace-asset-card ${active ? 'is-active' : ''} ${checked ? 'is-selected' : ''}`}
|
style={{
|
||||||
role="button"
|
...cardStyle,
|
||||||
tabIndex={0}
|
borderColor: active ? 'var(--color-primary)' : 'var(--color-border)',
|
||||||
onClick={() => setSelectedId(asset.publicId)}
|
boxShadow: active ? '0 14px 32px color-mix(in srgb, var(--color-primary) 16%, transparent)' : undefined,
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
setSelectedId(asset.publicId);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setSelectedId(asset.publicId)}
|
||||||
>
|
>
|
||||||
<div className="workspace-thumb">
|
<div style={thumbStyle}>
|
||||||
<label className="workspace-select" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleSelectedAsset(asset.publicId)}
|
|
||||||
aria-label={`选择 ${assetTitle(asset)}`}
|
|
||||||
/>
|
|
||||||
<span className="workspace-select-mark" aria-hidden="true">✓</span>
|
|
||||||
</label>
|
|
||||||
{renderPreview(asset, true)}
|
{renderPreview(asset, true)}
|
||||||
<span className="workspace-type-badge">{kindLabel(asset.kind)}</span>
|
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
|
||||||
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
|
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
|
||||||
<span className="workspace-preview-badge">{formatBytes(asset.sizeBytes)}</span>
|
<span style={previewBadgeStyle}>{formatBytes(asset.sizeBytes)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-card-body">
|
<div style={cardBodyStyle}>
|
||||||
<strong className="workspace-card-title">{assetTitle(asset)}</strong>
|
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
||||||
<span className="workspace-meta">{sourceLabel(asset.source)} / {asset.model || '未记录模型'}</span>
|
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
|
||||||
<div className="workspace-card-footer">
|
<div style={cardFooterStyle}>
|
||||||
<span className="workspace-status-pill">{statusLabel(asset.status)}</span>
|
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
|
||||||
<span className="workspace-meta">{assetCreatedAtLabel(asset)}</span>
|
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="workspace-load-more">
|
<div style={loadMoreStyle}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
@@ -490,45 +400,44 @@ export default function Workspace() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="workspace-detail">
|
<aside style={detailStyle}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<div className="workspace-detail-header">
|
<div style={detailHeaderStyle}>
|
||||||
<div>
|
<div>
|
||||||
<div className="workspace-detail-kicker">{sourceLabel(selected.source)} / {kindLabel(selected.kind)}</div>
|
<div style={detailEyebrowStyle}>{sourceLabel(selected.source)} · {kindLabel(selected.kind)}</div>
|
||||||
<h2 className="workspace-detail-title">{assetTitle(selected)}</h2>
|
<h2 style={{ margin: '6px 0 0', fontSize: 18 }}>{assetTitle(selected)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="workspace-status-pill">{statusLabel(selected.status)}</span>
|
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-detail-preview">
|
<div style={detailPreviewStyle}>
|
||||||
{renderPreview(selected)}
|
{renderPreview(selected)}
|
||||||
</div>
|
</div>
|
||||||
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
|
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost workspace-preview-load"
|
className="btn btn-ghost"
|
||||||
|
style={{ width: '100%', marginTop: 10 }}
|
||||||
onClick={() => void loadPreview(selected)}
|
onClick={() => void loadPreview(selected)}
|
||||||
disabled={Boolean(previewLoading[selected.publicId])}
|
disabled={Boolean(previewLoading[selected.publicId])}
|
||||||
>
|
>
|
||||||
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
|
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="workspace-detail-meta">
|
<div style={detailMetaStyle}>
|
||||||
{audience !== 'user' && (
|
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
||||||
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
|
||||||
)}
|
|
||||||
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
||||||
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
|
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
|
||||||
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
|
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
|
||||||
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
|
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{selected.prompt && (
|
{selected.prompt && (
|
||||||
<div className="workspace-prompt-panel">
|
<div style={promptPanelStyle}>
|
||||||
<span>提示词</span>
|
<span>提示词</span>
|
||||||
<p className="workspace-prompt">{selected.prompt}</p>
|
<p style={promptStyle}>{selected.prompt}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="workspace-detail-actions">
|
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void handleDownload(selected)}>
|
<button type="button" className="btn btn-primary" onClick={() => void handleDownload(selected)}>
|
||||||
下载
|
下载
|
||||||
</button>
|
</button>
|
||||||
@@ -548,3 +457,270 @@ export default function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolbarStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 14,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
height: 40,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '0 12px',
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(300px, 380px)',
|
||||||
|
gap: 16,
|
||||||
|
alignItems: 'start',
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||||
|
gap: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardStyle: CSSProperties = {
|
||||||
|
textAlign: 'left',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbStyle: CSSProperties = {
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '16 / 11',
|
||||||
|
background: 'var(--color-bg-subtle)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaStyle: CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderStyle: CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderHintStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardBodyStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
padding: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardTitleStyle: CSSProperties = {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailStyle: CSSProperties = {
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
padding: 16,
|
||||||
|
position: 'sticky',
|
||||||
|
top: 84,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailPreviewStyle: CSSProperties = {
|
||||||
|
aspectRatio: '16 / 11',
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-bg-subtle)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailMetaStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
marginTop: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptStyle: CSSProperties = {
|
||||||
|
margin: '6px 0 0',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxHeight: 160,
|
||||||
|
overflow: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '160px 160px minmax(0, 1fr)',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryItemStyle: CSSProperties = {
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
padding: '12px 14px',
|
||||||
|
minHeight: 70,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryLabelStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryValueStyle: CSSProperties = {
|
||||||
|
fontSize: 22,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRowStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chipStyle: CSSProperties = {
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
padding: '7px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeChipStyle: CSSProperties = {
|
||||||
|
...chipStyle,
|
||||||
|
borderColor: 'var(--color-primary)',
|
||||||
|
background: 'var(--color-primary-light)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeBadgeStyle: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 10,
|
||||||
|
top: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.56)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewBadgeStyle: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 10,
|
||||||
|
bottom: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardFooterStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusPillStyle: CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'color-mix(in srgb, var(--color-success) 12%, var(--color-bg-card))',
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailHeaderStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailEyebrowStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptPanelStyle: CSSProperties = {
|
||||||
|
marginTop: 14,
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
};
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ export default function LoginShell({
|
|||||||
>
|
>
|
||||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||||
<div className="brand-mark-canvas">
|
<div className="brand-mark-canvas">
|
||||||
<img src="/logo.png" alt="BossAI" className="login-brand-logo" />
|
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="login-brand-summary"
|
className="login-brand-summary"
|
||||||
style={variant === 'user' ? { textAlign: 'center' } : undefined}
|
style={variant === 'user' ? { textAlign: 'center' } : undefined}
|
||||||
>
|
>
|
||||||
<div className="login-brand-name">BossAI</div>
|
<div className="login-brand-name">BoosAPI</div>
|
||||||
<div className="login-brand-kicker">
|
<div className="login-brand-kicker">
|
||||||
{variant === 'user' ? t('用户登录') : t('聚合多模型多模态的 AI 应用平台')}
|
{variant === 'user' ? t('用户登录') : t('中转站的中转站')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@ export default function LoginShell({
|
|||||||
<>
|
<>
|
||||||
<div className="login-brand-copy-block">
|
<div className="login-brand-copy-block">
|
||||||
<p className="login-brand-copy">
|
<p className="login-brand-copy">
|
||||||
{t('聚合文本、图像、视频、语音等多模态能力,统一接入多模型工作流和 AI 应用。')}
|
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-compat-line">
|
<div className="login-compat-line">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useToast } from '../../components/Toast.js';
|
|||||||
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
|
||||||
import { readClientTimeZone } from '../helpers/siteAnnouncementPresentation.js';
|
import { readClientTimeZone } from '../helpers/siteAnnouncementPresentation.js';
|
||||||
import {
|
import {
|
||||||
BillingLimitBar,
|
|
||||||
formatCompactTokens,
|
formatCompactTokens,
|
||||||
formatIso,
|
formatIso,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
@@ -164,35 +163,31 @@ export default function DownstreamKeyDrawer({
|
|||||||
|
|
||||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
||||||
计费与额度
|
基础信息
|
||||||
</div>
|
</div>
|
||||||
{overviewLoading ? (
|
{overviewLoading ? (
|
||||||
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
|
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 14, fontSize: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
||||||
<BillingLimitBar label="请求额度" used={item?.usedRequests || 0} max={item?.maxRequests} formatter={(value) => Math.trunc(value).toLocaleString()} />
|
<div>
|
||||||
<BillingLimitBar label="成本额度" used={item?.usedCost || 0} max={item?.maxCost} formatter={formatMoney} />
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
|
||||||
<div>
|
</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
|
<div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
|
||||||
</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
|
||||||
<div>
|
</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
|
<div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
|
||||||
</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
|
||||||
<div>
|
</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
|
<div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
|
||||||
</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
|
||||||
<div>
|
</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
|
<div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>主分组</div>
|
||||||
</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{item?.groupName || '未分组'}</div>
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>主分组</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{item?.groupName || '未分组'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 6 }}>标签</div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 6 }}>标签</div>
|
||||||
|
|||||||
@@ -45,19 +45,6 @@ export type AggregateUsage = {
|
|||||||
totalCost: number;
|
totalCost: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BillingLimit = {
|
|
||||||
used: number;
|
|
||||||
max: number | null;
|
|
||||||
remaining: number | null;
|
|
||||||
percent: number | null;
|
|
||||||
exhausted: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BillingSummary = {
|
|
||||||
requestLimit: BillingLimit;
|
|
||||||
costLimit: BillingLimit;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OverviewResponse = {
|
export type OverviewResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
item: SummaryItem;
|
item: SummaryItem;
|
||||||
@@ -180,47 +167,3 @@ export function StatusBadge({ enabled }: { enabled: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingLimitBar({
|
|
||||||
label,
|
|
||||||
used,
|
|
||||||
max,
|
|
||||||
formatter,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
used: number;
|
|
||||||
max: number | null | undefined;
|
|
||||||
formatter: (value: number) => string;
|
|
||||||
}) {
|
|
||||||
const hasLimit = Number.isFinite(Number(max)) && Number(max) > 0;
|
|
||||||
const percent = hasLimit ? Math.min(100, Math.max(0, (Number(used || 0) / Number(max)) * 100)) : 0;
|
|
||||||
const exhausted = hasLimit && Number(used || 0) >= Number(max);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'grid', gap: 6 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: 12 }}>
|
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>{label}</span>
|
|
||||||
<span style={{ color: exhausted ? 'var(--color-danger)' : 'var(--color-text-primary)', fontWeight: 700 }}>
|
|
||||||
{formatter(Number(used || 0))} / {hasLimit ? formatter(Number(max)) : '不限'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: 6, borderRadius: 999, background: 'var(--color-bg-muted)', overflow: 'hidden' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${hasLimit ? percent : 0}%`,
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: exhausted
|
|
||||||
? 'var(--color-danger)'
|
|
||||||
: percent >= 80
|
|
||||||
? 'var(--color-warning)'
|
|
||||||
: 'var(--color-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
|
||||||
{hasLimit ? `剩余 ${formatter(Math.max(0, Number(max) - Number(used || 0)))} · ${percent.toFixed(1)}%` : '未设置上限'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function normalizeVerifyFailureMessage(message: unknown): string {
|
|||||||
if (!text) return '验证失败';
|
if (!text) return '验证失败';
|
||||||
const lowered = text.toLowerCase();
|
const lowered = text.toLowerCase();
|
||||||
if (isNetworkFailureMessage(text)) {
|
if (isNetworkFailureMessage(text)) {
|
||||||
return '无法连接到 BossAI 服务端,请检查服务状态或网络连接';
|
return '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
|
||||||
}
|
}
|
||||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||||
@@ -43,7 +43,7 @@ export function buildVerifyFailureHint(result: VerifyResultLike): string | null
|
|||||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||||
}
|
}
|
||||||
if (isNetworkFailureMessage(result.message)) {
|
if (isNetworkFailureMessage(result.message)) {
|
||||||
return '这不是 Token 错误判断。请检查 BossAI 服务是否在线,以及目标站点或代理是否可达。';
|
return '这不是 Token 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
|
||||||
}
|
}
|
||||||
if (isTimeoutFailureMessage(result.message)) {
|
if (isTimeoutFailureMessage(result.message)) {
|
||||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||||
@@ -63,7 +63,7 @@ export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
|||||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||||
}
|
}
|
||||||
if (isNetworkFailureMessage(result.message)) {
|
if (isNetworkFailureMessage(result.message)) {
|
||||||
return '验证请求未成功完成,请先检查 BossAI 服务、站点网络或代理配置。';
|
return '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
|
||||||
}
|
}
|
||||||
if (isTimeoutFailureMessage(result.message)) {
|
if (isTimeoutFailureMessage(result.message)) {
|
||||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ export default function FactoryResetModal({
|
|||||||
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
||||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||||
这是不可逆操作。系统会清空当前 BossAI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
这是不可逆操作。系统会清空当前 BoosAPI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BossAI 数据。</div>
|
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据。</div>
|
||||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
||||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function ModelAvailabilityProbeConfirmModal({
|
|||||||
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
|
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
|
||||||
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||||
开启后,BossAI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
开启后,BoosAPI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||||
请手动输入以下整句后再开启:
|
请手动输入以下整句后再开启:
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/ComfyUI_frontend updated: 8aec9178d8...d405002127
Reference in New Issue
Block a user