Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| deb8edded2 | |||
| c876370a22 | |||
| 4ff6a89587 | |||
| a213e394d9 | |||
| 6b24f160c3 | |||
| 0e36358624 | |||
| 082099e63d | |||
| 8730713655 | |||
| 44e3e6ea35 | |||
| 559a31463e | |||
| eb7cf61fea | |||
| b012f48a23 | |||
| 0db88e2b20 | |||
| add7ae7048 | |||
| 53d416cfb8 | |||
| 3178f0d67f | |||
| 4719cf41eb | |||
| 281d65a0c7 | |||
| f4d129ebc4 | |||
| 294d227eba |
@@ -13,6 +13,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.8", features = [
|
sqlx = { version = "0.8", features = [
|
||||||
|
|||||||
@@ -66,6 +66,29 @@
|
|||||||
|
|
||||||
## 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**:
|
||||||
@@ -161,8 +184,54 @@
|
|||||||
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
|
||||||
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
|
||||||
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
|
||||||
|
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
|
||||||
|
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
|
||||||
|
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
|
||||||
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
|
||||||
|
|
||||||
|
### Workflow 后端配置
|
||||||
|
|
||||||
|
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
|
||||||
|
|
||||||
|
Comfy Cloud:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "cloud"
|
||||||
|
base_url = "https://cloud.comfy.org"
|
||||||
|
api_key = "your-comfy-cloud-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
RunningHub:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "runninghub"
|
||||||
|
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
||||||
|
api_key = "your-runninghub-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
外部完整版 ComfyUI:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "external"
|
||||||
|
base_url = "http://127.0.0.1:8188"
|
||||||
|
```
|
||||||
|
|
||||||
|
BoosAPI API-native 执行器:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "boosapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
|
||||||
|
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
|
||||||
|
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 运维操作 (Ops)
|
## 5. 运维操作 (Ops)
|
||||||
@@ -179,6 +248,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
|
|||||||
cp data/boosapi.db data/boosapi.db.bak
|
cp data/boosapi.db data/boosapi.db.bak
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
|
||||||
|
|
||||||
### 查看日志
|
### 查看日志
|
||||||
如果使用 Systemd:
|
如果使用 Systemd:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# BoosAPI
|
# BossAI
|
||||||
|
|
||||||
**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> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。
|
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
|
||||||
<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 密钥,往往既分散又费时。
|
||||||
|
|
||||||
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
**BossAI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
||||||
|
|
||||||
支持的上游范围:
|
支持的上游范围:
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
| 痛点 | BoosAPI 怎么解决 |
|
| 痛点 | BossAI 怎么解决 |
|
||||||
|------|-----------------|
|
|------|-----------------|
|
||||||
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
||||||
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
||||||
@@ -53,20 +53,10 @@
|
|||||||
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
||||||
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
||||||
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
||||||
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
|
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BossAI 模型路由 |
|
||||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||||
|
|
||||||
## 🖼️ 界面预览
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
### 🎨 Workflow Studio 与视频助手(Rust)
|
### 🎨 Workflow Studio 与视频助手(Rust)
|
||||||
@@ -75,7 +65,8 @@
|
|||||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||||
- **场景生成**:自动为每个场景生成配图
|
- **场景生成**:自动为每个场景生成配图
|
||||||
- **TTS 语音合成**:对话台词自动转语音
|
- **TTS 语音合成**:对话台词自动转语音
|
||||||
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI
|
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
|
||||||
|
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
|
||||||
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
|
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
|
||||||
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
||||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||||
@@ -165,13 +156,13 @@
|
|||||||
|
|
||||||
## 🖥️ 硬件配置建议
|
## 🖥️ 硬件配置建议
|
||||||
|
|
||||||
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
||||||
|
|
||||||
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
||||||
|----------|-----|------|------|-----|
|
|----------|-----|------|------|-----|
|
||||||
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
||||||
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
| BossAI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
||||||
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
|
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BossAI 机器不需要;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+ 更稳 |
|
||||||
|
|
||||||
@@ -184,7 +175,50 @@ BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
|
|||||||
无需 GPU
|
无需 GPU
|
||||||
```
|
```
|
||||||
|
|
||||||
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
如果图像、视频、语音能力走 BossAI 上游路由、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"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;生产构建会把可用资产嵌入 Rust 二进制。
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -306,6 +340,11 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
|
|||||||
|
|
||||||
# 前端静态目录(默认 dist/web)
|
# 前端静态目录(默认 dist/web)
|
||||||
BOOSAPI_WEB_DIR=dist/web
|
BOOSAPI_WEB_DIR=dist/web
|
||||||
|
|
||||||
|
# Workflow 后端
|
||||||
|
BOOSAPI_COMFYUI__BACKEND=cloud
|
||||||
|
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
|
||||||
|
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ 技术栈
|
## 🏗️ 技术栈
|
||||||
@@ -319,7 +358,7 @@ BOOSAPI_WEB_DIR=dist/web
|
|||||||
| 认证鉴权 | **JWT** + API Key 双模式 |
|
| 认证鉴权 | **JWT** + API Key 双模式 |
|
||||||
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
||||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||||
| Workflow Studio | API-native 工作流入口,后续承接节点画布 |
|
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
||||||
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
| 前端框架 | React 19 + Vite(仅构建工具) |
|
||||||
| 样式 | Tailwind CSS v4 |
|
| 样式 | Tailwind CSS v4 |
|
||||||
@@ -370,9 +409,35 @@ npm run build
|
|||||||
# 输出到 dist/web/,Rust 后端自动 serve
|
# 输出到 dist/web/,Rust 后端自动 serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
可选使用 Bun 加速主前端构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一次性使用 Bun 构建主前端
|
||||||
|
./build-and-run.sh --bun
|
||||||
|
|
||||||
|
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
|
||||||
|
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
|
||||||
|
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
|
||||||
|
|
||||||
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
|
||||||
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
|
||||||
|
|
||||||
|
### 生产单二进制构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-and-run.sh --production
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
boosapi
|
||||||
|
config.toml
|
||||||
|
```
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+140
-151
@@ -2,206 +2,195 @@
|
|||||||
|
|
||||||
# BoosAPI
|
# BoosAPI
|
||||||
|
|
||||||
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point**
|
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
||||||
<br>
|
<br>
|
||||||
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization.
|
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
|
||||||
<br>
|
<br>
|
||||||
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
|
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
|
||||||
<br>
|
|
||||||
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
|
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
|
||||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
|
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
|
||||||
|
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
|
||||||
|
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
|
||||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文</a> |
|
<a href="README.md">中文</a> |
|
||||||
<a href="README_EN.md"><strong>English</strong></a>
|
<a href="README_EN.md"><strong>English</strong></a> |
|
||||||
|
<a href="DEPLOY.md">Deployment</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Introduction
|
## Introduction
|
||||||
|
|
||||||
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming.
|
BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
|
||||||
|
|
||||||
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes.
|
Supported upstreams:
|
||||||
|
|
||||||
### Supported Upstreams
|
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
|
||||||
|
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
|
||||||
|
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
|
||||||
|
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
|
||||||
|
|
||||||
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
|
## Key Features
|
||||||
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
|
|
||||||
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
|
|
||||||
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
|
|
||||||
|
|
||||||
| Pain Point | BoosAPI Solution |
|
### Unified API Gateway
|
||||||
|------------|-----------------|
|
|
||||||
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
|
|
||||||
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
|
|
||||||
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
|
|
||||||
| 📊 Scattered balances | **Central dashboard** with alerts |
|
|
||||||
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
|
|
||||||
| 🤷 What models are available? | **Auto model discovery**, zero config |
|
|
||||||
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
|
|
||||||
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
|
|
||||||
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
|
|
||||||
|
|
||||||
---
|
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
|
||||||
|
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
|
||||||
|
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
|
||||||
|
- Admin and user dashboards with API key management and model visibility controls
|
||||||
|
|
||||||
## ✨ Features
|
### Workflow Studio
|
||||||
|
|
||||||
### 🎨 Built-in ComfyUI Chat Assistant
|
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
|
||||||
|
|
||||||
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline
|
| backend | Purpose |
|
||||||
- **Multi-view generation**: front/back/left/right directional image generation
|
|---------|---------|
|
||||||
- **Scene/video generation**: text description directly generates video content
|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
|
||||||
- **SSE real-time streaming**: generation progress feedback
|
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
|
||||||
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
|
| `external` | Proxy a full ComfyUI instance you deploy yourself |
|
||||||
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
|
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
|
||||||
|
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
|
||||||
|
|
||||||
### 🌐 Unified Proxy Gateway
|
Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
|
||||||
|
|
||||||
- Compatible with **OpenAI** and **Claude** downstream formats
|
### Assistants
|
||||||
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
|
|
||||||
- SSE streaming with automatic format conversion
|
|
||||||
|
|
||||||
### 🧠 Smart Routing Engine
|
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
|
||||||
|
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
|
||||||
|
- Volcengine video task templates for create-task and query-task flows
|
||||||
|
|
||||||
- Auto-discovers models from all upstream stations
|
## Hardware Guide
|
||||||
- Four-tier cost signals: measured → configured → catalog → fallback
|
|
||||||
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
|
|
||||||
- Auto cooldown on failure (default 10 min), seamless channel switching
|
|
||||||
|
|
||||||
### 📡 Multi-Platform Management
|
| Mode | CPU | Memory | Disk | GPU |
|
||||||
|
|------|-----|--------|------|-----|
|
||||||
|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
|
||||||
|
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
|
||||||
|
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
|
||||||
|
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
|
||||||
|
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
|
||||||
|
|
||||||
| Platform | Adapter |
|
Recommended SaaS gateway server:
|
||||||
|----------|---------|
|
|
||||||
| New API | `new-api` |
|
|
||||||
| One API | `one-api` |
|
|
||||||
| OneHub | `onehub` |
|
|
||||||
| DoneHub | `done-hub` |
|
|
||||||
| Veloera | `veloera` |
|
|
||||||
| AnyRouter | `anyrouter` |
|
|
||||||
| Sub2API | `sub2api` |
|
|
||||||
|
|
||||||
### 👥 Account & Token Management
|
```txt
|
||||||
|
4 CPU cores
|
||||||
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
|
8GB RAM
|
||||||
- Encrypted credential storage, auto-renewal, cascading disable
|
50GB SSD
|
||||||
|
No GPU required
|
||||||
### 💰 Balance Management
|
|
||||||
|
|
||||||
- Scheduled refresh (default hourly), income/expense trends
|
|
||||||
- Fallback estimation when API is unavailable
|
|
||||||
|
|
||||||
### 👥 SaaS Multi-Tenant
|
|
||||||
|
|
||||||
- **Organizations**: Create orgs, each with independent members and quotas
|
|
||||||
- **Member Invitations**: Email-based invites with admin/member roles
|
|
||||||
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
|
|
||||||
- **Role Control**: Owner / Admin / Member with granular permissions
|
|
||||||
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
|
|
||||||
- **Invite Links**: Token-based, 48h expiry
|
|
||||||
|
|
||||||
### 🔔 Alerts & Notifications
|
|
||||||
|
|
||||||
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
|
|
||||||
|
|
||||||
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
|
|
||||||
|
|
||||||
### 🔌 REST API Overview
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/api/organizations` | GET/POST | List / Create org |
|
|
||||||
| `/api/organizations/plans` | GET | List available plans |
|
|
||||||
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
|
|
||||||
| `/api/organizations/:id/members` | GET | List members |
|
|
||||||
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
|
|
||||||
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
|
|
||||||
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
|
|
||||||
| `/api/invitations/:token` | GET | Public invite lookup |
|
|
||||||
| `/api/invitations/:token/accept` | POST | Accept invitation |
|
|
||||||
| `/api/users/register` | POST | Register new user |
|
|
||||||
| `/api/users/login` | POST | Login (returns JWT) |
|
|
||||||
| `/api/users/me` | GET/PATCH | Profile / Update |
|
|
||||||
| `/api/user-api-keys` | GET/POST | List / Create API key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir boosapi && cd boosapi
|
|
||||||
|
|
||||||
cat > docker-compose.yml << 'EOF'
|
|
||||||
services:
|
|
||||||
boosapi:
|
|
||||||
image: 1467078763/boosapi:latest
|
|
||||||
ports:
|
|
||||||
- "4000:4000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
environment:
|
|
||||||
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
|
|
||||||
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
|
|
||||||
CHECKIN_CRON: "0 8 * * *"
|
|
||||||
BALANCE_REFRESH_CRON: "0 * * * *"
|
|
||||||
PORT: ${PORT:-4000}
|
|
||||||
DATA_DIR: /app/data
|
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
|
||||||
restart: unless-stopped
|
|
||||||
EOF
|
|
||||||
|
|
||||||
export AUTH_TOKEN=your-admin-token
|
|
||||||
export PROXY_TOKEN=your-proxy-sk-token
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
|
## Quick Start
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cp config.toml.example config.toml
|
||||||
|
./build-and-run.sh
|
||||||
|
```
|
||||||
|
|
||||||
## 🏗️ Tech Stack
|
For a production bundle with embedded web assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-and-run.sh --production
|
||||||
|
```
|
||||||
|
|
||||||
|
The production output is intended to run with only:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
boosapi
|
||||||
|
config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
|
||||||
|
|
||||||
|
## Workflow Backend Configuration
|
||||||
|
|
||||||
|
Comfy Cloud:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "cloud"
|
||||||
|
base_url = "https://cloud.comfy.org"
|
||||||
|
api_key = "your-comfy-cloud-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
RunningHub:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "runninghub"
|
||||||
|
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
|
||||||
|
api_key = "your-runninghub-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
External ComfyUI:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "external"
|
||||||
|
base_url = "http://127.0.0.1:8188"
|
||||||
|
```
|
||||||
|
|
||||||
|
BoosAPI API-native executor:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[comfyui]
|
||||||
|
backend = "boosapi"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BOOSAPI_SERVER__HOST=0.0.0.0
|
||||||
|
BOOSAPI_SERVER__PORT=4000
|
||||||
|
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||||
|
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||||
|
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
|
||||||
|
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Tech |
|
| Layer | Tech |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| Backend | Fastify |
|
| Backend | Rust, Axum 0.8, Tokio |
|
||||||
| Frontend | React 18 + Vite |
|
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
|
||||||
| Language | TypeScript |
|
| Proxy engine | Token Router + Proxy Conductor |
|
||||||
| Styling | Tailwind CSS v4 |
|
| Protocols | OpenAI / Claude / Gemini compatible transformers |
|
||||||
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
|
| Frontend | React 19 + Vite |
|
||||||
| Visualization | VChart |
|
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
|
||||||
| Container | Docker + Docker Compose |
|
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
|
||||||
| Test | Vitest |
|
| Container | Docker / Docker Compose |
|
||||||
|
|
||||||
---
|
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
|
||||||
|
|
||||||
## 🛠️ Local Development
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
cargo check
|
||||||
npm run db:migrate
|
cargo run
|
||||||
npm run dev
|
pnpm install
|
||||||
|
pnpm run dev:web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Build checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # Build web + server
|
cargo check
|
||||||
npm test # Run tests
|
pnpm run typecheck:web
|
||||||
|
npx vite build
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## License
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
BIN
Binary file not shown.
+140
-6
@@ -20,7 +20,10 @@ 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}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -31,18 +34,28 @@ 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)
|
||||||
--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_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 --production
|
||||||
|
./build-and-run.sh --production --production-dir /opt/boosapi-release
|
||||||
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -52,6 +65,16 @@ 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"
|
||||||
@@ -61,6 +84,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--backend-only) BACKEND_ONLY=1 ;;
|
--backend-only) BACKEND_ONLY=1 ;;
|
||||||
--force-frontend) FORCE_FRONTEND=1 ;;
|
--force-frontend) FORCE_FRONTEND=1 ;;
|
||||||
--skip-frontend) SKIP_FRONTEND=1 ;;
|
--skip-frontend) SKIP_FRONTEND=1 ;;
|
||||||
|
--bun) FRONTEND_PM="bun" ;;
|
||||||
--no-run) NO_RUN=1 ;;
|
--no-run) NO_RUN=1 ;;
|
||||||
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
@@ -73,6 +97,14 @@ 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) ;;
|
||||||
@@ -161,25 +193,69 @@ 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)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v npx >/dev/null 2>&1; then
|
local frontend_pm="$FRONTEND_PM"
|
||||||
fail "未找到 npx,请先安装 Node.js"
|
case "$frontend_pm" in
|
||||||
fi
|
auto)
|
||||||
|
if command -v bun >/dev/null 2>&1; then
|
||||||
|
frontend_pm="bun"
|
||||||
|
else
|
||||||
|
frontend_pm="npm"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
npm|bun) ;;
|
||||||
|
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
install_frontend_dependencies "$frontend_pm"
|
||||||
|
|
||||||
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
||||||
local fingerprint
|
local fingerprint
|
||||||
fingerprint="$(
|
fingerprint="$(
|
||||||
{
|
{
|
||||||
printf 'build-script-v2\n'
|
printf 'build-script-v2\n'
|
||||||
|
printf 'frontend_pm=%s\n' "$frontend_pm"
|
||||||
printf 'node='
|
printf 'node='
|
||||||
node --version 2>/dev/null || true
|
node --version 2>/dev/null || true
|
||||||
printf 'npx='
|
printf 'npx='
|
||||||
npx --version 2>/dev/null || true
|
npx --version 2>/dev/null || true
|
||||||
|
printf 'bun='
|
||||||
|
bun --version 2>/dev/null || true
|
||||||
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
|
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
|
||||||
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
||||||
done | sort | xargs -r sha256sum
|
done | sort | xargs -r sha256sum
|
||||||
@@ -194,14 +270,41 @@ build_frontend() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "构建前端 (Vite)..."
|
log "构建前端 (Vite via ${frontend_pm})..."
|
||||||
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
|
# 直接调用 vite build;桌面端构建链路已移除。
|
||||||
npx vite build
|
if [ "$frontend_pm" = "bun" ]; then
|
||||||
|
bunx --bun vite build
|
||||||
|
else
|
||||||
|
pnpm exec vite build
|
||||||
|
fi
|
||||||
mkdir -p "$ROOT_DIR/dist/web"
|
mkdir -p "$ROOT_DIR/dist/web"
|
||||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||||
ok "前端构建完成 → dist/web/"
|
ok "前端构建完成 → dist/web/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 工具链"
|
||||||
@@ -236,6 +339,29 @@ 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"
|
||||||
@@ -254,10 +380,18 @@ 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
|
||||||
|
|||||||
+9
-9
@@ -28,19 +28,19 @@ proxy_log_retention_days = 30
|
|||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
# boosapi = use embedded Workflow API bridge; external/cloud = proxy Workflow
|
|
||||||
# API and WebSocket to a full ComfyUI-compatible backend.
|
|
||||||
backend = "cloud"
|
|
||||||
docker_image = "boosapi-comfyui:multi-tenant"
|
|
||||||
container_name = "boosapi-comfyui-workflow"
|
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
max_restarts = 5
|
||||||
|
# backend modes:
|
||||||
|
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
||||||
|
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
|
||||||
|
# - "external": proxy to your own full ComfyUI server.
|
||||||
|
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
||||||
|
backend = "cloud"
|
||||||
|
# docker_image = "boosapi-comfyui:multi-tenant"
|
||||||
|
# container_name = "boosapi-comfyui-workflow"
|
||||||
|
# base_url = "https://www.runninghub.cn/proxy/94d1330ea62d4ae980ffb018ecdf82a2"
|
||||||
base_url = "https://cloud.comfy.org"
|
base_url = "https://cloud.comfy.org"
|
||||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||||
# For Comfy Cloud:
|
|
||||||
# backend = "cloud"
|
|
||||||
# base_url = "https://cloud.comfy.org"
|
|
||||||
# api_key = "your-comfy-cloud-api-key"
|
|
||||||
agent_model = "gpt-5.5"
|
agent_model = "gpt-5.5"
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ RUN find /workspace/ComfyUI -mindepth 1 \
|
|||||||
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
|
||||||
|
|
||||||
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
|
||||||
|
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
|
||||||
|
|
||||||
|
RUN bash /tmp/install-runninghub-template-nodes.sh \
|
||||||
|
&& rm -f /tmp/install-runninghub-template-nodes.sh
|
||||||
|
|
||||||
RUN python - <<'PY'
|
RUN python - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB |
@@ -1,42 +0,0 @@
|
|||||||
appId: me.cita777.boosapi.desktop
|
|
||||||
productName: Metapi
|
|
||||||
artifactName: ${name}-${version}-${os}-${arch}.${ext}
|
|
||||||
directories:
|
|
||||||
output: release
|
|
||||||
electronDist: node_modules/electron/dist
|
|
||||||
icon: build/desktop-icon.png
|
|
||||||
asar: false
|
|
||||||
files:
|
|
||||||
- dist/**/*
|
|
||||||
- drizzle/**/*
|
|
||||||
- package.json
|
|
||||||
mac:
|
|
||||||
target:
|
|
||||||
- dmg
|
|
||||||
- zip
|
|
||||||
category: public.app-category.developer-tools
|
|
||||||
hardenedRuntime: false
|
|
||||||
gatekeeperAssess: false
|
|
||||||
entitlementsInherit: null
|
|
||||||
win:
|
|
||||||
target:
|
|
||||||
- nsis
|
|
||||||
- zip
|
|
||||||
linux:
|
|
||||||
target:
|
|
||||||
- AppImage
|
|
||||||
- deb
|
|
||||||
- rpm
|
|
||||||
maintainer: cita-777 <cita-777@users.noreply.github.com>
|
|
||||||
publish:
|
|
||||||
provider: github
|
|
||||||
owner: cita-777
|
|
||||||
repo: boosapi
|
|
||||||
releaseType: release
|
|
||||||
publishAutoUpdate: true
|
|
||||||
nsis:
|
|
||||||
oneClick: false
|
|
||||||
perMachine: false
|
|
||||||
allowToChangeInstallationDirectory: true
|
|
||||||
afterPack: scripts/desktop/afterPack.mjs
|
|
||||||
afterSign: scripts/desktop/afterSign.mjs
|
|
||||||
+10
-70
@@ -2,12 +2,12 @@
|
|||||||
"name": "boosapi",
|
"name": "boosapi",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||||
"main": "dist/desktop/main.js",
|
"main": "dist/web/index.html",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
"api-gateway",
|
"api-gateway",
|
||||||
"proxy",
|
"proxy",
|
||||||
"fastify",
|
"rust",
|
||||||
"openai",
|
"openai",
|
||||||
"claude",
|
"claude",
|
||||||
"aggregator"
|
"aggregator"
|
||||||
@@ -22,51 +22,22 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=25.0.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
"dev": "vite",
|
||||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
|
||||||
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
|
|
||||||
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
|
|
||||||
"build:web": "vite build",
|
"build:web": "vite build",
|
||||||
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
|
"build": "npm run build:web",
|
||||||
"build:desktop": "tsc -p tsconfig.desktop.json",
|
|
||||||
"build": "npm run build:web && npm run build:server && npm run build:desktop",
|
|
||||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||||
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
|
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||||
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
|
|
||||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
|
|
||||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
|
||||||
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
|
|
||||||
"package:desktop": "npm run dist:desktop",
|
|
||||||
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
|
|
||||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"start": "node dist/server/index.js",
|
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
||||||
"start:deploy-helper": "node dist/server/update-helper/index.js",
|
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
||||||
"start:desktop": "electron dist/desktop/main.js",
|
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
||||||
"comfyui:install": "node scripts/comfyui/install.mjs",
|
|
||||||
"comfyui:start": "node scripts/comfyui/start.mjs",
|
|
||||||
"comfyui:stop": "node scripts/comfyui/stop.mjs",
|
|
||||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs",
|
|
||||||
"db:generate": "drizzle-kit generate",
|
|
||||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
|
||||||
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
|
|
||||||
"schema:generate": "npm run db:generate && npm run schema:contract",
|
|
||||||
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
|
|
||||||
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
|
|
||||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
|
||||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
|
||||||
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
|
|
||||||
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
|
|
||||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
|
||||||
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
|
||||||
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
|
||||||
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
|
|
||||||
"test": "vitest run --root .",
|
"test": "vitest run --root .",
|
||||||
"test:watch": "vitest --root ."
|
"test:watch": "vitest --root ."
|
||||||
},
|
},
|
||||||
@@ -74,46 +45,17 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
|
||||||
"@fastify/static": "^9.0.0",
|
|
||||||
"@visactor/react-vchart": "^2.0.21",
|
"@visactor/react-vchart": "^2.0.21",
|
||||||
"better-sqlite3": "^12.8.0",
|
|
||||||
"dotenv": "^17.4.1",
|
|
||||||
"drizzle-orm": "^0.45.2",
|
|
||||||
"electron-log": "^5.2.4",
|
|
||||||
"electron-updater": "^6.6.2",
|
|
||||||
"fastify": "^5.8.4",
|
|
||||||
"get-port": "^7.2.0",
|
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"minimatch": "^10.2.5",
|
|
||||||
"minimist": "^1.2.8",
|
|
||||||
"mysql2": "^3.20.0",
|
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^8.0.4",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"rate-limiter-flexible": "^11.0.0",
|
|
||||||
"socks": "^2.8.7",
|
|
||||||
"undici": "^6.24.1",
|
|
||||||
"ws": "^8.20.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/notarize": "^3.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^8.0.0",
|
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"concurrently": "^9.1.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
|
||||||
"electron": "^41.1.1",
|
|
||||||
"electron-builder": "^26.0.12",
|
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"mermaid": "^11.14.0",
|
"mermaid": "^11.14.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -122,13 +64,11 @@
|
|||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitepress-plugin-mermaid": "^2.0.17",
|
"vitepress-plugin-mermaid": "^2.0.17",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8"
|
||||||
"wait-on": "^9.0.4"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimist": "^1.2.8"
|
"minimist": "^1.2.8"
|
||||||
|
|||||||
Generated
+74
-3768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
const DEFAULT_PACKS = [
|
||||||
|
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
|
||||||
|
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
|
||||||
|
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
|
||||||
|
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
|
||||||
|
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
|
||||||
|
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
|
||||||
|
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
|
||||||
|
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const configPath = valueArg('--config') || 'config.toml';
|
||||||
|
const baseUrl = trimSlash(
|
||||||
|
process.env.BOOSAPI_COMFYUI__BASE_URL
|
||||||
|
|| process.env.COMFYUI_CLOUD_BASE_URL
|
||||||
|
|| (await readComfyConfig(configPath)).base_url
|
||||||
|
|| 'https://cloud.comfy.org'
|
||||||
|
);
|
||||||
|
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|
||||||
|
|| process.env.COMFYUI_CLOUD_API_KEY
|
||||||
|
|| (await readComfyConfig(configPath)).api_key
|
||||||
|
|| '';
|
||||||
|
const dryRun = args.has('--dry-run');
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cloud backend: ${baseUrl}`);
|
||||||
|
console.log(`Packs: ${DEFAULT_PACKS.length}`);
|
||||||
|
if (dryRun) {
|
||||||
|
for (const [id, repository] of DEFAULT_PACKS) {
|
||||||
|
console.log(`[dry-run] ${id} <- ${repository}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('GET', '/api/manager/queue/start').catch((error) => {
|
||||||
|
console.warn(`manager queue start warning: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [id, repository] of DEFAULT_PACKS) {
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
version: 'nightly',
|
||||||
|
selected_version: 'nightly',
|
||||||
|
repository,
|
||||||
|
mode: 'remote',
|
||||||
|
channel: 'default',
|
||||||
|
skip_post_install: false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await request('POST', '/api/manager/queue/install', payload);
|
||||||
|
console.log(`queued: ${id}`);
|
||||||
|
results.push({ id, ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`failed: ${id}: ${error.message}`);
|
||||||
|
results.push({ id, ok: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('GET', '/api/manager/queue/status')
|
||||||
|
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
|
||||||
|
.catch((error) => console.warn(`queue status warning: ${error.message}`));
|
||||||
|
|
||||||
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? safeJson(text) : null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { body: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readComfyConfig(path) {
|
||||||
|
const result = {};
|
||||||
|
let section = '';
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
text = await fs.readFile(path, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
section = sectionMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (section !== 'comfyui') continue;
|
||||||
|
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
|
||||||
|
if (match) result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlash(value) {
|
||||||
|
return String(value || '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueArg(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
|
||||||
|
return found ? found.slice(prefix.length) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
|
||||||
|
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
|
||||||
|
|
||||||
|
if [ ! -d "$COMFYUI_DIR" ]; then
|
||||||
|
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$PYTHON_BIN" ]; then
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="$(command -v python3)"
|
||||||
|
else
|
||||||
|
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CUSTOM_NODES_DIR"
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
local name="$1"
|
||||||
|
local repo="$2"
|
||||||
|
local ref="${3:-}"
|
||||||
|
local dir="$CUSTOM_NODES_DIR/$name"
|
||||||
|
|
||||||
|
if [ -d "$dir/.git" ]; then
|
||||||
|
echo "==> Updating $name"
|
||||||
|
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
|
||||||
|
else
|
||||||
|
echo "==> Installing $name"
|
||||||
|
git clone --depth 1 "$repo" "$dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ref" ]; then
|
||||||
|
git -C "$dir" checkout --quiet "$ref"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$dir/requirements.txt" ]; then
|
||||||
|
echo "==> Installing Python requirements for $name"
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
|
||||||
|
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
|
||||||
|
else
|
||||||
|
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$dir/install.py" ]; then
|
||||||
|
echo "==> Running install.py for $name"
|
||||||
|
"$PYTHON_BIN" "$dir/install.py" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
|
||||||
|
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
|
||||||
|
# for local/docker/external ComfyUI instances.
|
||||||
|
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
|
||||||
|
|
||||||
|
# Nodes used by common RunningHub digital-human / video templates.
|
||||||
|
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
|
||||||
|
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
|
||||||
|
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
|
||||||
|
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
|
||||||
|
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
|
||||||
|
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
|
||||||
|
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
RunningHub template nodes installed.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
|
||||||
|
node requires them at runtime.
|
||||||
|
- The screenshot also references a "soundflow" package. I did not find a stable
|
||||||
|
public repository for that exact package name; install it through
|
||||||
|
ComfyUI-Manager or add its repository manually when you have the source URL.
|
||||||
|
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
|
||||||
|
nodes. Put those models into the matching ComfyUI models folders or use a
|
||||||
|
backend where RunningHub/Cloud already provides them.
|
||||||
|
EOF
|
||||||
Executable
+260
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the BossAI production binary, package Alpine/musl runtime libraries,
|
||||||
|
# and deploy the runnable bundle to a Debian server.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||||
|
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
|
||||||
|
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
|
||||||
|
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
|
||||||
|
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
|
||||||
|
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
|
||||||
|
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
|
||||||
|
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
|
||||||
|
BOSSAI_REF="${BOSSAI_REF:-main}"
|
||||||
|
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
|
||||||
|
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
|
||||||
|
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
|
||||||
|
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
|
||||||
|
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||||
|
SKIP_BUILD=0
|
||||||
|
SKIP_VERIFY=0
|
||||||
|
SKIP_SOURCE_UPDATE=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/deploy-bossai-debian.sh [options]
|
||||||
|
|
||||||
|
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
|
||||||
|
needed by the Alpine-built binary, upload it to a Debian host, extract it,
|
||||||
|
and run a config check.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--host <host> Debian host/IP. Default: 129.211.30.104
|
||||||
|
--user <user> SSH user. Default: root
|
||||||
|
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
|
||||||
|
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
|
||||||
|
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
|
||||||
|
--remote <name> Git remote for BossAI. Default: bossai
|
||||||
|
--ref <ref> BossAI branch/ref to build. Default: main
|
||||||
|
--skip-build Reuse dist/bossai-production from a previous build
|
||||||
|
--skip-source-update Do not fetch/reset the BossAI source worktree
|
||||||
|
--skip-verify Skip remote ./run-boosapi.sh --check-config
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
BOSSAI_DEBIAN_HOST
|
||||||
|
BOSSAI_DEBIAN_USER
|
||||||
|
BOSSAI_DEBIAN_DIR
|
||||||
|
BOSSAI_DEBIAN_SSH_KEY
|
||||||
|
BOSSAI_SOURCE_DIR
|
||||||
|
BOSSAI_REMOTE
|
||||||
|
BOSSAI_REF
|
||||||
|
BOSSAI_WORKFLOW_UI_SOURCE
|
||||||
|
BOSSAI_PRODUCTION_DIR
|
||||||
|
BOSSAI_DEBIAN_BUNDLE_DIR
|
||||||
|
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
|
||||||
|
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
|
||||||
|
|
||||||
|
Remote start command after deploy:
|
||||||
|
cd /root/BossAI && ./run-boosapi.sh
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--host)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--host requires a value"
|
||||||
|
REMOTE_HOST="$1"
|
||||||
|
;;
|
||||||
|
--user)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--user requires a value"
|
||||||
|
REMOTE_USER="$1"
|
||||||
|
;;
|
||||||
|
--dir)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--dir requires a value"
|
||||||
|
REMOTE_DIR="$1"
|
||||||
|
;;
|
||||||
|
--key)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--key requires a value"
|
||||||
|
SSH_KEY="$1"
|
||||||
|
;;
|
||||||
|
--source-dir)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--source-dir requires a value"
|
||||||
|
BOSSAI_SOURCE_DIR="$1"
|
||||||
|
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
|
||||||
|
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
|
||||||
|
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
|
||||||
|
;;
|
||||||
|
--remote)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--remote requires a value"
|
||||||
|
BOSSAI_REMOTE="$1"
|
||||||
|
;;
|
||||||
|
--ref)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || fail "--ref requires a value"
|
||||||
|
BOSSAI_REF="$1"
|
||||||
|
;;
|
||||||
|
--skip-build)
|
||||||
|
SKIP_BUILD=1
|
||||||
|
;;
|
||||||
|
--skip-source-update)
|
||||||
|
SKIP_SOURCE_UPDATE=1
|
||||||
|
;;
|
||||||
|
--skip-verify)
|
||||||
|
SKIP_VERIFY=1
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
[ -f "$path" ] || fail "Missing required file: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_bossai_source() {
|
||||||
|
require_command git
|
||||||
|
|
||||||
|
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
|
||||||
|
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
|
||||||
|
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
|
||||||
|
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
|
||||||
|
|
||||||
|
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
|
||||||
|
git -C "$BOSSAI_SOURCE_DIR" clean -fd
|
||||||
|
else
|
||||||
|
rm -rf "$BOSSAI_SOURCE_DIR"
|
||||||
|
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
|
||||||
|
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
|
||||||
|
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workflow_ui_source() {
|
||||||
|
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
|
||||||
|
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
|
||||||
|
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
|
||||||
|
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime_lib() {
|
||||||
|
local path="$1"
|
||||||
|
require_file "$path"
|
||||||
|
cp "$path" "$BUNDLE_DIR/lib/"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_runner() {
|
||||||
|
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_bundle() {
|
||||||
|
require_file "$PRODUCTION_DIR/boosapi"
|
||||||
|
require_file "$PRODUCTION_DIR/config.toml"
|
||||||
|
|
||||||
|
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
|
||||||
|
mkdir -p "$BUNDLE_DIR/lib"
|
||||||
|
|
||||||
|
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
|
||||||
|
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
|
||||||
|
chmod +x "$BUNDLE_DIR/boosapi"
|
||||||
|
|
||||||
|
runtime_lib /lib/ld-musl-x86_64.so.1
|
||||||
|
runtime_lib /usr/lib/libssl.so.3
|
||||||
|
runtime_lib /usr/lib/libcrypto.so.3
|
||||||
|
runtime_lib /usr/lib/libgcc_s.so.1
|
||||||
|
write_runner
|
||||||
|
|
||||||
|
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
|
||||||
|
ok "Bundle ready: $BUNDLE_ARCHIVE"
|
||||||
|
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
|
||||||
|
ls -lh "$BUNDLE_ARCHIVE"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_bundle() {
|
||||||
|
require_file "$SSH_KEY"
|
||||||
|
require_command ssh
|
||||||
|
require_command scp
|
||||||
|
|
||||||
|
local remote="${REMOTE_USER}@${REMOTE_HOST}"
|
||||||
|
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
log "Preparing remote directory: $remote:$REMOTE_DIR"
|
||||||
|
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
|
||||||
|
|
||||||
|
log "Uploading bundle..."
|
||||||
|
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
|
||||||
|
|
||||||
|
log "Extracting bundle on Debian host..."
|
||||||
|
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
|
||||||
|
|
||||||
|
if [ "$SKIP_VERIFY" -eq 0 ]; then
|
||||||
|
log "Checking remote config..."
|
||||||
|
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Deployed BossAI to $remote:$REMOTE_DIR"
|
||||||
|
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$SKIP_BUILD" -eq 0 ]; then
|
||||||
|
ensure_bossai_source
|
||||||
|
prepare_workflow_ui_source
|
||||||
|
log "Building BossAI production bundle..."
|
||||||
|
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
|
||||||
|
--production \
|
||||||
|
--production-dir "$PRODUCTION_DIR"
|
||||||
|
else
|
||||||
|
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
|
||||||
|
log "Skipping build; using existing $PRODUCTION_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
package_bundle
|
||||||
|
deploy_bundle
|
||||||
@@ -4,10 +4,34 @@ import { spawn } from 'child_process';
|
|||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
|
||||||
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1';
|
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
|
||||||
|
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
|
||||||
|
const DEFAULT_CLOUD_REF = 'v1.46.2';
|
||||||
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
|
||||||
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
|
||||||
const DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
|
||||||
|
|
||||||
|
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
|
||||||
|
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
external: {
|
||||||
|
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|
||||||
|
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|
||||||
|
|| DEFAULT_EXTERNAL_REF,
|
||||||
|
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|
||||||
|
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
|
||||||
|
description: 'External/RunningHub compatible frontend',
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|
||||||
|
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|
||||||
|
|| DEFAULT_CLOUD_REF,
|
||||||
|
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|
||||||
|
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
|
||||||
|
description: 'Comfy Cloud frontend',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -25,23 +49,54 @@ function run(command, args, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
|
function selectedVariants() {
|
||||||
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR;
|
if (VARIANT === 'all') return Object.entries(variants);
|
||||||
if (REPO) {
|
if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
|
||||||
|
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildVariant(name, config) {
|
||||||
|
const ref = config.ref;
|
||||||
|
const dist = config.dist;
|
||||||
|
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
|
||||||
|
|
||||||
|
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
|
||||||
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
|
||||||
await fs.rm(CACHE_DIR, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
|
|
||||||
} else if (!hasSubmodule) {
|
if (REPO) {
|
||||||
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
|
||||||
|
} else {
|
||||||
|
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
|
||||||
|
if (!hasSubmodule) {
|
||||||
|
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
||||||
|
}
|
||||||
|
await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
|
||||||
|
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
|
||||||
|
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
|
||||||
|
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
|
||||||
|
if (hasPnpmLock) {
|
||||||
|
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
|
||||||
|
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: dir });
|
||||||
|
} else {
|
||||||
|
await run('npm', ['ci'], { cwd: dir });
|
||||||
|
await run('npm', ['run', 'build'], { cwd: dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(dist, { recursive: true, force: true });
|
||||||
|
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
|
||||||
|
console.log(`workflow-ui:${name} installed to ${dist}`);
|
||||||
|
|
||||||
|
if (name === 'cloud') {
|
||||||
|
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
|
||||||
|
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
|
||||||
|
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const hasPnpmLock = await fs.access(path.join(DIR, 'pnpm-lock.yaml')).then(() => true, () => false);
|
|
||||||
if (hasPnpmLock) {
|
for (const [name, config] of selectedVariants()) {
|
||||||
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR });
|
await buildVariant(name, config);
|
||||||
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
+11
-1
@@ -47,6 +47,8 @@ pub struct ProxyConfig {
|
|||||||
pub first_byte_timeout_secs: u64,
|
pub first_byte_timeout_secs: u64,
|
||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
pub proxy_log_retention_days: u32,
|
pub proxy_log_retention_days: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub template_proxy_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -76,6 +78,7 @@ pub enum ComfyUIBackend {
|
|||||||
Boosapi,
|
Boosapi,
|
||||||
External,
|
External,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Runninghub,
|
||||||
Docker,
|
Docker,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +112,20 @@ impl Default for ComfyUIConfig {
|
|||||||
|
|
||||||
impl ComfyUIConfig {
|
impl ComfyUIConfig {
|
||||||
pub fn uses_native_backend(&self) -> bool {
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::External | ComfyUIBackend::Cloud)
|
matches!(
|
||||||
|
self.backend,
|
||||||
|
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn uses_cloud_backend(&self) -> bool {
|
pub fn uses_cloud_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::Cloud)
|
matches!(self.backend, ComfyUIBackend::Cloud)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn uses_runninghub_backend(&self) -> bool {
|
||||||
|
matches!(self.backend, ComfyUIBackend::Runninghub)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn uses_docker_backend(&self) -> bool {
|
pub fn uses_docker_backend(&self) -> bool {
|
||||||
matches!(self.backend, ComfyUIBackend::Docker)
|
matches!(self.backend, ComfyUIBackend::Docker)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -6,6 +6,7 @@ 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;
|
||||||
|
|
||||||
@@ -47,9 +48,7 @@ impl AppState {
|
|||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
register_captcha: Arc::new(
|
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||||
services::register_captcha::RegisterCaptchaStore::new(),
|
|
||||||
),
|
|
||||||
registration_limiter: Arc::new(
|
registration_limiter: Arc::new(
|
||||||
services::registration_limiter::RegistrationLimiter::new(),
|
services::registration_limiter::RegistrationLimiter::new(),
|
||||||
),
|
),
|
||||||
|
|||||||
+455
-60
@@ -8,8 +8,10 @@ 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()
|
||||||
@@ -100,6 +102,62 @@ 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 {
|
||||||
@@ -128,42 +186,73 @@ 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<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
|
let routes: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
|
||||||
|
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();
|
|
||||||
for route in routes {
|
let summaries: Vec<Value> = routes
|
||||||
let channel_count: i64 =
|
.into_iter()
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM route_channels WHERE route_id = ?")
|
.map(|row| {
|
||||||
.bind(route.id)
|
let site_names_raw: String = row.try_get("site_names").unwrap_or_default();
|
||||||
.fetch_one(&*state.db)
|
let site_names: Vec<String> = site_names_raw
|
||||||
.await?;
|
.split(',')
|
||||||
let enabled_channel_count: i64 = sqlx::query_scalar(
|
.map(str::trim)
|
||||||
"SELECT COUNT(*) FROM route_channels WHERE route_id = ? AND COALESCE(enabled, 1) = 1",
|
.filter(|value| !value.is_empty())
|
||||||
)
|
.map(str::to_string)
|
||||||
.bind(route.id)
|
.collect();
|
||||||
.fetch_one(&*state.db)
|
let decision_snapshot_raw: Option<String> = row.try_get("decision_snapshot").ok();
|
||||||
.await?;
|
let decision_snapshot = decision_snapshot_raw
|
||||||
summaries.push(json!({
|
.as_deref()
|
||||||
"id": route.id,
|
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||||
"modelPattern": route.model_pattern,
|
.unwrap_or(Value::Null);
|
||||||
"displayName": route.display_name,
|
json!({
|
||||||
"displayIcon": route.display_icon,
|
"id": row.try_get::<i64, _>("id").unwrap_or_default(),
|
||||||
"routeMode": route.route_mode,
|
"modelPattern": row.try_get::<String, _>("model_pattern").unwrap_or_default(),
|
||||||
|
"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": route.model_mapping,
|
"modelMapping": row.try_get::<Option<String>, _>("model_mapping").ok().flatten(),
|
||||||
"routingStrategy": route.routing_strategy,
|
"routingStrategy": row.try_get::<Option<String>, _>("routing_strategy").ok().flatten(),
|
||||||
"enabled": route.enabled.unwrap_or(true),
|
"enabled": row.try_get::<Option<bool>, _>("enabled").ok().flatten().unwrap_or(true),
|
||||||
"channelCount": channel_count,
|
"channelCount": row.try_get::<i64, _>("channel_count").unwrap_or_default(),
|
||||||
"enabledChannelCount": enabled_channel_count,
|
"enabledChannelCount": row.try_get::<i64, _>("enabled_channel_count").unwrap_or_default(),
|
||||||
"siteNames": [],
|
"siteNames": site_names,
|
||||||
"decisionSnapshot": Value::Null,
|
"decisionSnapshot": decision_snapshot,
|
||||||
"decisionRefreshedAt": Value::Null,
|
"decisionRefreshedAt": row.try_get::<Option<String>, _>("decision_refreshed_at").ok().flatten(),
|
||||||
}));
|
})
|
||||||
}
|
})
|
||||||
Ok(Json(
|
.collect();
|
||||||
json!({ "success": true, "items": summaries, "data": summaries }),
|
Ok(Json(json!({
|
||||||
))
|
"success": true,
|
||||||
|
"routes": summaries,
|
||||||
|
"items": summaries,
|
||||||
|
"data": summaries,
|
||||||
|
"total": summaries.len(),
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route(
|
async fn create_route(
|
||||||
@@ -375,9 +464,17 @@ 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,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,16 +591,13 @@ async fn reset_downstream_key_usage(
|
|||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
async fn downstream_keys_summary(
|
||||||
let items: Vec<Value> = sqlx::query_as::<
|
State(state): State<AppState>,
|
||||||
_,
|
Query(query): Query<DownstreamUsageQuery>,
|
||||||
crate::db::models::downstream_api_key::DownstreamApiKey,
|
) -> AppResult<Json<Value>> {
|
||||||
>("SELECT * FROM downstream_api_keys ORDER BY name")
|
let (range, since, _bucket_seconds) = resolve_downstream_usage_window(query.range.as_deref());
|
||||||
.fetch_all(&*state.db)
|
let rows = fetch_downstream_key_usage_rows(&state, since.as_deref()).await?;
|
||||||
.await?
|
let items: Vec<Value> = rows.into_iter().map(downstream_key_usage_json).collect();
|
||||||
.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?;
|
||||||
@@ -522,6 +616,7 @@ async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Jso
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
|
"range": range,
|
||||||
"items": items,
|
"items": items,
|
||||||
"data": items,
|
"data": items,
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -538,28 +633,327 @@ 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: Option<Value> = sqlx::query_as::<
|
let key = fetch_downstream_key_usage_row(&state, id, None).await?;
|
||||||
_,
|
let item = downstream_key_usage_json(key);
|
||||||
crate::db::models::downstream_api_key::DownstreamApiKey,
|
let usage = json!({
|
||||||
>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
"last24h": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::hours(24)))).await?,
|
||||||
.bind(id)
|
"last7d": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::days(7)))).await?,
|
||||||
.fetch_optional(&*state.db)
|
"all": downstream_usage_aggregate(&state, id, None).await?,
|
||||||
.await?
|
});
|
||||||
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})));
|
Ok(Json(json!({ "success": true, "item": item, "usage": usage })))
|
||||||
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<std::collections::HashMap<String, String>>,
|
Query(q): Query<DownstreamUsageQuery>,
|
||||||
) -> Json<Value> {
|
) -> AppResult<Json<Value>> {
|
||||||
Json(json!({
|
let key: Option<(i64, String)> =
|
||||||
|
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": q.get("range").map(String::as_str).unwrap_or("24h"),
|
"range": range,
|
||||||
"item": { "id": id, "name": "" },
|
"item": { "id": key_id, "name": key_name },
|
||||||
"bucketSeconds": 3600,
|
"bucketSeconds": bucket_seconds,
|
||||||
"buckets": [],
|
"timeZone": q.time_zone,
|
||||||
}))
|
"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> {
|
||||||
@@ -586,6 +980,7 @@ 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,6 +409,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ pub async fn create_site(
|
|||||||
return Err(AppError::BadRequest("platform is required".into()));
|
return Err(AppError::BadRequest("platform is required".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let site_url = normalize_site_url(&payload.url)?;
|
||||||
|
let platform = payload.platform.trim().to_string();
|
||||||
|
ensure_site_unique(&state, &platform, &site_url, None).await?;
|
||||||
|
|
||||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
@@ -154,8 +158,8 @@ pub async fn create_site(
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
.bind(&payload.url)
|
.bind(&site_url)
|
||||||
.bind(&payload.platform)
|
.bind(&platform)
|
||||||
.bind(&status)
|
.bind(&status)
|
||||||
.bind(&payload.api_key)
|
.bind(&payload.api_key)
|
||||||
.bind(&payload.proxy_url)
|
.bind(&payload.proxy_url)
|
||||||
@@ -217,6 +221,27 @@ pub async fn update_site(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||||
|
|
||||||
|
if payload
|
||||||
|
.platform
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|platform| platform.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest("platform is required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_url = match payload.url.as_deref() {
|
||||||
|
Some(url) => normalize_site_url(url)?,
|
||||||
|
None => existing.url.clone(),
|
||||||
|
};
|
||||||
|
let next_platform = payload
|
||||||
|
.platform
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(existing.platform.as_str())
|
||||||
|
.to_string();
|
||||||
|
ensure_site_unique(&state, &next_platform, &next_url, Some(id)).await?;
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -230,8 +255,8 @@ pub async fn update_site(
|
|||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(payload.name.unwrap_or(existing.name))
|
.bind(payload.name.unwrap_or(existing.name))
|
||||||
.bind(payload.url.unwrap_or(existing.url))
|
.bind(next_url)
|
||||||
.bind(payload.platform.unwrap_or(existing.platform))
|
.bind(next_platform)
|
||||||
.bind(payload.status.unwrap_or(existing.status))
|
.bind(payload.status.unwrap_or(existing.status))
|
||||||
.bind(payload.api_key.or(existing.api_key))
|
.bind(payload.api_key.or(existing.api_key))
|
||||||
.bind(payload.proxy_url.or(existing.proxy_url))
|
.bind(payload.proxy_url.or(existing.proxy_url))
|
||||||
@@ -466,6 +491,45 @@ fn normalize_site_url(raw: &str) -> AppResult<String> {
|
|||||||
Ok(trimmed)
|
Ok(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_site_unique(
|
||||||
|
state: &AppState,
|
||||||
|
platform: &str,
|
||||||
|
url: &str,
|
||||||
|
except_id: Option<i64>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let existing: Option<(i64, String)> = if let Some(id) = except_id {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT id, name FROM sites WHERE platform = ? AND url = ? AND id != ? LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(platform)
|
||||||
|
.bind(url)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as("SELECT id, name FROM sites WHERE platform = ? AND url = ? LIMIT 1")
|
||||||
|
.bind(platform)
|
||||||
|
.bind(url)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((id, name)) = existing {
|
||||||
|
let display_name = name.trim();
|
||||||
|
let display_name = if display_name.is_empty() {
|
||||||
|
"未命名站点"
|
||||||
|
} else {
|
||||||
|
display_name
|
||||||
|
};
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"该平台下已存在相同 URL 的站点:{}(ID {})",
|
||||||
|
display_name, id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
||||||
let lower = url.to_ascii_lowercase();
|
let lower = url.to_ascii_lowercase();
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target
|
|||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = client_socket
|
let _ = client_socket
|
||||||
.send(Message::Text(format!("invalid ComfyUI websocket URL: {err}").into()))
|
.send(Message::Text(
|
||||||
|
format!("invalid ComfyUI websocket URL: {err}").into(),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -701,7 +701,8 @@ fn attachment_summary(attachment: &AgentAttachment) -> String {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
let header = format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
let header =
|
||||||
|
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
|
||||||
|
|
||||||
if let Some(text) = attachment
|
if let Some(text) = attachment
|
||||||
.text
|
.text
|
||||||
@@ -1234,6 +1235,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
+1104
-12
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ mod digital_human;
|
|||||||
mod events;
|
mod events;
|
||||||
mod health;
|
mod health;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
|
mod money_printer;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
mod picture_agent;
|
mod picture_agent;
|
||||||
@@ -46,6 +47,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(admin::routes(state.clone()))
|
.merge(admin::routes(state.clone()))
|
||||||
.merge(proxy::routes())
|
.merge(proxy::routes())
|
||||||
.merge(picture_agent::routes())
|
.merge(picture_agent::routes())
|
||||||
|
.merge(money_printer::routes())
|
||||||
.merge(comfyui_agent::routes())
|
.merge(comfyui_agent::routes())
|
||||||
.merge(digital_human::routes())
|
.merge(digital_human::routes())
|
||||||
.merge(comfyui::routes())
|
.merge(comfyui::routes())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+120
-21
@@ -1,21 +1,21 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
AppState,
|
||||||
auth::jwt::validate_jwt,
|
auth::jwt::validate_jwt,
|
||||||
db::models::background_task::BackgroundTask,
|
db::models::background_task::BackgroundTask,
|
||||||
db::models::downstream_api_key::DownstreamApiKey,
|
db::models::downstream_api_key::DownstreamApiKey,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
AppState,
|
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::{header, HeaderMap, Response, StatusCode},
|
http::{HeaderMap, Response, StatusCode, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const TEMPLATE_GALLERY_URL: &str =
|
const TEMPLATE_GALLERY_URL: &str =
|
||||||
@@ -344,18 +344,52 @@ async fn templates_handler(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
let _auth = resolve_picture_agent_auth(&headers, &state).await?;
|
||||||
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
let limit = query.limit.unwrap_or(144).clamp(1, 240);
|
||||||
let items = template_client()
|
let mut templates = builtin_picture_templates(limit);
|
||||||
|
let remote_status = match fetch_remote_gallery_templates(
|
||||||
|
&state.config,
|
||||||
|
limit.saturating_sub(templates.len()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut remote_templates) => {
|
||||||
|
templates.append(&mut remote_templates);
|
||||||
|
json!({ "ok": true })
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, "Picture Agent template gallery unavailable; using built-in templates");
|
||||||
|
json!({ "ok": false, "message": err })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
templates.truncate(limit);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"sourceUrl": "https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html",
|
||||||
|
"fallback": remote_status.get("ok").and_then(Value::as_bool) != Some(true),
|
||||||
|
"remote": remote_status,
|
||||||
|
"templates": templates,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_remote_gallery_templates(
|
||||||
|
config: &crate::config::AppConfig,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Value>, String> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let items = crate::template_http::client(config, TEMPLATE_GALLERY_TIMEOUT_SECS)
|
||||||
.get(TEMPLATE_GALLERY_URL)
|
.get(TEMPLATE_GALLERY_URL)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::UpstreamError(format!("failed to fetch templates: {err}")))?
|
.map_err(|err| format!("failed to fetch templates: {err}"))?
|
||||||
.error_for_status()
|
.error_for_status()
|
||||||
.map_err(|err| AppError::UpstreamError(format!("template gallery returned {err}")))?
|
.map_err(|err| format!("template gallery returned {err}"))?
|
||||||
.json::<Vec<GalleryItem>>()
|
.json::<Vec<GalleryItem>>()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::UpstreamError(format!("failed to parse templates: {err}")))?;
|
.map_err(|err| format!("failed to parse templates: {err}"))?;
|
||||||
|
|
||||||
let templates: Vec<Value> = items
|
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();
|
||||||
@@ -390,20 +424,85 @@ async fn templates_handler(
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.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 {
|
fn builtin_picture_templates(limit: usize) -> Vec<Value> {
|
||||||
Client::builder()
|
[
|
||||||
.timeout(Duration::from_secs(TEMPLATE_GALLERY_TIMEOUT_SECS))
|
(
|
||||||
.build()
|
"boosapi-product-glass-robot",
|
||||||
.unwrap_or_else(|_| Client::new())
|
"玻璃机器人产品照",
|
||||||
|
"商业",
|
||||||
|
"1024x1024",
|
||||||
|
"A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background, controlled studio lighting, premium advertising composition, ultra detailed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-hanfu-cat-golden-hour",
|
||||||
|
"汉服与白猫",
|
||||||
|
"国风",
|
||||||
|
"1024x1536",
|
||||||
|
"A breathtakingly beautiful ancient Chinese woman in flowing jade-green and blush pink hanfu, golden peony embroidery, sitting by a moon-shaped window in warm afternoon sunlight, gently teasing a fluffy white cat with a silk tassel, celadon tea set and ink calligraphy nearby, serene romantic dreamlike Song Dynasty aesthetics, cinematic depth of field, ultra detailed digital art",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-cyberpunk-street",
|
||||||
|
"赛博街景海报",
|
||||||
|
"海报",
|
||||||
|
"1536x1024",
|
||||||
|
"A cinematic cyberpunk street market after rain, neon signs reflected on wet pavement, a courier in a translucent raincoat walking through steam, layered storefronts and holographic menus, dramatic teal and amber lighting, editorial poster composition, high detail",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-character-turnaround",
|
||||||
|
"角色四视图",
|
||||||
|
"角色",
|
||||||
|
"1536x1024",
|
||||||
|
"Character design sheet, same original fantasy explorer shown in front view, side view, back view and three-quarter view, consistent outfit and proportions, clean light background, readable silhouette, detailed fabric and accessories, professional concept art",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-food-photography",
|
||||||
|
"高级餐饮摄影",
|
||||||
|
"商业",
|
||||||
|
"1024x1024",
|
||||||
|
"Premium food photography of a porcelain bowl of hand-pulled noodles with chili oil, scallions and steam, dark stone tabletop, soft side lighting, shallow depth of field, appetizing texture, refined restaurant campaign style",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-architecture-interior",
|
||||||
|
"现代茶室空间",
|
||||||
|
"空间",
|
||||||
|
"1536x1024",
|
||||||
|
"A quiet contemporary Chinese tea room interior with warm timber lattice, low stone tea table, linen cushions, bamboo courtyard visible through sliding doors, natural morning light, calm luxury hospitality design, realistic architectural visualization",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-kids-book-dragon",
|
||||||
|
"绘本小龙",
|
||||||
|
"插画",
|
||||||
|
"1024x1024",
|
||||||
|
"A gentle children's book illustration of a tiny green dragon learning to fly above a village garden, soft watercolor texture, expressive friendly eyes, warm pastel palette, charming composition, lots of small story details",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boosapi-film-still-office",
|
||||||
|
"悬疑电影帧",
|
||||||
|
"电影",
|
||||||
|
"1536x1024",
|
||||||
|
"A suspenseful cinematic film still in a dim modern office at night, a figure with a glowing screen-like face stands motionless near frosted glass, subtle volumetric light, muted colors, realistic lens depth, unsettling quiet atmosphere",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|(id, title, tag, size, prompt)| {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"title": title,
|
||||||
|
"tag": tag,
|
||||||
|
"size": size,
|
||||||
|
"image": Value::Null,
|
||||||
|
"prompt": prompt,
|
||||||
|
"source": {
|
||||||
|
"name": "BossAI 内置模板",
|
||||||
|
"url": Value::Null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn picture_generation_client() -> Client {
|
fn picture_generation_client() -> Client {
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ 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),
|
||||||
@@ -1688,6 +1689,7 @@ mod tests {
|
|||||||
first_byte_timeout_secs: 30,
|
first_byte_timeout_secs: 30,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
|
|||||||
+233
-23
@@ -8,6 +8,8 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -124,6 +126,7 @@ 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(),
|
||||||
@@ -148,6 +151,7 @@ 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,
|
||||||
@@ -186,11 +190,139 @@ 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,
|
||||||
@@ -489,28 +621,70 @@ async fn site_distribution(
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Sum per-site usage
|
// Sum per-site usage
|
||||||
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
let mut rows: Vec<(i64, String, Option<String>, i64, f64, i64, i64, f64)> = sqlx::query_as(
|
||||||
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0)
|
"SELECT
|
||||||
|
s.id,
|
||||||
|
s.name,
|
||||||
|
s.platform,
|
||||||
|
COALESCE(SUM(m.total_calls), 0),
|
||||||
|
COALESCE(SUM(m.total_spend), 0.0),
|
||||||
|
COALESCE(SUM(m.total_tokens), 0),
|
||||||
|
COUNT(DISTINCT a.id),
|
||||||
|
COALESCE(SUM(a.balance), 0.0)
|
||||||
FROM sites s
|
FROM sites s
|
||||||
|
LEFT JOIN accounts a ON a.site_id = s.id
|
||||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||||
GROUP BY s.id, s.name
|
GROUP BY s.id, s.name, s.platform
|
||||||
ORDER BY SUM(m.total_spend) DESC"
|
ORDER BY SUM(m.total_spend) DESC",
|
||||||
)
|
)
|
||||||
|
.bind(&since_day)
|
||||||
|
.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(|(id, name, calls, spend, tokens)| {
|
.map(
|
||||||
json!({
|
|(id, name, platform, calls, spend, tokens, account_count, balance)| {
|
||||||
"siteId": id,
|
json!({
|
||||||
"siteName": name,
|
"siteId": id,
|
||||||
"calls": calls,
|
"siteName": name,
|
||||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
"platform": platform,
|
||||||
"tokens": tokens,
|
"calls": calls,
|
||||||
})
|
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||||
})
|
"totalSpend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||||
|
"totalBalance": balance,
|
||||||
|
"accountCount": account_count,
|
||||||
|
"tokens": tokens,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(json!({ "distribution": distribution })))
|
Ok(Json(json!({ "distribution": distribution })))
|
||||||
@@ -528,25 +702,61 @@ async fn site_trend(
|
|||||||
.format("%Y-%m-%d")
|
.format("%Y-%m-%d")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let rows: Vec<(String, i64, f64, i64)> = sqlx::query_as(
|
let mut rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
|
||||||
"SELECT local_day, COALESCE(SUM(total_calls), 0), COALESCE(SUM(total_spend), 0.0), COALESCE(SUM(total_tokens), 0)
|
"SELECT
|
||||||
FROM model_day_usage
|
m.local_day,
|
||||||
WHERE local_day >= ?
|
s.name,
|
||||||
GROUP BY local_day
|
COALESCE(SUM(m.total_calls), 0),
|
||||||
ORDER BY local_day ASC",
|
COALESCE(SUM(m.total_spend), 0.0),
|
||||||
|
COALESCE(SUM(m.total_tokens), 0)
|
||||||
|
FROM model_day_usage m
|
||||||
|
JOIN sites s ON s.id = m.site_id
|
||||||
|
WHERE m.local_day >= ?
|
||||||
|
GROUP BY m.local_day, s.id, s.name
|
||||||
|
ORDER BY m.local_day ASC, s.name ASC",
|
||||||
)
|
)
|
||||||
.bind(&since_day)
|
.bind(&since_day)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let trend: Vec<Value> = rows
|
if rows.is_empty() {
|
||||||
.into_iter()
|
rows = sqlx::query_as(
|
||||||
.map(|(day, calls, spend, tokens)| {
|
"SELECT
|
||||||
|
date(l.created_at),
|
||||||
|
COALESCE(s.name, '未知站点'),
|
||||||
|
COUNT(l.id),
|
||||||
|
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||||
|
COALESCE(SUM(l.total_tokens), 0)
|
||||||
|
FROM proxy_logs l
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE date(l.created_at) >= ?
|
||||||
|
GROUP BY date(l.created_at), s.id, s.name
|
||||||
|
ORDER BY date(l.created_at) ASC, s.name ASC",
|
||||||
|
)
|
||||||
|
.bind(&since_day)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut by_day: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
|
||||||
|
for (day, site, calls, spend, tokens) in rows {
|
||||||
|
by_day.entry(day).or_default().insert(
|
||||||
|
site,
|
||||||
json!({
|
json!({
|
||||||
"date": day,
|
|
||||||
"calls": calls,
|
"calls": calls,
|
||||||
"spend": spend,
|
"spend": spend,
|
||||||
"tokens": tokens,
|
"tokens": tokens,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trend: Vec<Value> = by_day
|
||||||
|
.into_iter()
|
||||||
|
.map(|(day, sites)| {
|
||||||
|
json!({
|
||||||
|
"date": day,
|
||||||
|
"sites": sites,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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),
|
||||||
@@ -551,6 +552,53 @@ 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,
|
||||||
|
|||||||
+2529
-230
File diff suppressed because it is too large
Load Diff
+126
-8
@@ -7,12 +7,13 @@ use axum::{
|
|||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap, Response, StatusCode},
|
http::{header, HeaderMap, Response, StatusCode},
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::path::PathBuf;
|
use std::{collections::HashSet, path::PathBuf};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum WorkspaceCaller {
|
enum WorkspaceCaller {
|
||||||
@@ -33,9 +34,19 @@ struct AssetQuery {
|
|||||||
page_size: Option<i64>,
|
page_size: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BatchDeleteAssetsRequest {
|
||||||
|
#[serde(default, alias = "publicIds")]
|
||||||
|
public_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/workspace/assets", get(list_admin_assets))
|
.route("/api/workspace/assets", get(list_admin_assets))
|
||||||
|
.route(
|
||||||
|
"/api/workspace/assets/batch-delete",
|
||||||
|
post(batch_delete_admin_assets),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/workspace/assets/{public_id}",
|
"/api/workspace/assets/{public_id}",
|
||||||
get(get_admin_asset).delete(delete_admin_asset),
|
get(get_admin_asset).delete(delete_admin_asset),
|
||||||
@@ -45,6 +56,10 @@ pub fn routes() -> Router<AppState> {
|
|||||||
get(get_admin_asset_file),
|
get(get_admin_asset_file),
|
||||||
)
|
)
|
||||||
.route("/api/user/workspace/assets", get(list_user_assets))
|
.route("/api/user/workspace/assets", get(list_user_assets))
|
||||||
|
.route(
|
||||||
|
"/api/user/workspace/assets/batch-delete",
|
||||||
|
post(batch_delete_user_assets),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/user/workspace/assets/{public_id}",
|
"/api/user/workspace/assets/{public_id}",
|
||||||
get(get_user_asset).delete(delete_user_asset),
|
get(get_user_asset).delete(delete_user_asset),
|
||||||
@@ -167,12 +182,17 @@ 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 (? IS NULL OR user_id = ?)
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND 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 (
|
||||||
@@ -185,8 +205,11 @@ 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)
|
||||||
@@ -206,7 +229,11 @@ 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 (? IS NULL OR user_id = ?)
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND 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 (
|
||||||
@@ -217,8 +244,11 @@ 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)
|
||||||
@@ -249,17 +279,21 @@ async fn get_asset_row(
|
|||||||
WorkspaceCaller::Admin => None,
|
WorkspaceCaller::Admin => None,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE public_id = ?
|
WHERE public_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND (? IS NULL OR user_id = ?)
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(public_id)
|
||||||
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
||||||
@@ -286,16 +320,20 @@ async fn delete_asset(
|
|||||||
WorkspaceCaller::Admin => None,
|
WorkspaceCaller::Admin => None,
|
||||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||||
};
|
};
|
||||||
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE workspace_assets
|
"UPDATE workspace_assets
|
||||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||||
WHERE public_id = ?
|
WHERE public_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND (? IS NULL OR user_id = ?)",
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(public_id)
|
||||||
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(scoped_user_id)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
@@ -304,6 +342,62 @@ 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,
|
||||||
@@ -318,7 +412,11 @@ async fn get_asset_file(
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
.ok_or_else(|| AppError::NotFound("Workspace asset file is not archived locally".into()))?;
|
||||||
let path = PathBuf::from(file_path);
|
let path = PathBuf::from(file_path);
|
||||||
let bytes = tokio::fs::read(&path)
|
let file = tokio::fs::File::open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
.map_err(|_| AppError::NotFound("Workspace asset file not found".into()))?;
|
||||||
let mime_type = row
|
let mime_type = row
|
||||||
@@ -334,11 +432,13 @@ async fn get_asset_file(
|
|||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, mime_type)
|
.header(header::CONTENT_TYPE, mime_type)
|
||||||
|
.header(header::CONTENT_LENGTH, metadata.len().to_string())
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
format!("inline; filename=\"{}\"", file_name.replace('"', "")),
|
||||||
)
|
)
|
||||||
.body(Body::from(bytes))
|
.body(Body::from_stream(ReaderStream::new(file)))
|
||||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +478,15 @@ async fn delete_admin_asset(
|
|||||||
delete_asset(state, caller, public_id).await
|
delete_asset(state, caller, public_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn batch_delete_admin_assets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<BatchDeleteAssetsRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let caller = resolve_caller(&headers, &state, true)?;
|
||||||
|
delete_assets_batch(state, caller, payload.public_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_user_assets(
|
async fn list_user_assets(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -413,3 +522,12 @@ async fn delete_user_asset(
|
|||||||
let caller = resolve_caller(&headers, &state, false)?;
|
let caller = resolve_caller(&headers, &state, false)?;
|
||||||
delete_asset(state, caller, public_id).await
|
delete_asset(state, caller, public_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn batch_delete_user_assets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<BatchDeleteAssetsRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let caller = resolve_caller(&headers, &state, false)?;
|
||||||
|
delete_assets_batch(state, caller, payload.public_ids).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export function parseBatchApiKeys(input) {
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
return Array.from(new Set(input
|
|
||||||
.map((item) => String(item || '').trim())
|
|
||||||
.filter((item) => item.length > 0)));
|
|
||||||
}
|
|
||||||
const raw = String(input || '').trim();
|
|
||||||
if (!raw)
|
|
||||||
return [];
|
|
||||||
return Array.from(new Set(raw
|
|
||||||
.split(/[\r\n,,;;\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0)));
|
|
||||||
}
|
|
||||||
export function buildBatchApiKeyConnectionName(baseName, index, total) {
|
|
||||||
const normalized = String(baseName || '').trim();
|
|
||||||
if (!normalized)
|
|
||||||
return '';
|
|
||||||
if (total <= 1)
|
|
||||||
return normalized;
|
|
||||||
return `${normalized} #${index + 1}`;
|
|
||||||
}
|
|
||||||
@@ -1,779 +0,0 @@
|
|||||||
const BRAND_DEFINITIONS = [
|
|
||||||
{
|
|
||||||
name: 'OpenAI',
|
|
||||||
icon: 'openai',
|
|
||||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'gpt', mode: 'startsWith' },
|
|
||||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
|
||||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
|
||||||
{ keyword: 'whisper', mode: 'startsWith' },
|
|
||||||
{ keyword: 'text-embedding', mode: 'startsWith' },
|
|
||||||
{ keyword: 'text-moderation', mode: 'startsWith' },
|
|
||||||
{ keyword: 'davinci', mode: 'startsWith' },
|
|
||||||
{ keyword: 'babbage', mode: 'startsWith' },
|
|
||||||
{ keyword: 'codex-mini', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o1', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o3', mode: 'startsWith' },
|
|
||||||
{ keyword: 'o4', mode: 'startsWith' },
|
|
||||||
{ keyword: 'tts', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Anthropic',
|
|
||||||
icon: 'claude-color',
|
|
||||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'claude', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google',
|
|
||||||
icon: 'gemini-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'gemini', mode: 'includes' },
|
|
||||||
{ keyword: 'gemma', mode: 'includes' },
|
|
||||||
{ keyword: 'google/', mode: 'includes' },
|
|
||||||
{ keyword: 'palm', mode: 'includes' },
|
|
||||||
{ keyword: 'paligemma', mode: 'includes' },
|
|
||||||
{ keyword: 'shieldgemma', mode: 'includes' },
|
|
||||||
{ keyword: 'recurrentgemma', mode: 'includes' },
|
|
||||||
{ keyword: 'deplot', mode: 'includes' },
|
|
||||||
{ keyword: 'codegemma', mode: 'includes' },
|
|
||||||
{ keyword: 'imagen', mode: 'includes' },
|
|
||||||
{ keyword: 'learnlm', mode: 'includes' },
|
|
||||||
{ keyword: 'aqa', mode: 'includes' },
|
|
||||||
{ keyword: 'veo', mode: 'startsWith' },
|
|
||||||
{ keyword: 'google/', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepSeek',
|
|
||||||
icon: 'deepseek-color',
|
|
||||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepseek', mode: 'includes' },
|
|
||||||
{ keyword: 'ds-chat', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '通义千问',
|
|
||||||
icon: 'qwen-color',
|
|
||||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'qwen', mode: 'includes' },
|
|
||||||
{ keyword: 'qwq', mode: 'includes' },
|
|
||||||
{ keyword: 'tongyi', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '智谱 AI',
|
|
||||||
icon: 'zhipu-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'glm', mode: 'includes' },
|
|
||||||
{ keyword: 'chatglm', mode: 'includes' },
|
|
||||||
{ keyword: 'codegeex', mode: 'includes' },
|
|
||||||
{ keyword: 'cogview', mode: 'includes' },
|
|
||||||
{ keyword: 'cogvideo', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Meta',
|
|
||||||
icon: 'meta-color',
|
|
||||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'llama', mode: 'includes' },
|
|
||||||
{ keyword: 'code-llama', mode: 'includes' },
|
|
||||||
{ keyword: 'codellama', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mistral',
|
|
||||||
icon: 'mistral-color',
|
|
||||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'mistral', mode: 'includes' },
|
|
||||||
{ keyword: 'mixtral', mode: 'includes' },
|
|
||||||
{ keyword: 'codestral', mode: 'includes' },
|
|
||||||
{ keyword: 'pixtral', mode: 'includes' },
|
|
||||||
{ keyword: 'ministral', mode: 'includes' },
|
|
||||||
{ keyword: 'voxtral', mode: 'includes' },
|
|
||||||
{ keyword: 'magistral', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Moonshot',
|
|
||||||
icon: 'moonshot',
|
|
||||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'moonshot', mode: 'includes' },
|
|
||||||
{ keyword: 'kimi', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '零一万物',
|
|
||||||
icon: 'yi-color',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'yi-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'yi', mode: 'boundary' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '文心一言',
|
|
||||||
icon: 'wenxin-color',
|
|
||||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ernie', mode: 'includes' },
|
|
||||||
{ keyword: 'eb-', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '讯飞星火',
|
|
||||||
icon: 'spark-color',
|
|
||||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'spark', mode: 'includes' },
|
|
||||||
{ keyword: 'generalv', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '腾讯混元',
|
|
||||||
icon: 'hunyuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'hunyuan', mode: 'includes' },
|
|
||||||
{ keyword: 'tencent-hunyuan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '豆包',
|
|
||||||
icon: 'doubao-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'doubao', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MiniMax',
|
|
||||||
icon: 'minimax-color',
|
|
||||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'minimax', mode: 'includes' },
|
|
||||||
{ keyword: 'abab', mode: 'includes' },
|
|
||||||
{ keyword: 'mini2.1', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cohere',
|
|
||||||
icon: 'cohere-color',
|
|
||||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'command', mode: 'includes' },
|
|
||||||
{ keyword: 'c4ai-', mode: 'includes' },
|
|
||||||
{ keyword: 'aya', mode: 'includes' },
|
|
||||||
{ keyword: 'embed-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Microsoft',
|
|
||||||
icon: 'microsoft-color',
|
|
||||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'microsoft/', mode: 'includes' },
|
|
||||||
{ keyword: 'phi-', mode: 'includes' },
|
|
||||||
{ keyword: 'kosmos', mode: 'includes' },
|
|
||||||
{ keyword: 'phi4', mode: 'segment' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'xAI',
|
|
||||||
icon: 'xai',
|
|
||||||
color: 'linear-gradient(135deg, #111, #444)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'grok', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阶跃星辰',
|
|
||||||
icon: 'stepfun-color',
|
|
||||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'stepfun', mode: 'includes' },
|
|
||||||
{ keyword: 'step-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'step3', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百川智能',
|
|
||||||
icon: 'baichuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baichuan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI21 Labs',
|
|
||||||
icon: 'ai21-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ai21', mode: 'includes' },
|
|
||||||
{ keyword: 'jamba', mode: 'startsWith' },
|
|
||||||
{ keyword: 'jamba', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AI2',
|
|
||||||
icon: 'ai2-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'allenai', mode: 'includes' },
|
|
||||||
{ keyword: 'olmo', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Amazon Nova',
|
|
||||||
icon: 'nova',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'amazon/nova', mode: 'startsWith' },
|
|
||||||
{ keyword: 'amazon.nova', mode: 'includes' },
|
|
||||||
{ keyword: 'us.amazon.nova', mode: 'includes' },
|
|
||||||
{ keyword: 'nova-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-lite', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-pro', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-micro', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-canvas', mode: 'startsWith' },
|
|
||||||
{ keyword: 'nova-reel', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stability',
|
|
||||||
icon: 'stability-color',
|
|
||||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'flux', mode: 'includes' },
|
|
||||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
|
||||||
{ keyword: 'stable-diffusion', mode: 'includes' },
|
|
||||||
{ keyword: 'sdxl', mode: 'includes' },
|
|
||||||
{ keyword: 'sd3', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NVIDIA',
|
|
||||||
icon: 'nvidia-color',
|
|
||||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'nvidia/', mode: 'includes' },
|
|
||||||
{ keyword: 'nvclip', mode: 'includes' },
|
|
||||||
{ keyword: 'nemotron', mode: 'includes' },
|
|
||||||
{ keyword: 'nemoretriever', mode: 'includes' },
|
|
||||||
{ keyword: 'neva', mode: 'includes' },
|
|
||||||
{ keyword: 'riva-translate', mode: 'includes' },
|
|
||||||
{ keyword: 'cosmos', mode: 'includes' },
|
|
||||||
{ keyword: 'nv-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IBM',
|
|
||||||
icon: 'ibm',
|
|
||||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ibm/', mode: 'includes' },
|
|
||||||
{ keyword: 'granite', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BAAI',
|
|
||||||
icon: 'baai',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baai/', mode: 'includes' },
|
|
||||||
{ keyword: 'bge-', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ByteDance',
|
|
||||||
icon: 'bytedance-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bytedance', mode: 'includes' },
|
|
||||||
{ keyword: 'seed-oss', mode: 'includes' },
|
|
||||||
{ keyword: 'kolors', mode: 'includes' },
|
|
||||||
{ keyword: 'kwai', mode: 'includes' },
|
|
||||||
{ keyword: 'kwaipilot', mode: 'includes' },
|
|
||||||
{ keyword: 'wan-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'kat-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'InternLM',
|
|
||||||
icon: 'internlm-color',
|
|
||||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'internlm', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Midjourney',
|
|
||||||
icon: 'midjourney',
|
|
||||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'midjourney', mode: 'includes' },
|
|
||||||
{ keyword: 'mj_', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepL',
|
|
||||||
icon: 'deepl-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
|
||||||
{ keyword: 'deepl/', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Jina AI',
|
|
||||||
icon: 'jina',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'jina', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Relace',
|
|
||||||
icon: 'relace',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'relace', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Arcee',
|
|
||||||
icon: 'arcee-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'arcee-ai', mode: 'includes' },
|
|
||||||
{ keyword: 'arcee', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AionLabs',
|
|
||||||
icon: 'aionlabs-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'aion-labs', mode: 'includes' },
|
|
||||||
{ keyword: 'aionlabs', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepCogito',
|
|
||||||
icon: 'deepcogito-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepcogito', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Essential AI',
|
|
||||||
icon: 'essentialai-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #334155)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'essentialai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inception',
|
|
||||||
icon: 'inception',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'inception', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inflection',
|
|
||||||
icon: 'inflection',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'inflection', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Liquid AI',
|
|
||||||
icon: 'liquid',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #475569)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'liquid', mode: 'includes' },
|
|
||||||
{ keyword: 'lfm-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'LongCat',
|
|
||||||
icon: 'longcat-color',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb7185)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'longcat', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Morph',
|
|
||||||
icon: 'morph-color',
|
|
||||||
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'morph/', mode: 'includes' },
|
|
||||||
{ keyword: 'morph-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Nous Research',
|
|
||||||
icon: 'nousresearch',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'nousresearch', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Upstage',
|
|
||||||
icon: 'upstage-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'upstage', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Xiaomi MiMo',
|
|
||||||
icon: 'xiaomimimo',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'xiaomi/mimo', mode: 'includes' },
|
|
||||||
{ keyword: 'xiaomimimo', mode: 'includes' },
|
|
||||||
{ keyword: 'mimo-v', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Z.ai',
|
|
||||||
icon: 'zai',
|
|
||||||
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: '2zai', mode: 'startsWith' },
|
|
||||||
{ keyword: 'z-ai', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SenseNova',
|
|
||||||
icon: 'sensenova-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'sensenova', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Perplexity',
|
|
||||||
icon: 'perplexity-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'perplexity', mode: 'includes' },
|
|
||||||
{ keyword: 'pplx-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OpenRouter',
|
|
||||||
icon: 'openrouter',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'openrouter', mode: 'includes' },
|
|
||||||
{ keyword: 'openrouter-', mode: 'startsWith' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Groq',
|
|
||||||
icon: 'groq',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'groq', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Fireworks',
|
|
||||||
icon: 'fireworks-color',
|
|
||||||
color: 'linear-gradient(135deg, #fb7185, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'fireworks-ai', mode: 'includes' },
|
|
||||||
{ keyword: 'fireworks', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepInfra',
|
|
||||||
icon: 'deepinfra-color',
|
|
||||||
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'deepinfra', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Together AI',
|
|
||||||
icon: 'together-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'together.ai', mode: 'includes' },
|
|
||||||
{ keyword: 'together', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Replicate',
|
|
||||||
icon: 'replicate-brand',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'replicate', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SambaNova',
|
|
||||||
icon: 'sambanova-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'sambanova', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cerebras',
|
|
||||||
icon: 'cerebras-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'cerebras', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Ollama',
|
|
||||||
icon: 'ollama',
|
|
||||||
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'ollama', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ModelScope',
|
|
||||||
icon: 'modelscope-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'modelscope', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'SiliconCloud',
|
|
||||||
icon: 'siliconcloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'siliconcloud', mode: 'includes' },
|
|
||||||
{ keyword: 'siliconflow', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Azure AI',
|
|
||||||
icon: 'azureai-color',
|
|
||||||
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'azureai', mode: 'includes' },
|
|
||||||
{ keyword: 'azure-openai', mode: 'includes' },
|
|
||||||
{ keyword: 'azure/openai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AWS Bedrock',
|
|
||||||
icon: 'bedrock-color',
|
|
||||||
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bedrock', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Vertex AI',
|
|
||||||
icon: 'vertexai-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'vertexai', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google Cloud',
|
|
||||||
icon: 'googlecloud-brand-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'googlecloud', mode: 'includes' },
|
|
||||||
{ keyword: 'google-cloud', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百度智能云',
|
|
||||||
icon: 'baiducloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'baiducloud', mode: 'includes' },
|
|
||||||
{ keyword: 'qianfan', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '百炼',
|
|
||||||
icon: 'bailian-color',
|
|
||||||
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'bailian', mode: 'includes' },
|
|
||||||
{ keyword: 'dashscope', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阿里云',
|
|
||||||
icon: 'alibabacloud-color',
|
|
||||||
color: 'linear-gradient(135deg, #f97316, #fb923c)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'alibabacloud', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '火山引擎',
|
|
||||||
icon: 'volcengine-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'volcengine', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '七牛云',
|
|
||||||
icon: 'qiniu-color',
|
|
||||||
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
|
|
||||||
rules: [
|
|
||||||
{ keyword: 'qiniu', mode: 'includes' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
function normalizeInput(value) {
|
|
||||||
return String(value || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
export function stripCommonWrappers(value) {
|
|
||||||
return value
|
|
||||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
|
||||||
.replace(/^re:\s*/g, '')
|
|
||||||
.replace(/^\^+/, '')
|
|
||||||
.replace(/\$+$/, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
export function collectBrandCandidates(modelName) {
|
|
||||||
const queue = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const push = (value) => {
|
|
||||||
const normalized = normalizeInput(value);
|
|
||||||
if (!normalized || seen.has(normalized))
|
|
||||||
return;
|
|
||||||
seen.add(normalized);
|
|
||||||
queue.push(normalized);
|
|
||||||
};
|
|
||||||
push(modelName);
|
|
||||||
for (let index = 0; index < queue.length; index += 1) {
|
|
||||||
const candidate = queue[index];
|
|
||||||
const cleaned = stripCommonWrappers(candidate);
|
|
||||||
push(cleaned);
|
|
||||||
if (cleaned.includes('/')) {
|
|
||||||
for (const part of cleaned.split('/'))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(':')) {
|
|
||||||
for (const part of cleaned.split(':'))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(',')) {
|
|
||||||
for (const part of cleaned.split(','))
|
|
||||||
push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return queue;
|
|
||||||
}
|
|
||||||
function buildMatchContext(modelName) {
|
|
||||||
const candidates = collectBrandCandidates(modelName);
|
|
||||||
const raw = candidates[0] || normalizeInput(modelName);
|
|
||||||
const cleaned = stripCommonWrappers(raw);
|
|
||||||
const segments = Array.from(new Set(candidates
|
|
||||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
|
||||||
.map((segment) => segment.trim())
|
|
||||||
.filter(Boolean)));
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
cleaned,
|
|
||||||
segments,
|
|
||||||
candidates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function matchesRule(context, rule) {
|
|
||||||
switch (rule.mode) {
|
|
||||||
case 'includes':
|
|
||||||
return context.raw.includes(rule.keyword)
|
|
||||||
|| context.cleaned.includes(rule.keyword)
|
|
||||||
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
|
|
||||||
case 'startsWith':
|
|
||||||
return context.raw.startsWith(rule.keyword)
|
|
||||||
|| context.cleaned.startsWith(rule.keyword)
|
|
||||||
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|
|
||||||
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
|
|
||||||
case 'segment':
|
|
||||||
return context.segments.includes(rule.keyword);
|
|
||||||
case 'boundary': {
|
|
||||||
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
|
|
||||||
return pattern.test(context.raw)
|
|
||||||
|| pattern.test(context.cleaned)
|
|
||||||
|| context.candidates.some((candidate) => pattern.test(candidate));
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function escapeRegExp(value) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
|
||||||
brand,
|
|
||||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
|
||||||
}));
|
|
||||||
export function getAllBrands() {
|
|
||||||
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
|
|
||||||
}
|
|
||||||
export function getAllBrandNames() {
|
|
||||||
return BRAND_DEFINITIONS.map((brand) => brand.name);
|
|
||||||
}
|
|
||||||
function toBrandInfo(brand) {
|
|
||||||
return {
|
|
||||||
name: brand.name,
|
|
||||||
icon: brand.icon,
|
|
||||||
color: brand.color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function getMatchingBrands(modelName) {
|
|
||||||
const context = buildMatchContext(modelName);
|
|
||||||
const matches = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const add = (brand) => {
|
|
||||||
if (seen.has(brand.name))
|
|
||||||
return;
|
|
||||||
seen.add(brand.name);
|
|
||||||
matches.push(toBrandInfo(brand));
|
|
||||||
};
|
|
||||||
for (const definition of BRAND_DEFINITIONS) {
|
|
||||||
if (definition.rules.some((rule) => matchesRule(context, rule))) {
|
|
||||||
add(definition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const candidate of context.candidates) {
|
|
||||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
|
||||||
if (rule.boundaryRegex.test(candidate)) {
|
|
||||||
add(rule.brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
export function getMatchingBrandNames(modelName) {
|
|
||||||
return getMatchingBrands(modelName).map((brand) => brand.name);
|
|
||||||
}
|
|
||||||
export function getBrand(modelName) {
|
|
||||||
return getMatchingBrands(modelName)[0] || null;
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
function normalizeString(value) {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
function normalizeDigest(value) {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
function normalizeStableTag(value) {
|
|
||||||
return normalizeStableVersion(value);
|
|
||||||
}
|
|
||||||
function hasSameImageTag(left, right) {
|
|
||||||
const rawLeft = normalizeString(left);
|
|
||||||
const rawRight = normalizeString(right);
|
|
||||||
if (!rawLeft || !rawRight)
|
|
||||||
return false;
|
|
||||||
const stableLeft = normalizeStableTag(left);
|
|
||||||
const stableRight = normalizeStableTag(right);
|
|
||||||
if (stableLeft && stableRight) {
|
|
||||||
return stableLeft === stableRight;
|
|
||||||
}
|
|
||||||
return rawLeft === rawRight;
|
|
||||||
}
|
|
||||||
export function normalizeStableVersion(value) {
|
|
||||||
const raw = normalizeString(value);
|
|
||||||
if (!raw)
|
|
||||||
return '';
|
|
||||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
|
||||||
if (!match)
|
|
||||||
return '';
|
|
||||||
return [
|
|
||||||
Number.parseInt(match[1], 10),
|
|
||||||
Number.parseInt(match[2], 10),
|
|
||||||
Number.parseInt(match[3], 10),
|
|
||||||
].join('.');
|
|
||||||
}
|
|
||||||
export function compareStableVersions(left, right) {
|
|
||||||
const normalizedLeft = normalizeStableVersion(left);
|
|
||||||
const normalizedRight = normalizeStableVersion(right);
|
|
||||||
if (!normalizedLeft || !normalizedRight)
|
|
||||||
return null;
|
|
||||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] === rightParts[index])
|
|
||||||
continue;
|
|
||||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
export function isSameImageTarget(current, target) {
|
|
||||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
|
||||||
const targetDigest = normalizeDigest(target.digest);
|
|
||||||
if (currentDigest && targetDigest) {
|
|
||||||
return currentDigest === targetDigest;
|
|
||||||
}
|
|
||||||
return hasSameImageTag(current?.imageTag, target.tag);
|
|
||||||
}
|
|
||||||
export function buildUpdateReminderCandidateKey(source, candidate) {
|
|
||||||
const tagName = normalizeString(candidate.tagName);
|
|
||||||
const digest = normalizeDigest(candidate.digest);
|
|
||||||
if (!tagName)
|
|
||||||
return '';
|
|
||||||
if (source === 'docker-hub-tag' && digest) {
|
|
||||||
return `${source}:${tagName}@${digest}`;
|
|
||||||
}
|
|
||||||
return `${source}:${tagName}`;
|
|
||||||
}
|
|
||||||
export function resolveUpdateReminderCandidate(input) {
|
|
||||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
|
||||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
|
||||||
if (githubTag) {
|
|
||||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
source: 'github-release',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
|
||||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
|
||||||
tagName: githubTag,
|
|
||||||
digest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
|
||||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
|
||||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
|
||||||
if (!dockerTag)
|
|
||||||
return null;
|
|
||||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
|
||||||
if (dockerVersionCompare === -1) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-digest',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -108,8 +108,11 @@ impl UsageTracker {
|
|||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
if let Some(max_cost) = max_cost {
|
if let Some(max_cost) = max_cost {
|
||||||
if used_cost.unwrap_or(0.0) >= max_cost {
|
if max_cost <= 0.0 || used_cost.unwrap_or(0.0) >= max_cost {
|
||||||
return Err(AppError::RateLimited);
|
return Err(AppError::RateLimitedWithRetry {
|
||||||
|
message: "下游密钥成本额度已用完".into(),
|
||||||
|
retry_after_secs: 60,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -260,7 +263,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
tracker.check_rate_limit(1).await,
|
tracker.check_rate_limit(1).await,
|
||||||
Err(AppError::RateLimited)
|
Err(AppError::RateLimitedWithRetry { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ impl ComfyUIProcess {
|
|||||||
let base_url = self.internal_base_url();
|
let base_url = self.internal_base_url();
|
||||||
let mode = if self.config.uses_docker_backend() {
|
let mode = if self.config.uses_docker_backend() {
|
||||||
"docker"
|
"docker"
|
||||||
|
} else if self.config.uses_cloud_backend() {
|
||||||
|
"cloud"
|
||||||
|
} else if self.config.uses_runninghub_backend() {
|
||||||
|
"runninghub"
|
||||||
} else if self.config.enabled {
|
} else if self.config.enabled {
|
||||||
"embedded"
|
"embedded"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -179,7 +179,13 @@ mod tests {
|
|||||||
fn captcha_is_one_time() {
|
fn captcha_is_one_time() {
|
||||||
let store = RegisterCaptchaStore::new();
|
let store = RegisterCaptchaStore::new();
|
||||||
let challenge = store.issue(None);
|
let challenge = store.issue(None);
|
||||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
let answer = store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.get(&challenge.id)
|
||||||
|
.unwrap()
|
||||||
|
.answer
|
||||||
|
.clone();
|
||||||
|
|
||||||
store.verify(&challenge.id, &answer, None).unwrap();
|
store.verify(&challenge.id, &answer, None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -192,7 +198,13 @@ mod tests {
|
|||||||
fn captcha_rejects_other_ip() {
|
fn captcha_rejects_other_ip() {
|
||||||
let store = RegisterCaptchaStore::new();
|
let store = RegisterCaptchaStore::new();
|
||||||
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
let challenge = store.issue(Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
let answer = store.inner.lock().get(&challenge.id).unwrap().answer.clone();
|
let answer = store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.get(&challenge.id)
|
||||||
|
.unwrap()
|
||||||
|
.answer
|
||||||
|
.clone();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ pub async fn enforce_key_request_limit(
|
|||||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||||
}
|
}
|
||||||
if let Some(max_requests) = max_requests {
|
if let Some(max_requests) = max_requests {
|
||||||
|
if max_requests <= 0 {
|
||||||
|
return Err(AppError::RateLimitedWithRetry {
|
||||||
|
message: "下游密钥请求额度已用完".into(),
|
||||||
|
retry_after_secs: 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
if used_requests.unwrap_or(0) + increment > max_requests {
|
if used_requests.unwrap_or(0) + increment > max_requests {
|
||||||
return Err(AppError::RateLimited);
|
return Err(AppError::RateLimitedWithRetry {
|
||||||
|
message: "下游密钥请求额度已用完".into(),
|
||||||
|
retry_after_secs: 60,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
export declare function parseBatchApiKeys(input: unknown): string[];
|
|
||||||
export declare function buildBatchApiKeyConnectionName(
|
|
||||||
baseName: string | null | undefined,
|
|
||||||
index: number,
|
|
||||||
total: number,
|
|
||||||
): string;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
buildBatchApiKeyConnectionName,
|
|
||||||
parseBatchApiKeys,
|
|
||||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
|
||||||
Vendored
-40
@@ -1,40 +0,0 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
|
||||||
normalizedVersion?: string | null;
|
|
||||||
displayVersion?: string | null;
|
|
||||||
tagName?: string | null;
|
|
||||||
digest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminderCandidate = {
|
|
||||||
source: 'github-release' | 'docker-hub-tag';
|
|
||||||
kind: 'new-version' | 'new-digest';
|
|
||||||
candidateKey: string;
|
|
||||||
displayVersion: string;
|
|
||||||
tagName: string;
|
|
||||||
digest: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function normalizeStableVersion(value?: string | null): string;
|
|
||||||
export declare function compareStableVersions(
|
|
||||||
left?: string | null,
|
|
||||||
right?: string | null,
|
|
||||||
): number | null;
|
|
||||||
export declare function isSameImageTarget(
|
|
||||||
current: UpdateHelperRuntimeLike | null | undefined,
|
|
||||||
target: { tag?: string | null; digest?: string | null },
|
|
||||||
): boolean;
|
|
||||||
export declare function buildUpdateReminderCandidateKey(
|
|
||||||
source: 'github-release' | 'docker-hub-tag',
|
|
||||||
candidate: { tagName?: string | null; digest?: string | null },
|
|
||||||
): string;
|
|
||||||
export declare function resolveUpdateReminderCandidate(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminderCandidate | null;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export {
|
|
||||||
buildUpdateReminderCandidateKey,
|
|
||||||
compareStableVersions,
|
|
||||||
isSameImageTarget,
|
|
||||||
normalizeStableVersion,
|
|
||||||
resolveUpdateReminderCandidate,
|
|
||||||
} from '../server/shared/updateCenterReminder.js';
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::config::AppConfig;
|
||||||
|
use reqwest::{Client, Proxy};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn client(config: &AppConfig, timeout_secs: u64) -> Client {
|
||||||
|
let mut builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
|
||||||
|
if let Some(proxy_url) = template_proxy_url(config) {
|
||||||
|
match Proxy::all(&proxy_url) {
|
||||||
|
Ok(proxy) => {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%err, %proxy_url, "invalid template proxy url; falling back to direct template request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.build().unwrap_or_else(|_| Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn template_proxy_url(config: &AppConfig) -> Option<String> {
|
||||||
|
config
|
||||||
|
.proxy
|
||||||
|
.template_proxy_url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(non_empty)
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: &str) -> Option<&str> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty_owned(value: String) -> Option<String> {
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
}
|
||||||
@@ -36,8 +36,8 @@ describe('Login surface', () => {
|
|||||||
&& node.props.className.includes('brand-mark-canvas')
|
&& node.props.className.includes('brand-mark-canvas')
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(pageText).toContain('BoosAPI');
|
expect(pageText).toContain('BossAI');
|
||||||
expect(pageText).toContain('中转站的中转站');
|
expect(pageText).toContain('聚合多模型多模态的 AI 应用平台');
|
||||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
||||||
expect(pageText).toContain('统一代理网关');
|
expect(pageText).toContain('统一代理网关');
|
||||||
|
|||||||
+96
-17
@@ -7,6 +7,8 @@ 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,
|
||||||
@@ -37,6 +39,7 @@ const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
|||||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||||
|
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
||||||
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
const Workspace = lazy(() => import('./pages/Workspace.js'));
|
||||||
const Workflows = lazy(() => import('./pages/Workflows.js'));
|
const Workflows = lazy(() => import('./pages/Workflows.js'));
|
||||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||||
@@ -110,13 +113,15 @@ 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'
|
||||||
@@ -127,17 +132,53 @@ function resolveStoredProfile(): UserProfile {
|
|||||||
? parsed.avatarStyle.trim()
|
? parsed.avatarStyle.trim()
|
||||||
: '';
|
: '';
|
||||||
return {
|
return {
|
||||||
name: name || '管理员',
|
name: name || fallbackName,
|
||||||
avatarSeed: resolvedSeed,
|
avatarSeed: resolvedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(resolvedSeed),
|
: pickDicebearStyle(resolvedSeed),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const avatarSeed = createRandomAvatarSeed();
|
return null;
|
||||||
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveStoredProfile(audience: 'admin' | 'user'): UserProfile {
|
||||||
|
const fallbackName = audience === 'admin' ? '管理员' : '用户';
|
||||||
|
const storageKey = audience === 'admin' ? ADMIN_PROFILE_STORAGE_KEY : USER_PROFILE_STORAGE_KEY;
|
||||||
|
const stored = parseStoredProfile(localStorage.getItem(storageKey), fallbackName);
|
||||||
|
if (stored) return stored;
|
||||||
|
|
||||||
|
if (audience === 'user') {
|
||||||
|
const legacy = parseStoredProfile(localStorage.getItem(LEGACY_USER_PROFILE_STORAGE_KEY), fallbackName);
|
||||||
|
if (legacy && legacy.name !== '管理员') {
|
||||||
|
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(legacy));
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = defaultProfile(fallbackName);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileStorageKey(audience: 'admin' | 'user'): string {
|
||||||
|
return audience === 'admin' ? ADMIN_PROFILE_STORAGE_KEY : USER_PROFILE_STORAGE_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
|
||||||
|
const name = username.trim();
|
||||||
|
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
|
||||||
|
const avatarStyle = current?.avatarStyle?.trim() || 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,
|
||||||
@@ -347,6 +388,7 @@ export const sidebarGroups = [
|
|||||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||||
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
||||||
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||||
|
{ to: '/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
|
||||||
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
{ to: '/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||||
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
{ to: '/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||||
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||||
@@ -379,6 +421,7 @@ export const userSidebarGroups = [
|
|||||||
label: '创作应用',
|
label: '创作应用',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||||
|
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
|
||||||
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||||
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||||
@@ -390,6 +433,7 @@ 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' },
|
||||||
@@ -419,7 +463,9 @@ function AppShell() {
|
|||||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile());
|
const isUserPage = location.pathname.startsWith('/user/');
|
||||||
|
const profileAudience: 'admin' | 'user' = isUserPage ? 'user' : 'admin';
|
||||||
|
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile(profileAudience));
|
||||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
@@ -441,6 +487,9 @@ 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);
|
||||||
@@ -560,6 +609,35 @@ function AppShell() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [location.pathname, userAuthed]);
|
}, [location.pathname, userAuthed]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!userAuthed
|
||||||
|
|| !location.pathname.startsWith('/user/')
|
||||||
|
|| ['/user/login', '/user/register'].includes(location.pathname)
|
||||||
|
) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const syncUserProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userApi.me();
|
||||||
|
if (cancelled) return;
|
||||||
|
const user = (response as any)?.user || (response as any)?.data || {};
|
||||||
|
const username = typeof user.username === 'string' ? user.username.trim() : '';
|
||||||
|
if (!username) return;
|
||||||
|
setUserProfile((current) => {
|
||||||
|
if ((current.name || '').trim() === username) return current;
|
||||||
|
const next = profileFromUsername(username, current, '用户');
|
||||||
|
localStorage.setItem(profileStorageKey('user'), JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep the local profile if the user profile endpoint is temporarily unavailable.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void syncUserProfile();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [location.pathname, userAuthed]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||||
@@ -579,14 +657,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('管理员'),
|
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
||||||
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(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized));
|
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
||||||
setShowProfileModal(false);
|
setShowProfileModal(false);
|
||||||
toast.success(t('个人信息已保存'));
|
toast.success(t('个人信息已保存'));
|
||||||
};
|
};
|
||||||
@@ -617,7 +695,6 @@ 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`.
|
||||||
@@ -696,8 +773,8 @@ function AppShell() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="topbar-logo">
|
<div className="topbar-logo">
|
||||||
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
<img src="/logo.png" alt="BossAI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||||
<span className="topbar-logo-text">BoosAPI</span>
|
<span className="topbar-logo-text">BossAI</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="topbar-nav">
|
<nav className="topbar-nav">
|
||||||
{currentTopNavItems.map((item) => (
|
{currentTopNavItems.map((item) => (
|
||||||
@@ -829,8 +906,8 @@ function AppShell() {
|
|||||||
closeLabel={t('关闭导航')}
|
closeLabel={t('关闭导航')}
|
||||||
>
|
>
|
||||||
<div className="mobile-drawer-header">
|
<div className="mobile-drawer-header">
|
||||||
<img src="/logo.png" alt="BoosAPI" />
|
<img src="/logo.png" alt="BossAI" />
|
||||||
<span>BoosAPI</span>
|
<span>BossAI</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mobile-nav">
|
<nav className="mobile-nav">
|
||||||
{currentSidebarGroups.map((group) => (
|
{currentSidebarGroups.map((group) => (
|
||||||
@@ -914,6 +991,7 @@ function AppShell() {
|
|||||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||||
<Route path="/models" element={<Models />} />
|
<Route path="/models" element={<Models />} />
|
||||||
<Route path="/picture-agent" element={<PictureAgent />} />
|
<Route path="/picture-agent" element={<PictureAgent />} />
|
||||||
|
<Route path="/money-printer" element={<MoneyPrinter />} />
|
||||||
<Route path="/digital-human" element={<DigitalHuman />} />
|
<Route path="/digital-human" element={<DigitalHuman />} />
|
||||||
<Route path="/workflows" element={<Workflows />} />
|
<Route path="/workflows" element={<Workflows />} />
|
||||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||||
@@ -933,6 +1011,7 @@ function AppShell() {
|
|||||||
<Route path="/user/usage" element={<UsageRecords />} />
|
<Route path="/user/usage" element={<UsageRecords />} />
|
||||||
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
<Route path="/user/announcements" element={<UserAnnouncements />} />
|
||||||
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
||||||
|
<Route path="/user/money-printer" element={<MoneyPrinter />} />
|
||||||
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
<Route path="/user/digital-human" element={<DigitalHuman />} />
|
||||||
<Route path="/user/workflows" element={<Workflows />} />
|
<Route path="/user/workflows" element={<Workflows />} />
|
||||||
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
||||||
|
|||||||
+121
@@ -322,6 +322,15 @@ function normalizeDownstreamKeyRow(row: any): any {
|
|||||||
groupName: row.groupName ?? row.group_name ?? null,
|
groupName: row.groupName ?? row.group_name ?? null,
|
||||||
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
||||||
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
||||||
|
billing: row.billing ?? null,
|
||||||
|
rangeUsage: row.rangeUsage ?? row.range_usage ?? {
|
||||||
|
totalRequests: 0,
|
||||||
|
successRequests: 0,
|
||||||
|
failedRequests: 0,
|
||||||
|
successRate: null,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
},
|
||||||
maxCost: row.maxCost ?? row.max_cost ?? null,
|
maxCost: row.maxCost ?? row.max_cost ?? null,
|
||||||
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
||||||
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
||||||
@@ -1083,6 +1092,13 @@ export type WorkspaceAssetsResponse = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceBatchDeleteResponse = {
|
||||||
|
success: boolean;
|
||||||
|
deletedCount: number;
|
||||||
|
deletedIds: string[];
|
||||||
|
failedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceAssetQuery = {
|
export type WorkspaceAssetQuery = {
|
||||||
source?: string;
|
source?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -1142,6 +1158,44 @@ 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 () => {
|
||||||
@@ -2000,6 +2054,11 @@ export const api = {
|
|||||||
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
|
||||||
deleteWorkspaceAsset: (publicId: string) =>
|
deleteWorkspaceAsset: (publicId: string) =>
|
||||||
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||||
|
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
|
||||||
|
request<WorkspaceBatchDeleteResponse>("/api/workspace/assets/batch-delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ publicIds }),
|
||||||
|
}),
|
||||||
getWorkflowNodeTypes: () =>
|
getWorkflowNodeTypes: () =>
|
||||||
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
|
||||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||||
@@ -2020,6 +2079,34 @@ 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: {
|
||||||
@@ -2047,6 +2134,7 @@ export const userApi = {
|
|||||||
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
|
||||||
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
userRequest(`/api/user/logs${buildQueryString(params)}`),
|
||||||
getSummary: () => userRequest("/api/user/summary"),
|
getSummary: () => userRequest("/api/user/summary"),
|
||||||
|
getDashboard: () => userRequest("/api/user/dashboard"),
|
||||||
getModelsMarketplace: (options?: {
|
getModelsMarketplace: (options?: {
|
||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
includePricing?: boolean;
|
includePricing?: boolean;
|
||||||
@@ -2069,6 +2157,11 @@ export const userApi = {
|
|||||||
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
|
||||||
deleteWorkspaceAsset: (publicId: string) =>
|
deleteWorkspaceAsset: (publicId: string) =>
|
||||||
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
|
||||||
|
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
|
||||||
|
userRequest<WorkspaceBatchDeleteResponse>("/api/user/workspace/assets/batch-delete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ publicIds }),
|
||||||
|
}),
|
||||||
getWorkflowNodeTypes: () =>
|
getWorkflowNodeTypes: () =>
|
||||||
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
|
||||||
getWorkflows: (params?: { include_public?: boolean }) =>
|
getWorkflows: (params?: { include_public?: boolean }) =>
|
||||||
@@ -2089,4 +2182,32 @@ 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,6 +3,8 @@ 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 = {
|
||||||
@@ -24,5 +26,6 @@ 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; icon: string }> = [
|
const tabs: Array<{ key: TabKey; label: string; code: string }> = [
|
||||||
{ key: 'spend', label: '消耗分布', icon: '💰' },
|
{ key: 'spend', label: '消耗分布', code: 'SP' },
|
||||||
{ key: 'trend', label: '消耗趋势', icon: '📈' },
|
{ key: 'trend', label: '消耗趋势', code: 'TR' },
|
||||||
{ key: 'calls', label: '调用分布', icon: '🔄' },
|
{ key: 'calls', label: '调用分布', code: 'CL' },
|
||||||
{ key: 'rank', label: '排行榜', icon: '🏆' },
|
{ key: 'rank', label: '排行榜', code: 'RK' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function toSafeNumber(value: unknown): number {
|
function toSafeNumber(value: unknown): number {
|
||||||
@@ -46,6 +46,13 @@ function formatPercent(value: number): string {
|
|||||||
return `${toSafeNumber(value).toFixed(1)}%`;
|
return `${toSafeNumber(value).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPiePercent(_text: unknown, datum: unknown): string {
|
||||||
|
const record = datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
|
||||||
|
const percent = toSafeNumber(record._percent_);
|
||||||
|
if (percent <= 0) return '';
|
||||||
|
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyBlock() {
|
function EmptyBlock() {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state" style={{ padding: 28 }}>
|
<div className="empty-state" style={{ padding: 28 }}>
|
||||||
@@ -71,15 +78,16 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
|||||||
const callRanking = (data?.callRanking || []).slice(0, 10);
|
const callRanking = (data?.callRanking || []).slice(0, 10);
|
||||||
|
|
||||||
const hasData = totals.calls > 0
|
const hasData = totals.calls > 0
|
||||||
|| spendDistribution.length > 0
|
|| spendDistribution.some((item) => toSafeNumber(item.spend) > 0)
|
||||||
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
|
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0)
|
||||||
|
|| callsDistribution.some((item) => toSafeNumber(item.calls) > 0);
|
||||||
|
|
||||||
const spendBarSpec = useMemo(() => ({
|
const spendBarSpec = useMemo(() => ({
|
||||||
type: 'bar' as const,
|
type: 'bar' as const,
|
||||||
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
|
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
|
||||||
xField: 'value', yField: 'model', direction: 'horizontal' as const,
|
xField: 'value', yField: 'model', direction: 'horizontal' as const,
|
||||||
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
|
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
|
||||||
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
|
label: { visible: true, position: 'right', formatMethod: (text: unknown) => formatCurrency(Number(text || 0)), style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
|
||||||
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
|
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
|
||||||
animation: true, background: 'transparent',
|
animation: true, background: 'transparent',
|
||||||
}), [spendDistribution, labelColor]);
|
}), [spendDistribution, labelColor]);
|
||||||
@@ -102,7 +110,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
|||||||
valueField: 'calls', categoryField: 'model',
|
valueField: 'calls', categoryField: 'model',
|
||||||
outerRadius: 0.8, innerRadius: 0.55,
|
outerRadius: 0.8, innerRadius: 0.55,
|
||||||
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
||||||
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
|
label: { visible: true, position: 'outside', formatMethod: formatPiePercent, style: { fill: labelColor, fontSize: 12, stroke: 'transparent' } },
|
||||||
legends: { visible: false },
|
legends: { visible: false },
|
||||||
animation: true,
|
animation: true,
|
||||||
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
|
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
|
||||||
@@ -138,7 +146,8 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
|||||||
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
>
|
>
|
||||||
{tab.icon} {tab.label}
|
<span className="pill-tab-code">{tab.code}</span>
|
||||||
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export {
|
|||||||
stripCommonWrappers,
|
stripCommonWrappers,
|
||||||
type BrandInfo,
|
type BrandInfo,
|
||||||
type BrandMatchContext,
|
type BrandMatchContext,
|
||||||
} from '../../server/shared/modelBrand.js';
|
} from '../../shared/modelBrand.js';
|
||||||
|
|
||||||
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
||||||
anthropic: 'claude-color',
|
anthropic: 'claude-color',
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
|||||||
|
|
||||||
interface SiteDistributionData {
|
interface SiteDistributionData {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
platform: string;
|
platform?: string | null;
|
||||||
totalBalance: number;
|
totalBalance?: number;
|
||||||
totalSpend: number;
|
totalSpend?: number;
|
||||||
accountCount: number;
|
accountCount?: number;
|
||||||
|
spend?: number;
|
||||||
|
calls?: number;
|
||||||
|
tokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteDistributionChartProps {
|
interface SiteDistributionChartProps {
|
||||||
@@ -26,6 +29,13 @@ function safeNumber(value: unknown): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPercentLabel(datum: unknown): string {
|
||||||
|
const item = coerceDatumRecord(datum);
|
||||||
|
const percent = safeNumber(item._percent_);
|
||||||
|
if (percent <= 0) return '';
|
||||||
|
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function SkeletonCircle() {
|
function SkeletonCircle() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -98,8 +108,11 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
return data.map((item) => ({
|
return data.map((item) => ({
|
||||||
siteName: item.siteName,
|
siteName: item.siteName,
|
||||||
platform: item.platform,
|
platform: item.platform,
|
||||||
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
|
value: safeNumber(viewMode === 'balance'
|
||||||
|
? item.totalBalance
|
||||||
|
: item.totalSpend ?? item.spend),
|
||||||
accountCount: safeNumber(item.accountCount),
|
accountCount: safeNumber(item.accountCount),
|
||||||
|
calls: safeNumber(item.calls),
|
||||||
}));
|
}));
|
||||||
}, [data, viewMode]);
|
}, [data, viewMode]);
|
||||||
|
|
||||||
@@ -118,7 +131,12 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
outerRadius: 0.8,
|
outerRadius: 0.8,
|
||||||
innerRadius: 0.55,
|
innerRadius: 0.55,
|
||||||
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
|
||||||
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
|
label: {
|
||||||
|
visible: true,
|
||||||
|
position: 'outside',
|
||||||
|
formatMethod: (_text: unknown, datum: unknown) => formatPercentLabel(datum),
|
||||||
|
style: { fill: labelColor, fontSize: 12, stroke: 'transparent' },
|
||||||
|
},
|
||||||
legends: { visible: false },
|
legends: { visible: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
mark: {
|
mark: {
|
||||||
@@ -149,6 +167,13 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
return String(item.accountCount || 0);
|
return String(item.accountCount || 0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '调用',
|
||||||
|
value: (datum: unknown) => {
|
||||||
|
const item = coerceDatumRecord(datum);
|
||||||
|
return String(item.calls || 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
] as any,
|
] as any,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -159,6 +184,7 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
|
|||||||
}, [chartData, hasData, labelColor]);
|
}, [chartData, hasData, labelColor]);
|
||||||
|
|
||||||
const formatValue = (value: number): string => {
|
const formatValue = (value: number): string => {
|
||||||
|
if (viewMode === 'balance' && value === 0) return '$0.00';
|
||||||
if (value >= 1000) return `$${value.toFixed(2)}`;
|
if (value >= 1000) return `$${value.toFixed(2)}`;
|
||||||
if (value >= 1) return `$${value.toFixed(3)}`;
|
if (value >= 1) return `$${value.toFixed(3)}`;
|
||||||
return `$${value.toFixed(6)}`;
|
return `$${value.toFixed(6)}`;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { VChart } from '@visactor/react-vchart';
|
import { VChart } from '@visactor/react-vchart';
|
||||||
|
import { useThemeLabelColor } from '../useThemeLabelColor.js';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -7,7 +8,10 @@ import { VChart } from '@visactor/react-vchart';
|
|||||||
|
|
||||||
interface SiteTrendData {
|
interface SiteTrendData {
|
||||||
date: string;
|
date: string;
|
||||||
sites: Record<string, { spend: number; calls: number }>;
|
sites?: Record<string, { spend?: number; calls?: number; tokens?: number }>;
|
||||||
|
spend?: number;
|
||||||
|
calls?: number;
|
||||||
|
tokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteTrendChartProps {
|
interface SiteTrendChartProps {
|
||||||
@@ -43,20 +47,26 @@ const COLOR_PALETTE = [
|
|||||||
|
|
||||||
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
||||||
const [metric, setMetric] = useState<Metric>('spend');
|
const [metric, setMetric] = useState<Metric>('spend');
|
||||||
|
const labelColor = useThemeLabelColor();
|
||||||
|
|
||||||
/* ---------- data transform ---------- */
|
/* ---------- data transform ---------- */
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
if (!data || data.length === 0) return [];
|
if (!data || data.length === 0) return [];
|
||||||
return data.flatMap((d) =>
|
return data.flatMap((d) => {
|
||||||
Object.entries(d.sites).map(([site, v]) => ({
|
const sites = d.sites && Object.keys(d.sites).length > 0
|
||||||
|
? d.sites
|
||||||
|
: { 全部站点: { spend: d.spend, calls: d.calls, tokens: d.tokens } };
|
||||||
|
return Object.entries(sites).map(([site, v]) => ({
|
||||||
date: d.date,
|
date: d.date,
|
||||||
site,
|
site,
|
||||||
value: metric === 'spend' ? v.spend : v.calls,
|
value: metric === 'spend' ? Number(v.spend ?? 0) : Number(v.calls ?? 0),
|
||||||
})),
|
}));
|
||||||
);
|
});
|
||||||
}, [data, metric]);
|
}, [data, metric]);
|
||||||
|
|
||||||
|
const hasPositiveValue = flatData.some((item) => Number(item.value || 0) > 0);
|
||||||
|
|
||||||
/* ---------- loading state ---------- */
|
/* ---------- loading state ---------- */
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -72,7 +82,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
|||||||
|
|
||||||
/* ---------- empty state ---------- */
|
/* ---------- empty state ---------- */
|
||||||
|
|
||||||
if (!data || data.length === 0 || flatData.length === 0) {
|
if (!data || data.length === 0 || flatData.length === 0 || !hasPositiveValue) {
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
@@ -80,7 +90,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="empty-state" style={{ padding: 48 }}>
|
<div className="empty-state" style={{ padding: 48 }}>
|
||||||
<div className="empty-state-title">暂无趋势数据</div>
|
<div className="empty-state-title">暂无趋势数据</div>
|
||||||
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
|
<div className="empty-state-desc">所选时间范围内还没有有效的{metric === 'spend' ? '消耗' : '调用'}记录</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -144,22 +154,38 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
|
|||||||
axes: [
|
axes: [
|
||||||
{
|
{
|
||||||
orient: 'bottom',
|
orient: 'bottom',
|
||||||
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
|
label: {
|
||||||
|
style: { fontSize: 11, fill: labelColor },
|
||||||
|
formatMethod: (value: string) => {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (text.length >= 10) return text.slice(5, 10);
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
},
|
||||||
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
domainLine: { style: { stroke: 'var(--color-border-light)' } },
|
||||||
tick: { style: { stroke: 'var(--color-border-light)' } },
|
tick: { style: { stroke: 'var(--color-border-light)' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
orient: 'left',
|
orient: 'left',
|
||||||
label: {
|
label: {
|
||||||
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
|
style: { fontSize: 11, fill: labelColor },
|
||||||
|
formatMethod: (value: string) => {
|
||||||
|
const n = Number(value || 0);
|
||||||
|
if (!Number.isFinite(n)) return String(value || '');
|
||||||
|
if (metric === 'spend') {
|
||||||
|
if (n >= 1) return `$${n.toFixed(1)}`;
|
||||||
|
return `$${n.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
return Math.round(n).toLocaleString();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
|
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [3, 5], lineWidth: 1 } },
|
||||||
domainLine: { visible: false },
|
domainLine: { visible: false },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
color: COLOR_PALETTE,
|
color: COLOR_PALETTE,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
padding: { left: 8, right: 16, top: 8, bottom: 8 },
|
padding: { left: 48, right: 18, top: 12, bottom: 36 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- render ---------- */
|
/* ---------- render ---------- */
|
||||||
|
|||||||
@@ -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',
|
||||||
'关于 BoosAPI': 'About BoosAPI',
|
'关于 BossAI': 'About BossAI',
|
||||||
'管理端 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',
|
||||||
'例如 BoosAPI': 'For example BoosAPI',
|
'例如 BossAI': 'For example BossAI',
|
||||||
'例如: 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',
|
||||||
'中转站的中转站 — 将你在各处注册的 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.',
|
'聚合多模型多模态的 AI 应用平台 — 统一接入文本、图像、视频、语音等能力,构建可复用的 AI 工作流和应用。': 'A multimodal AI application platform for many models — unify text, image, video, and voice capabilities to build reusable AI workflows and applications.',
|
||||||
'重建路由失败': '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',
|
||||||
'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.',
|
'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.',
|
||||||
'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',
|
||||||
'中转站的中转站': 'The Hub of Hubs',
|
'聚合多模型多模态的 AI 应用平台': 'A multimodal AI application platform for many models',
|
||||||
'把分散的 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.',
|
'聚合文本、图像、视频、语音等多模态能力,统一接入多模型工作流和 AI 应用。': 'Unify text, image, video, and voice capabilities across multi-model workflows and AI applications.',
|
||||||
'兼容 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',
|
||||||
'关于 BoosAPI': 'About BoosAPI',
|
'关于 BossAI': 'About BossAI',
|
||||||
'站点文档': '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',
|
||||||
'BoosAPI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BoosAPI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
'BossAI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BossAI 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
|
||||||
'中转站的中转站 — 将你在各处注册的 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.',
|
'聚合多模型多模态的 AI 应用平台 — 统一接入文本、图像、视频、语音等能力,构建可复用的 AI 工作流和应用。': 'A multimodal AI application platform for many models — unify text, image, video, and voice capabilities to build reusable AI workflows and applications.',
|
||||||
'核心特色': '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',
|
||||||
'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.',
|
'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.',
|
||||||
|
|
||||||
// User SaaS translations
|
// User SaaS translations
|
||||||
'用户登录': 'User Login',
|
'用户登录': 'User Login',
|
||||||
|
|||||||
+2377
-100
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="BoosAPI - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
|
<meta name="description" content="BossAI - 聚合多模型多模态的 AI 应用平台" />
|
||||||
<title>BoosAPI</title>
|
<title>BossAI</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 />
|
||||||
|
|||||||
+90
-98
@@ -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 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
|
{ icon: 'WF', title: '工作流画布', desc: '内嵌 ComfyUI 前端,支持 Comfy Cloud、RunningHub、外部 ComfyUI 与 BossAI API-native 执行器。' },
|
||||||
{ 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 查询任务结果,并可复用 BoosAPI 模型路由。' },
|
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BossAI 模型路由。' },
|
||||||
{ 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,7 +21,8 @@ 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: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
|
{ name: 'Workflow UI', desc: '内嵌官方 ComfyUI 前端资产,按后端模式代理 Cloud、RunningHub 或外部 ComfyUI。' },
|
||||||
|
{ name: 'Multi Backend', desc: '支持 cloud / runninghub / external / BossAI / docker 五种工作流后端配置。' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENDPOINTS = [
|
const ENDPOINTS = [
|
||||||
@@ -34,146 +35,137 @@ 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="animate-fade-in" style={{ maxWidth: 860 }}>
|
<div className="about-page animate-fade-in">
|
||||||
{/* Header */}
|
<div className="page-header about-header">
|
||||||
<div className="page-header" style={{ marginBottom: 14 }}>
|
<div>
|
||||||
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
|
<h2 className="page-title">{tr('关于 BossAI')}</h2>
|
||||||
|
<p className="page-subtitle">{tr('统一网关、模型路由、工作流与多模态助手的运行概览。')}</p>
|
||||||
|
</div>
|
||||||
|
<span className="about-version">v{VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero card */}
|
<section className="about-hero animate-slide-up stagger-1">
|
||||||
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
|
<div className="about-brand">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
|
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="BoosAPI"
|
alt="BossAI"
|
||||||
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
|
className="about-logo"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
|
<div className="about-brand-name">BossAI</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
|
<div className="about-brand-meta">{tr('自托管 AI 模型网关与工作台')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
<p className="about-hero-copy">
|
||||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
|
{tr('BossAI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent、Workflow Studio 和外部 API 都能使用后台已经配置好的模型。')}
|
||||||
</div>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
<div className="about-chip-row">
|
||||||
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
{['Rust Backend', 'OpenAI Compatible', 'Claude Messages', 'ComfyUI Frontend', 'Comfy Cloud', 'RunningHub'].map((tag) => (
|
||||||
<span
|
<span key={tag} className="about-chip">
|
||||||
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>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Features */}
|
<section className="about-section animate-slide-up stagger-2">
|
||||||
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
|
<div className="about-section-heading">
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
|
<h3>{tr('核心特色')}</h3>
|
||||||
<div style={{
|
<p>{tr('围绕网关、模型、工作流和运维审计组织能力。')}</p>
|
||||||
display: 'grid',
|
</div>
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
<div className="about-feature-grid">
|
||||||
gap: 10,
|
|
||||||
}}>
|
|
||||||
{FEATURES.map((f) => (
|
{FEATURES.map((f) => (
|
||||||
<div key={f.title} style={{
|
<div key={f.title} className="about-feature-row">
|
||||||
display: 'flex', gap: 10, padding: '8px 0',
|
<span className="about-feature-mark">
|
||||||
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 style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
|
<div className="about-feature-title">{tr(f.title)}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
|
<div className="about-feature-desc">
|
||||||
{tr(f.desc)}
|
{tr(f.desc)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
|
<div className="about-two-column animate-slide-up stagger-3">
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3>
|
<section className="about-section">
|
||||||
<div style={{ display: 'grid', gap: 9 }}>
|
<div className="about-section-heading">
|
||||||
|
<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}
|
||||||
style={{
|
className="about-definition-row about-endpoint-row"
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'minmax(190px, 260px) 1fr',
|
|
||||||
gap: 12,
|
|
||||||
alignItems: 'start',
|
|
||||||
padding: '9px 0',
|
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-primary)' }}>
|
<code>
|
||||||
{endpoint.path}
|
{endpoint.path}
|
||||||
</code>
|
</code>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.6 }}>
|
<div>
|
||||||
{tr(endpoint.desc)}
|
{tr(endpoint.desc)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tech Stack */}
|
<section className="about-section animate-slide-up stagger-4">
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
|
<div className="about-section-heading">
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
|
<h3>{tr('技术栈')}</h3>
|
||||||
<div style={{
|
</div>
|
||||||
display: 'grid',
|
<div className="about-tech-grid">
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
|
||||||
gap: 10,
|
|
||||||
}}>
|
|
||||||
{TECH_STACK.map((t) => (
|
{TECH_STACK.map((t) => (
|
||||||
<div key={t.name} style={{
|
<div key={t.name} className="about-tech-item">
|
||||||
padding: '10px 14px',
|
<div className="about-tech-name">{t.name}</div>
|
||||||
borderRadius: 8,
|
<div className="about-tech-desc">
|
||||||
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>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Privacy */}
|
<section className="about-privacy animate-slide-up stagger-5">
|
||||||
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
|
<h3>{tr('数据与隐私')}</h3>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
|
<p>
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
{tr('BossAI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||||
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
</p>
|
||||||
</div>
|
</section>
|
||||||
</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('关于 BoosAPI');
|
expect(text).toContain('关于 BossAI');
|
||||||
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 后,BoosAPI 会在 JWT 临近过期或
|
配置 refresh_token 后,BossAI 会在 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: 'BoosAPI Video Agent Project',
|
name: 'BossAI Video Agent Project',
|
||||||
version: 1,
|
version: 1,
|
||||||
messages: messages
|
messages: messages
|
||||||
.filter((message) => message.role !== 'system')
|
.filter((message) => message.role !== 'system')
|
||||||
|
|||||||
+16
-52
@@ -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="animate-fade-in">
|
<div className="dashboard-page animate-fade-in">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -969,25 +969,8 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 站点级分析 */}
|
{/* 站点级分析 */}
|
||||||
<div
|
<div className="dashboard-section-head">
|
||||||
style={{
|
<div className="dashboard-section-title">
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: 12,
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
@@ -1004,24 +987,12 @@ export default function Dashboard({
|
|||||||
</svg>
|
</svg>
|
||||||
站点分析
|
站点分析
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 4 }}>
|
<div className="dashboard-range-tabs">
|
||||||
{[7, 30, 90].map((d) => (
|
{[7, 30, 90].map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
onClick={() => setTrendDays(d)}
|
onClick={() => setTrendDays(d)}
|
||||||
style={{
|
className={`dashboard-range-tab ${trendDays === d ? "is-active" : ""}`}
|
||||||
padding: "4px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
background:
|
|
||||||
trendDays === d ? "var(--color-primary)" : "var(--color-bg)",
|
|
||||||
color:
|
|
||||||
trendDays === d ? "white" : "var(--color-text-secondary)",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{d}天
|
{d}天
|
||||||
</button>
|
</button>
|
||||||
@@ -1029,14 +1000,7 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="dashboard-chart-grid">
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr",
|
|
||||||
gap: 16,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||||
<Suspense fallback={<ChartFallback height={320} />}>
|
<Suspense fallback={<ChartFallback height={320} />}>
|
||||||
<SiteDistributionChart
|
<SiteDistributionChart
|
||||||
@@ -1190,7 +1154,7 @@ export default function Dashboard({
|
|||||||
>
|
>
|
||||||
{site.averageLatencyMs != null
|
{site.averageLatencyMs != null
|
||||||
? `${site.averageLatencyMs}ms`
|
? `${site.averageLatencyMs}ms`
|
||||||
: "—"}
|
: "暂无"}
|
||||||
</span>
|
</span>
|
||||||
<span className="site-observability-metric-sep">·</span>
|
<span className="site-observability-metric-sep">·</span>
|
||||||
<span>{Math.round(site.totalRequests || 0)} 次</span>
|
<span>{Math.round(site.totalRequests || 0)} 次</span>
|
||||||
@@ -1216,7 +1180,7 @@ export default function Dashboard({
|
|||||||
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
|
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
|
||||||
bucket.averageLatencyMs != null
|
bucket.averageLatencyMs != null
|
||||||
? `平均响应:${bucket.averageLatencyMs}ms`
|
? `平均响应:${bucket.averageLatencyMs}ms`
|
||||||
: "平均响应:—",
|
: "平均响应:暂无",
|
||||||
].join(" · ")}
|
].join(" · ")}
|
||||||
data-tooltip-align="start"
|
data-tooltip-align="start"
|
||||||
title={[
|
title={[
|
||||||
@@ -1227,7 +1191,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)} 使用日志`}
|
||||||
/>
|
/>
|
||||||
@@ -1585,7 +1549,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>
|
||||||
|
|||||||
+585
-267
@@ -1,103 +1,356 @@
|
|||||||
import React, { CSSProperties, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { fetchSessionResponse } from '../api.js';
|
import { api, fetchSessionResponse, userApi, type DigitalHumanJob, type WorkflowSummary, type WorkspaceAsset } from '../api.js';
|
||||||
import type { SessionAudience } from '../authSession.js';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
|
|
||||||
type Mode = 'audio' | 'image' | 'text';
|
type AudioRole = 'lip_sync' | 'voice_reference' | 'both';
|
||||||
|
type UploadPurpose = 'avatar' | 'audio';
|
||||||
|
type MappingKey = keyof typeof DEFAULT_MAPPING;
|
||||||
|
|
||||||
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
|
const DEFAULT_MAPPING = {
|
||||||
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
|
prompt: { nodeId: '', field: 'prompt' },
|
||||||
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
|
script: { nodeId: '', field: 'text' },
|
||||||
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
|
avatar: { nodeId: '', field: 'image' },
|
||||||
|
referenceAudio: { nodeId: '', field: 'audio' },
|
||||||
|
voiceReference: { nodeId: '', field: 'audio' },
|
||||||
|
output: { nodeId: '', field: 'video' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_ROLES: Array<{ value: AudioRole; label: string; description: string }> = [
|
||||||
|
{ value: 'both', label: '口型 + 音色参考', description: '同一参考音频同时注入口型和音色节点。' },
|
||||||
|
{ value: 'lip_sync', label: '只驱动口型', description: '人物按参考音频开口,适合已有成品配音。' },
|
||||||
|
{ value: 'voice_reference', label: '只做音色参考', description: '模板内 TTS 节点按文案生成声音。' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MODEL_PRESETS = [
|
function terminal(status: string) {
|
||||||
'longcat-video-avatar',
|
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
||||||
'doubao-seedance-1-0-lite-i2v-250428',
|
}
|
||||||
'doubao-seedance-1-0-lite-t2v-250428',
|
|
||||||
'wan2-1-14b-i2v-250225',
|
|
||||||
];
|
|
||||||
|
|
||||||
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
|
function statusLabel(status: string) {
|
||||||
|
if (status === 'succeeded') return '完成';
|
||||||
|
if (status === 'failed') return '失败';
|
||||||
|
if (status === 'cancelled') return '已取消';
|
||||||
|
if (status === 'running') return '生成中';
|
||||||
|
return '排队中';
|
||||||
|
}
|
||||||
|
|
||||||
async function parseError(response: Response) {
|
function parseError(response: Response) {
|
||||||
const text = await response.text().catch(() => '');
|
return response.text().then((text) => {
|
||||||
if (!text) return `HTTP ${response.status}`;
|
if (!text) return `HTTP ${response.status}`;
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(text);
|
const value = JSON.parse(text);
|
||||||
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 extractTaskId(value: any): string {
|
function includesAny(value: string, words: string[]) {
|
||||||
return value?.response?.id
|
const text = value.toLowerCase();
|
||||||
|| value?.response?.task_id
|
return words.some((word) => text.includes(word));
|
||||||
|| value?.response?.data?.id
|
|
||||||
|| value?.response?.data?.task_id
|
|
||||||
|| value?.id
|
|
||||||
|| value?.task_id
|
|
||||||
|| '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyText(value: string) {
|
function findNode(
|
||||||
if (!value) return;
|
nodes: ReturnType<typeof collectPromptNodes>,
|
||||||
void navigator.clipboard?.writeText(value);
|
words: string[],
|
||||||
|
inputWords: string[] = [],
|
||||||
|
) {
|
||||||
|
return nodes.find((node) => {
|
||||||
|
const haystack = `${node.classType} ${node.title}`;
|
||||||
|
const inputNames = Object.keys(node.inputs || {}).join(' ');
|
||||||
|
return includesAny(haystack, words) || (inputWords.length > 0 && includesAny(inputNames, inputWords));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMappingFromWorkflow(workflow: WorkflowSummary | null) {
|
||||||
|
if (!workflow) return DEFAULT_MAPPING;
|
||||||
|
const nodes = collectPromptNodes(workflow.graph);
|
||||||
|
const promptNode = findNode(nodes, ['prompt', 'positive', 'textgenerate', 'text generate', 'wan', 'ltx'], ['prompt']);
|
||||||
|
const scriptNode = findNode(nodes, ['script', 'text', 'tts', 'voice', 'audio'], ['text', 'script', 'caption']);
|
||||||
|
const avatarNode = findNode(nodes, ['loadimage', 'image', 'video', 'avatar', 'portrait', 'person'], ['image', 'video', 'avatar']);
|
||||||
|
const audioNode = findNode(nodes, ['audio', 'sound', 'voice', 'tts', 'load audio'], ['audio', 'voice']);
|
||||||
|
const outputNode = findNode(nodes, ['savevideo', 'save video', 'previewvideo', 'video combine', 'vhs', 'output'], ['video', 'filename_prefix'])
|
||||||
|
|| [...nodes].reverse().find((node) => includesAny(`${node.classType} ${node.title}`, ['save', 'preview', 'output']));
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: { nodeId: promptNode?.id || '', field: promptNode?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
||||||
|
script: { nodeId: scriptNode?.id || promptNode?.id || '', field: scriptNode?.inputs?.script !== undefined ? 'script' : 'text' },
|
||||||
|
avatar: { nodeId: avatarNode?.id || '', field: avatarNode?.inputs?.video !== undefined ? 'video' : 'image' },
|
||||||
|
referenceAudio: { nodeId: audioNode?.id || '', field: 'audio' },
|
||||||
|
voiceReference: { nodeId: audioNode?.id || '', field: 'audio' },
|
||||||
|
output: { nodeId: outputNode?.id || '', field: outputNode?.inputs?.filename_prefix !== undefined ? 'filename_prefix' : 'video' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetFileUrl(asset: WorkspaceAsset, audience: SessionAudience) {
|
||||||
|
if (asset.remoteUrl) return asset.remoteUrl;
|
||||||
|
if (!asset.hasLocalFile) return '';
|
||||||
|
const prefix = audience === 'user' ? '/api/user' : '/api';
|
||||||
|
return `${prefix}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetName(asset: WorkspaceAsset) {
|
||||||
|
return asset.title || asset.fileName || asset.publicId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJobTime(value?: string | null) {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DigitalHuman() {
|
export default function DigitalHuman() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
const [mode, setMode] = useState<Mode>('audio');
|
const workspacePath = audience === 'user' ? '/user/workspace' : '/workspace';
|
||||||
const [model, setModel] = useState(MODEL_PRESETS[0]);
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
|
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||||
const [imageUrl, setImageUrl] = useState('');
|
const [jobs, setJobs] = useState<DigitalHumanJob[]>([]);
|
||||||
const [audioUrl, setAudioUrl] = useState('');
|
const [workspaceAssets, setWorkspaceAssets] = useState<WorkspaceAsset[]>([]);
|
||||||
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [resolution, setResolution] = useState('720p');
|
const [mapping, setMapping] = useState<any>(DEFAULT_MAPPING);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState('');
|
||||||
|
const [referenceAudioUrl, setReferenceAudioUrl] = useState('');
|
||||||
|
const [productScript, setProductScript] = useState('今天给大家介绍这款商品。它的核心优势是上手简单、效果稳定,适合直播间高频讲解和短视频种草。');
|
||||||
|
const [prompt, setPrompt] = useState('真人数字人主播,面向镜头自然讲解商品,口型准确,表情稳定,商业带货风格。');
|
||||||
|
const [audioRole, setAudioRole] = useState<AudioRole>('both');
|
||||||
const [ratio, setRatio] = useState('9:16');
|
const [ratio, setRatio] = useState('9:16');
|
||||||
const [duration, setDuration] = useState(8);
|
const [resolution, setResolution] = useState('1080p');
|
||||||
const [refImageIndex, setRefImageIndex] = useState(0);
|
const [subtitles, setSubtitles] = useState(true);
|
||||||
const [audioCfg, setAudioCfg] = useState(4.5);
|
|
||||||
const [segments, setSegments] = useState(false);
|
|
||||||
const [multiPerson, setMultiPerson] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState<UploadPurpose | ''>('');
|
||||||
|
const [assetPicker, setAssetPicker] = useState<UploadPurpose | ''>('');
|
||||||
|
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [result, setResult] = useState<any>(null);
|
|
||||||
|
|
||||||
const bodyPreview = useMemo(() => ({
|
const client = useMemo(() => (audience === 'user' ? userApi : api), [audience]);
|
||||||
model: model.trim(),
|
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||||
prompt: [
|
const sortedWorkflows = useMemo(() => {
|
||||||
prompt.trim(),
|
return [...workflows].sort((a, b) => {
|
||||||
script.trim() ? `台词:${script.trim()}` : '',
|
const score = (item: WorkflowSummary) => (templateHasMapping(item) ? 0 : 10)
|
||||||
`风格:${voiceStyle}`,
|
+ (item.isTemplate || item.visibility === 'template' ? 0 : item.visibility === 'public' ? 2 : 4);
|
||||||
].filter(Boolean).join('\n'),
|
return score(a) - score(b) || a.name.localeCompare(b.name, 'zh-CN');
|
||||||
mode,
|
});
|
||||||
ratio,
|
}, [workflows]);
|
||||||
resolution,
|
const mapped = useMemo(() => ({
|
||||||
duration,
|
prompt: mappingValue(mapping, 'prompt'),
|
||||||
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
|
script: mappingValue(mapping, 'script'),
|
||||||
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
|
avatar: mappingValue(mapping, 'avatar'),
|
||||||
audio_cfg: audioCfg,
|
referenceAudio: mappingValue(mapping, 'referenceAudio'),
|
||||||
ref_img_index: refImageIndex,
|
voiceReference: mappingValue(mapping, 'voiceReference'),
|
||||||
segmented: segments,
|
output: mappingValue(mapping, 'output'),
|
||||||
multi_person: multiPerson,
|
}), [mapping]);
|
||||||
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
|
|
||||||
|
|
||||||
const submit = async () => {
|
const missingMapping = [
|
||||||
setSubmitting(true);
|
['提示词节点', mapped.prompt.nodeId],
|
||||||
|
['文案节点', mapped.script.nodeId],
|
||||||
|
['人物素材节点', mapped.avatar.nodeId],
|
||||||
|
['参考音频节点', mapped.referenceAudio.nodeId],
|
||||||
|
['输出节点', mapped.output.nodeId],
|
||||||
|
].filter(([, value]) => !value).map(([label]) => label);
|
||||||
|
|
||||||
|
const canSubmit = !!templateId
|
||||||
|
&& !!avatarUrl.trim()
|
||||||
|
&& !!referenceAudioUrl.trim()
|
||||||
|
&& !!productScript.trim()
|
||||||
|
&& missingMapping.length === 0
|
||||||
|
&& !submitting;
|
||||||
|
const selectedTemplateReady = selectedWorkflow ? templateHasMapping(selectedWorkflow) || missingMapping.length === 0 : false;
|
||||||
|
const pickerAssets = workspaceAssets.filter((asset) => {
|
||||||
|
if (assetPicker === 'audio') return asset.kind === 'audio';
|
||||||
|
if (assetPicker === 'avatar') return asset.kind === 'image' || asset.kind === 'video';
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
const [workflowPayload, jobPayload] = await Promise.all([
|
||||||
|
client.getWorkflows({ include_public: true }),
|
||||||
|
client.getDigitalHumanJobs({ limit: 30 }),
|
||||||
|
]);
|
||||||
|
const nextWorkflows = Array.isArray(workflowPayload.items) ? workflowPayload.items : [];
|
||||||
|
setWorkflows(nextWorkflows);
|
||||||
|
setJobs(Array.isArray(jobPayload.jobs) ? jobPayload.jobs : []);
|
||||||
|
setTemplateId((current) => current || nextWorkflows.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId || nextWorkflows[0]?.publicId || '');
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData().catch((err) => setError(err?.message || '数字人数据加载失败'));
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMapping(graphMapping(selectedWorkflow));
|
||||||
|
}, [selectedWorkflow?.publicId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobs.some((job) => !terminal(job.status))) return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
client.getDigitalHumanJobs({ limit: 30 })
|
||||||
|
.then((payload) => setJobs(payload.jobs || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, 4000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [client, jobs]);
|
||||||
|
|
||||||
|
const uploadFile = async (file: File, purpose: UploadPurpose) => {
|
||||||
|
setUploading(purpose);
|
||||||
setError('');
|
setError('');
|
||||||
setResult(null);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', {
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('purpose', purpose);
|
||||||
|
const response = await fetchSessionResponse(audience, '/api/digital-human/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(bodyPreview),
|
body: form,
|
||||||
timeoutMs: 300_000,
|
timeoutMs: 180_000,
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await parseError(response));
|
if (!response.ok) throw new Error(await parseError(response));
|
||||||
setResult(await response.json());
|
const payload = await response.json();
|
||||||
|
const fileUrl = payload?.asset?.fileUrl || '';
|
||||||
|
if (purpose === 'avatar') setAvatarUrl(fileUrl);
|
||||||
|
if (purpose === 'audio') setReferenceAudioUrl(fileUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '素材上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMapping = (key: keyof typeof DEFAULT_MAPPING, field: 'nodeId' | 'field', value: string) => {
|
||||||
|
setMapping((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
...mappingValue(prev, key),
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferMapping = () => {
|
||||||
|
setMapping(inferMappingFromWorkflow(selectedWorkflow));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssetPicker = async (purpose: UploadPurpose) => {
|
||||||
|
setAssetPicker(purpose);
|
||||||
|
setLoadingAssets(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const kind = purpose === 'audio' ? 'audio' : undefined;
|
||||||
|
const payload = await client.getWorkspaceAssets({ kind, pageSize: 60 });
|
||||||
|
setWorkspaceAssets(Array.isArray(payload.items) ? payload.items : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '工作空间素材加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoadingAssets(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseAsset = (asset: WorkspaceAsset) => {
|
||||||
|
const url = assetFileUrl(asset, audience);
|
||||||
|
if (!url) {
|
||||||
|
setError('这个素材没有可用文件地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (assetPicker === 'avatar') setAvatarUrl(url);
|
||||||
|
if (assetPicker === 'audio') setReferenceAudioUrl(url);
|
||||||
|
setAssetPicker('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload = await client.createDigitalHumanJob({
|
||||||
|
templateId,
|
||||||
|
nodeMapping: mapping,
|
||||||
|
avatarUrl: avatarUrl.trim(),
|
||||||
|
referenceAudioUrl: referenceAudioUrl.trim(),
|
||||||
|
productScript: productScript.trim(),
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
audioRole,
|
||||||
|
ratio,
|
||||||
|
resolution,
|
||||||
|
subtitles,
|
||||||
|
});
|
||||||
|
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '数字人任务创建失败');
|
setError(err?.message || '数字人任务创建失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,236 +358,301 @@ export default function DigitalHuman() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskId = extractTaskId(result);
|
const cancel = async (id: string) => {
|
||||||
|
const payload = await client.cancelDigitalHumanJob(id);
|
||||||
|
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = async () => {
|
||||||
|
await client.clearDigitalHumanJobs();
|
||||||
|
await loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = (job: DigitalHumanJob) => {
|
||||||
|
const request = job.result?.request || {};
|
||||||
|
setTemplateId(request.templateId || job.result?.templateId || templateId);
|
||||||
|
setMapping(request.nodeMapping || job.result?.mapping || mapping);
|
||||||
|
setAvatarUrl(request.avatarUrl || '');
|
||||||
|
setReferenceAudioUrl(request.referenceAudioUrl || '');
|
||||||
|
setProductScript(request.productScript || '');
|
||||||
|
setPrompt(request.prompt || prompt);
|
||||||
|
setAudioRole(request.audioRole || 'both');
|
||||||
|
setRatio(request.ratio || '9:16');
|
||||||
|
setResolution(request.resolution || '1080p');
|
||||||
|
setSubtitles(request.subtitles !== false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="digital-human-page animate-fade-in">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">数字人</h1>
|
<h1 className="page-title">数字人</h1>
|
||||||
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
|
<p className="page-subtitle">选择 Comfy 工作流模板,上传人物素材和参考音频,生成真人商品讲解视频。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
|
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
|
||||||
{submitting ? '创建中...' : '创建数字人任务'}
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
||||||
|
{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 style={layoutStyle}>
|
<div className="digital-human-layout">
|
||||||
<section style={panelStyle}>
|
<section className="digital-human-panel">
|
||||||
<div style={modeGridStyle}>
|
<div className="digital-human-section-head">
|
||||||
{MODE_OPTIONS.map((item) => (
|
<div>
|
||||||
<button
|
<h2>1. 选择工作流模板</h2>
|
||||||
key={item.value}
|
<p>优先选择已配置数字人节点映射的模板;普通 Comfy 工作流也可以用自动猜测补齐。</p>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => setMode(item.value)}
|
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>猜测映射</button>
|
||||||
style={mode === item.value ? activeModeStyle : modeStyle}
|
</div>
|
||||||
>
|
<div className="digital-human-template-grid">
|
||||||
<strong>{item.label}</strong>
|
{sortedWorkflows.slice(0, 6).map((workflow) => {
|
||||||
<span>{item.description}</span>
|
const ready = templateHasMapping(workflow);
|
||||||
</button>
|
const active = workflow.publicId === templateId;
|
||||||
))}
|
return (
|
||||||
|
<button
|
||||||
|
key={workflow.publicId}
|
||||||
|
type="button"
|
||||||
|
className={`digital-human-template-card${active ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setTemplateId(workflow.publicId)}
|
||||||
|
>
|
||||||
|
<span className="digital-human-template-top">
|
||||||
|
<strong>{workflow.name}</strong>
|
||||||
|
<em>{ready ? '可直接生成' : '需映射'}</em>
|
||||||
|
</span>
|
||||||
|
<span className="digital-human-template-desc">
|
||||||
|
{workflow.description || 'Comfy 工作流模板,可注入人物素材、文案和参考音频。'}
|
||||||
|
</span>
|
||||||
|
<span className="digital-human-template-tags">
|
||||||
|
<i>{workflowKindLabel(workflow)}</i>
|
||||||
|
<i>{workflow.visibility}</i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="digital-human-field digital-human-template-select">
|
||||||
|
<span>全部模板</span>
|
||||||
|
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||||
|
<option value="">请选择模板</option>
|
||||||
|
{sortedWorkflows.map((workflow) => (
|
||||||
|
<option key={workflow.publicId} value={workflow.publicId}>
|
||||||
|
{workflow.name} · {workflowKindLabel(workflow)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectedWorkflow && (
|
||||||
|
<div className="digital-human-template-meta">
|
||||||
|
<strong>{selectedWorkflow.name}</strong>
|
||||||
|
<span>{selectedWorkflow.description || '这个模板需要配置节点映射后才能注入人物、文案和音频。'}</span>
|
||||||
|
<span>{selectedTemplateReady ? '节点映射已准备好。' : '节点映射不完整,可点击“猜测映射”后再微调。'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2>2. 上传素材</h2>
|
||||||
|
<div className="digital-human-upload-grid">
|
||||||
|
<div className="digital-human-upload-card">
|
||||||
|
<strong>人物图片 / 视频</strong>
|
||||||
|
<span>{avatarUrl ? '已准备素材' : '支持本地上传或粘贴 URL'}</span>
|
||||||
|
{avatarUrl && (
|
||||||
|
avatarUrl.match(/\.(mp4|webm|mov|m4v)(\?|$)/i)
|
||||||
|
? <video className="digital-human-upload-preview" src={avatarUrl} muted controls preload="metadata" />
|
||||||
|
: <img className="digital-human-upload-preview" src={avatarUrl} alt="" loading="lazy" />
|
||||||
|
)}
|
||||||
|
<input ref={avatarInputRef} type="file" hidden accept="image/*,video/*" onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) void uploadFile(file, 'avatar');
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
}} />
|
||||||
|
<div className="digital-human-upload-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => avatarInputRef.current?.click()} disabled={uploading === 'avatar'}>
|
||||||
|
{uploading === 'avatar' ? '上传中...' : '上传人物素材'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void openAssetPicker('avatar')}>工作空间</button>
|
||||||
|
</div>
|
||||||
|
<input value={avatarUrl} onChange={(event) => setAvatarUrl(event.target.value)} placeholder="或粘贴图片/视频 URL" />
|
||||||
|
</div>
|
||||||
|
<div className="digital-human-upload-card">
|
||||||
|
<strong>参考音频</strong>
|
||||||
|
<span>{referenceAudioUrl ? '已准备音频' : '用于口型驱动或音色参考'}</span>
|
||||||
|
{referenceAudioUrl && <audio className="digital-human-audio-preview" src={referenceAudioUrl} controls />}
|
||||||
|
<input ref={audioInputRef} type="file" hidden accept="audio/*" onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) void uploadFile(file, 'audio');
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
}} />
|
||||||
|
<div className="digital-human-upload-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => audioInputRef.current?.click()} disabled={uploading === 'audio'}>
|
||||||
|
{uploading === 'audio' ? '上传中...' : '上传参考音频'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void openAssetPicker('audio')}>工作空间</button>
|
||||||
|
</div>
|
||||||
|
<input value={referenceAudioUrl} onChange={(event) => setReferenceAudioUrl(event.target.value)} placeholder="或粘贴音频 URL" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={formGridStyle}>
|
<h2>3. 商品文案</h2>
|
||||||
<label style={fieldStyle}>
|
<label className="digital-human-field">
|
||||||
<span>模型</span>
|
<span>讲解文案</span>
|
||||||
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
|
<textarea value={productScript} onChange={(event) => setProductScript(event.target.value)} rows={6} />
|
||||||
<datalist id="digital-human-models">
|
</label>
|
||||||
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
|
<label className="digital-human-field">
|
||||||
</datalist>
|
<span>画面提示</span>
|
||||||
|
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={3} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="digital-human-form-grid">
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>参考音频用途</span>
|
||||||
|
<select value={audioRole} onChange={(event) => setAudioRole(event.target.value as AudioRole)}>
|
||||||
|
{AUDIO_ROLES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style={fieldStyle}>
|
<label className="digital-human-field">
|
||||||
<span>画幅</span>
|
<span>画幅</span>
|
||||||
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
|
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
|
||||||
<option value="9:16">9:16 竖屏</option>
|
<option value="9:16">9:16 竖屏</option>
|
||||||
<option value="16:9">16:9 横屏</option>
|
<option value="16:9">16:9 横屏</option>
|
||||||
<option value="1:1">1:1 方屏</option>
|
<option value="1:1">1:1 方屏</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style={fieldStyle}>
|
<label className="digital-human-field">
|
||||||
<span>分辨率</span>
|
<span>分辨率</span>
|
||||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
|
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
|
||||||
<option value="720p">720p</option>
|
<option value="720p">720p</option>
|
||||||
<option value="1080p">1080p</option>
|
<option value="1080p">1080p</option>
|
||||||
<option value="480p">480p</option>
|
<option value="480p">480p</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style={fieldStyle}>
|
<label className="digital-human-checkbox">
|
||||||
<span>时长(秒)</span>
|
<input type="checkbox" checked={subtitles} onChange={(event) => setSubtitles(event.target.checked)} />
|
||||||
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
|
<span>生成字幕</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>人物/场景描述</span>
|
|
||||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
|
|
||||||
</label>
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>台词/脚本</span>
|
|
||||||
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style={formGridStyle}>
|
|
||||||
{mode !== 'text' && (
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>人物图 URL</span>
|
|
||||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{mode === 'audio' && (
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>音频 URL</span>
|
|
||||||
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>口播风格</span>
|
|
||||||
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
|
|
||||||
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>ref_img_index</span>
|
|
||||||
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
|
|
||||||
</label>
|
|
||||||
<label style={fieldStyle}>
|
|
||||||
<span>audio CFG</span>
|
|
||||||
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={toggleRowStyle}>
|
|
||||||
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
|
|
||||||
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside style={panelStyle}>
|
<section className="digital-human-panel">
|
||||||
<h2 style={sideTitleStyle}>任务请求</h2>
|
<div className="digital-human-section-head">
|
||||||
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
|
<div>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
|
<h2>节点映射</h2>
|
||||||
|
<p>把页面输入写入对应 Comfy 节点。字段名可按模板节点输入口微调。</p>
|
||||||
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
|
</div>
|
||||||
{result ? (
|
<button type="button" className="btn btn-ghost" onClick={inferMapping} disabled={!selectedWorkflow}>重新猜测</button>
|
||||||
<>
|
</div>
|
||||||
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
|
<div className="digital-human-mapping-grid">
|
||||||
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
|
{([
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
|
['prompt', '提示词节点'],
|
||||||
</>
|
['script', '文案节点'],
|
||||||
) : (
|
['avatar', '人物素材节点'],
|
||||||
<div className="empty-state" style={{ minHeight: 160 }}>
|
['referenceAudio', '参考音频节点'],
|
||||||
<div className="empty-state-title">暂无任务</div>
|
['voiceReference', '音色参考节点(可选)'],
|
||||||
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
|
['output', '输出视频节点'],
|
||||||
|
] as Array<[keyof typeof DEFAULT_MAPPING, string]>).map(([key, label]) => {
|
||||||
|
const item = mapped[key];
|
||||||
|
return (
|
||||||
|
<div key={key} className="digital-human-mapping-row">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input value={item.nodeId} onChange={(event) => updateMapping(key, 'nodeId', event.target.value)} placeholder="Node ID" />
|
||||||
|
<input value={item.field} onChange={(event) => updateMapping(key, 'field', event.target.value)} placeholder="字段名" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{missingMapping.length > 0 && (
|
||||||
|
<div className="alert alert-warning" style={{ marginTop: 12 }}>
|
||||||
|
缺少映射:{missingMapping.join('、')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
|
||||||
|
<h2>任务队列</h2>
|
||||||
|
<div className="digital-human-job-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={clear}>清空已完成</button>
|
||||||
|
</div>
|
||||||
|
<div 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,6 +18,7 @@ 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,
|
||||||
@@ -44,6 +45,22 @@ 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>;
|
||||||
@@ -1087,6 +1104,7 @@ 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>
|
||||||
@@ -1192,7 +1210,16 @@ 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 label="额度" value={`${row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()} / ${row.maxCost == null ? '成本不限' : formatMoney(row.maxCost)}`} stacked />
|
<MobileField
|
||||||
|
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>
|
||||||
@@ -1209,7 +1236,7 @@ export default function DownstreamKeys() {
|
|||||||
</th>
|
</th>
|
||||||
<th>密钥信息</th>
|
<th>密钥信息</th>
|
||||||
<th>授权范围</th>
|
<th>授权范围</th>
|
||||||
<th style={{ textAlign: 'right' }}>额度</th>
|
<th style={{ minWidth: 220 }}>计费额度</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>
|
||||||
@@ -1251,10 +1278,12 @@ 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={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
<td style={{ fontVariantNumeric: 'tabular-nums', minWidth: 220 }}>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()}</div>
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>{row.maxCost == null ? '成本不限' : `成本 ${formatMoney(row.maxCost)}`}</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.expiresAt ? `到期 ${formatIso(row.expiresAt)}` : '永久有效'}</div>
|
<BillingLimitBar label="成本" used={row.usedCost || 0} max={row.maxCost} formatter={formatMoney} />
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
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 generatedComfyPromptFromForm(model: string, ratio: string, resolution: string, duration: number) {
|
||||||
|
return {
|
||||||
|
'1': {
|
||||||
|
class_type: 'BoosAPITextToVideo',
|
||||||
|
inputs: {
|
||||||
|
model,
|
||||||
|
prompt: '',
|
||||||
|
ratio,
|
||||||
|
resolution,
|
||||||
|
duration,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
class_type: 'output.preview_video',
|
||||||
|
inputs: {
|
||||||
|
video: ['1', 0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedComfyMapping() {
|
||||||
|
return {
|
||||||
|
prompt: { nodeId: '1', field: 'prompt' },
|
||||||
|
ratio: { nodeId: '1', field: 'ratio' },
|
||||||
|
resolution: { nodeId: '1', field: 'resolution' },
|
||||||
|
duration: { nodeId: '1', field: 'duration' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<'comfy' | 'boosapi'>('comfy');
|
||||||
|
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 : [];
|
||||||
|
setWorkflows(items);
|
||||||
|
setTemplateId((current) => current || items.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.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 (videoBackend !== 'comfy' || useAdvancedComfy) return;
|
||||||
|
setComfyPrompt(JSON.stringify(generatedComfyPromptFromForm(videoModel.trim(), ratio, resolution, duration), null, 2));
|
||||||
|
setComfyNodeMapping(JSON.stringify(generatedComfyMapping(), null, 2));
|
||||||
|
}, [videoBackend, useAdvancedComfy, videoModel, ratio, resolution, duration]);
|
||||||
|
|
||||||
|
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: useAdvancedComfy ? templateId || undefined : 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="comfy">Comfy API</option>
|
||||||
|
<option value="boosapi">BoosAPI /v1/videos</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>{useAdvancedComfy ? '使用保存过的工作流模板,会自动带入 Comfy API prompt 和节点映射。' : '已根据上方表单自动生成 Comfy prompt,生成时会注入 AI 改写后的视频提示词。'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setUseAdvancedComfy((value) => !value)}>
|
||||||
|
{useAdvancedComfy ? '使用自动生成' : '高级模板'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{useAdvancedComfy && (
|
||||||
|
<>
|
||||||
|
<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}>
|
||||||
|
{workflow.name} · {templateLabel(workflow)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectedWorkflow && (
|
||||||
|
<div className="money-printer-template-meta">
|
||||||
|
<strong>{selectedWorkflow.name}</strong>
|
||||||
|
<span>{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '这个模板没有内置节点映射,系统会尝试自动推断。'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!useAdvancedComfy && (
|
||||||
|
<div className="money-printer-template-meta">
|
||||||
|
<strong>自动生成 Comfy prompt</strong>
|
||||||
|
<span>模型 {videoModel || '未填写'} / {ratio} / {resolution} / {duration} 秒</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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)' }}>
|
||||||
在 BoosAPI 内查看外部站点监控页面。
|
在 BossAI 内查看外部站点监控页面。
|
||||||
</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(
|
||||||
'本地部署',
|
'本地部署',
|
||||||
'BoosAPI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
'BossAI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||||||
<div className="oauth-guide-copy">
|
<div className="oauth-guide-copy">
|
||||||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BoosAPI。
|
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BossAI。
|
||||||
</div>,
|
</div>,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSession.instructions.sshTunnelCommand
|
{activeSession.instructions.sshTunnelCommand
|
||||||
? renderGuideCard(
|
? renderGuideCard(
|
||||||
'云端部署',
|
'云端部署',
|
||||||
'BoosAPI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
'BossAI 部署在 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)}
|
||||||
|
|||||||
+137
-79
@@ -57,7 +57,7 @@ type PromptTemplate = {
|
|||||||
title: string;
|
title: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
size: string;
|
size: string;
|
||||||
image: string;
|
image?: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
source?: {
|
source?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -76,7 +76,6 @@ 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 = [
|
||||||
@@ -106,6 +105,50 @@ 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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const FALLBACK_TEMPLATES: PromptTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'local-product-glass-robot',
|
||||||
|
title: '玻璃机器人产品照',
|
||||||
|
tag: '商业',
|
||||||
|
size: '1024x1024',
|
||||||
|
prompt: 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background, controlled studio lighting, premium advertising composition, ultra detailed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-hanfu-cat-golden-hour',
|
||||||
|
title: '汉服与白猫',
|
||||||
|
tag: '国风',
|
||||||
|
size: '1024x1536',
|
||||||
|
prompt: 'A breathtakingly beautiful ancient Chinese woman in flowing jade-green and blush pink hanfu, golden peony embroidery, sitting by a moon-shaped window in warm afternoon sunlight, gently teasing a fluffy white cat with a silk tassel, celadon tea set and ink calligraphy nearby, serene romantic dreamlike Song Dynasty aesthetics, cinematic depth of field, ultra detailed digital art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-cyberpunk-street',
|
||||||
|
title: '赛博街景海报',
|
||||||
|
tag: '海报',
|
||||||
|
size: '1536x1024',
|
||||||
|
prompt: 'A cinematic cyberpunk street market after rain, neon signs reflected on wet pavement, a courier in a translucent raincoat walking through steam, layered storefronts and holographic menus, dramatic teal and amber lighting, editorial poster composition, high detail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-character-turnaround',
|
||||||
|
title: '角色四视图',
|
||||||
|
tag: '角色',
|
||||||
|
size: '1536x1024',
|
||||||
|
prompt: 'Character design sheet, same original fantasy explorer shown in front view, side view, back view and three-quarter view, consistent outfit and proportions, clean light background, readable silhouette, detailed fabric and accessories, professional concept art',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-food-photography',
|
||||||
|
title: '高级餐饮摄影',
|
||||||
|
tag: '商业',
|
||||||
|
size: '1024x1024',
|
||||||
|
prompt: 'Premium food photography of a porcelain bowl of hand-pulled noodles with chili oil, scallions and steam, dark stone tabletop, soft side lighting, shallow depth of field, appetizing texture, refined restaurant campaign style',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-tea-room',
|
||||||
|
title: '现代茶室空间',
|
||||||
|
tag: '空间',
|
||||||
|
size: '1536x1024',
|
||||||
|
prompt: 'A quiet contemporary Chinese tea room interior with warm timber lattice, low stone tea table, linen cushions, bamboo courtyard visible through sliding doors, natural morning light, calm luxury hospitality design, realistic architectural visualization',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function makeId() {
|
function makeId() {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
@@ -282,39 +325,6 @@ 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';
|
||||||
@@ -365,6 +375,13 @@ 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);
|
||||||
@@ -406,53 +423,84 @@ export default function PictureAgent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBootError('');
|
setBootError('');
|
||||||
setTemplatesLoading(true);
|
setStatus((current) => current || {
|
||||||
|
success: true,
|
||||||
|
mode: 'local',
|
||||||
|
upstreamLabel: 'BossAI 本地图片路由',
|
||||||
|
keyName: '加载密钥中',
|
||||||
|
});
|
||||||
|
setModel((current) => current || 'gpt-image-2');
|
||||||
|
setTemplates(FALLBACK_TEMPLATES);
|
||||||
setTemplatesError('');
|
setTemplatesError('');
|
||||||
|
setTemplatesLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statusRes, modelsRes, templatesRes, jobsRes] = await Promise.all([
|
const [statusResult, modelsResult, jobsResult] = await Promise.allSettled([
|
||||||
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
|
authedFetch('/api/picture-agent/status', { timeoutMs: 8_000 }),
|
||||||
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
|
authedFetch('/api/picture-agent/models', { timeoutMs: 8_000 }),
|
||||||
authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 30_000 }),
|
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 8_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)
|
|
||||||
? nextModels.models.filter((item: string) => isPictureModel(item))
|
if (statusResult.status === 'fulfilled') {
|
||||||
: [];
|
if (!statusResult.value.ok) {
|
||||||
setStatus(nextStatus);
|
throw new Error(await parseError(statusResult.value));
|
||||||
setModels(availableModels);
|
}
|
||||||
setTemplates(nextTemplates);
|
setStatus(await statusResult.value.json());
|
||||||
setJobs(nextJobs);
|
} else {
|
||||||
hydrateImagesFromJobs(nextJobs);
|
throw new Error(statusResult.reason?.message || '生图助手状态加载失败');
|
||||||
setModel((current) => {
|
}
|
||||||
if (current && isPictureModel(current)) return current;
|
|
||||||
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
|
if (modelsResult.status === 'fulfilled') {
|
||||||
? nextModels.defaultModel
|
if (!modelsResult.value.ok) {
|
||||||
: availableModels[0];
|
throw new Error(await parseError(modelsResult.value));
|
||||||
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(templatePayload?.fallback && templatePayload?.remote?.message
|
||||||
|
? String(templatePayload.remote.message)
|
||||||
|
: '');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!ignore) setTemplatesError(err?.message || '远程模板加载失败,已使用本地模板');
|
||||||
} finally {
|
} finally {
|
||||||
if (!ignore) setTemplatesLoading(false);
|
if (!ignore) setTemplatesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -720,7 +768,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 || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
{modeLabel} · {status?.upstreamLabel || 'BossAI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
@@ -856,6 +904,9 @@ 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>
|
||||||
@@ -872,7 +923,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>{templatesLoading ? '正在刷新模板...' : '每次打开自动获取 Linux Do GPT Image Gallery'}</span>
|
<span>{templatesLoading ? '正在刷新模板...' : '内置模板优先,外部图库可用时自动补充'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="picture-agent-template-list">
|
<div className="picture-agent-template-list">
|
||||||
{templatesError && templates.length === 0 ? (
|
{templatesError && templates.length === 0 ? (
|
||||||
@@ -887,7 +938,14 @@ export default function PictureAgent() {
|
|||||||
onClick={() => applyTemplate(template)}
|
onClick={() => applyTemplate(template)}
|
||||||
title={template.prompt}
|
title={template.prompt}
|
||||||
>
|
>
|
||||||
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
|
{template.image ? (
|
||||||
|
<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 优先。只有这里开启后,BoosAPI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
默认采用 HTTP 优先。只有这里开启后,BossAI 才会在 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)' }}>允许 BoosAPI 到 Codex 上游使用 WebSocket</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BossAI 到 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}>
|
||||||
默认关闭。开启后,BoosAPI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
默认关闭。开启后,BossAI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/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)' }}>允许 BoosAPI 后台主动批量测活</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BossAI 后台主动批量测活</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 }}>
|
||||||
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
|
重新初始化系统会清空当前 BossAI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BossAI 数据,然后切回默认 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: BoosAPI 站点管理页
|
* @Project_description: BossAI 站点管理页
|
||||||
* @Description: 代码是我抄的,不会也是真的
|
* @Description: 代码是我抄的,不会也是真的
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|||||||
@@ -410,7 +410,14 @@ 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;
|
||||||
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
|
const reusedRoutes = Number(res?.rebuild?.reusedRoutes ?? 0);
|
||||||
|
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="例如 BoosAPI"
|
placeholder="例如 BossAI"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+129
-99
@@ -29,12 +29,32 @@ 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;
|
latency_ms?: number | null;
|
||||||
|
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);
|
||||||
@@ -57,44 +77,23 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const profileRes = await userApi.me();
|
const dashboardRes: any = await userApi.getDashboard();
|
||||||
setProfile(profileRes.user);
|
setProfile(dashboardRes.user || null);
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
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([]);
|
setKeys([]);
|
||||||
}
|
|
||||||
|
|
||||||
if (summaryRes.status === 'fulfilled') {
|
|
||||||
setSummary(summaryRes.value as UserSummary);
|
|
||||||
} else {
|
|
||||||
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
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([]);
|
setRecentLogs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}, [checkAuth, navigate]);
|
}, [checkAuth, navigate]);
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
@@ -107,16 +106,26 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', minHeight: '400px' }}>
|
<div className="user-dashboard-page">
|
||||||
<div className="spinner" />
|
<div className="user-dashboard-skeleton">
|
||||||
|
<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="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
<div className="user-dashboard-page">
|
||||||
<div className="card" style={{ padding: 20, color: 'var(--color-danger)' }}>
|
<div className="alert alert-error">
|
||||||
{error || '用户信息加载失败'}
|
{error || '用户信息加载失败'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,69 +135,90 @@ 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="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
<div className="user-dashboard-page animate-fade-in">
|
||||||
<header style={{ marginBottom: '24px' }}>
|
<header className="user-dashboard-hero">
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px 0' }}>你好, {profile.username}</h1>
|
<div className="user-dashboard-identity">
|
||||||
<p style={{ color: 'var(--color-text-muted)', margin: 0 }}>欢迎回到 BoosAPI 用户中心。</p>
|
<div className="user-dashboard-avatar">{userInitial}</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<div className="user-dashboard-stat-grid">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '32px' }}>
|
<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">{summary?.totalRequests || 0}</div>
|
||||||
<div style={{ fontSize: '28px', fontWeight: 700 }}>{summary?.totalRequests || 0}</div>
|
<div className="user-dashboard-stat-note">{failedRequests} 次失败</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ padding: '20px' }}>
|
<div className="card user-dashboard-stat">
|
||||||
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>请求成功率</div>
|
<div className="user-dashboard-stat-label">请求成功率</div>
|
||||||
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--color-success)' }}>{successRate}%</div>
|
<div className="user-dashboard-stat-value is-success">{successRate}%</div>
|
||||||
|
<div className="user-dashboard-stat-note">{summary?.successRequests || 0} 次成功</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ padding: '20px' }}>
|
<div className="card user-dashboard-stat">
|
||||||
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总消耗金额</div>
|
<div className="user-dashboard-stat-label">总消耗金额</div>
|
||||||
<div style={{ fontSize: '28px', fontWeight: 700 }}>${summary?.totalCost ? summary.totalCost.toFixed(4) : '0.0000'}</div>
|
<div className="user-dashboard-stat-value">{formatMoney(summary?.totalCost)}</div>
|
||||||
|
<div className="user-dashboard-stat-note">{activeKeyCount}/{keys.length} 个密钥启用</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
|
<div className="user-dashboard-grid">
|
||||||
{/* Left Column: Recent Logs & Quick Entries */}
|
<div className="user-dashboard-column">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
<section className="card user-dashboard-panel">
|
||||||
{/* Quick Entries */}
|
<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: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
<div className="user-dashboard-entry-grid">
|
||||||
<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' }}>
|
<Link to="/user/workflows" 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 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="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
<Link to="/user/comfyui-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="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>
|
||||||
|
|
||||||
{/* Recent Records */}
|
<section className="card user-dashboard-panel">
|
||||||
<section className="card" style={{ padding: '20px' }}>
|
<div className="user-dashboard-panel-head">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
<h2>最近记录</h2>
|
||||||
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>最近记录</h2>
|
<Link to="/user/usage" className="btn btn-link btn-link-primary">查看全部</Link>
|
||||||
<Link to="/user/usage" style={{ fontSize: '13px', color: 'var(--color-primary)', textDecoration: 'none' }}>查看全部</Link>
|
|
||||||
</div>
|
</div>
|
||||||
{recentLogs.length === 0 ? (
|
{recentLogs.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>暂无记录</div>
|
<div className="user-dashboard-empty">暂无记录</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<div className="user-dashboard-list">
|
||||||
{recentLogs.map(log => (
|
{recentLogs.map(log => (
|
||||||
<div key={log.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '10px', borderBottom: '1px solid var(--color-border-light)' }}>
|
<div key={log.id} className="user-dashboard-row">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '14px', fontWeight: 500 }}>{log.model_requested}</div>
|
<div className="user-dashboard-row-title">{log.model_actual || log.model_requested || '未知模型'}</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{new Date(log.created_at).toLocaleString()}</div>
|
<div className="user-dashboard-row-meta">{formatDateLabel(log.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div className="user-dashboard-row-side">
|
||||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
|
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}>
|
||||||
{log.status === 'success' ? '成功' : '失败'}
|
{log.status === 'success' ? '成功' : '失败'}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', marginTop: '2px' }}>{log.latency_ms}ms</div>
|
<div>{formatLatency(log.latency_ms)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -197,59 +227,59 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: API Keys */}
|
<div className="user-dashboard-column">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
<section className="card user-dashboard-panel">
|
||||||
<section className="card" style={{ padding: '20px' }}>
|
<div className="user-dashboard-panel-head">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
<h2>API 密钥</h2>
|
||||||
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>API 密钥</h2>
|
<button className="btn btn-primary" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<div className="user-dashboard-list">
|
||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>尚未创建密钥</div>
|
<div className="user-dashboard-empty">尚未创建密钥</div>
|
||||||
) : (
|
) : (
|
||||||
keys.slice(0, 3).map(key => (
|
keys.slice(0, 3).map(key => (
|
||||||
<div key={key.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', borderRadius: '8px', background: 'var(--color-bg)' }}>
|
<div key={key.id} className="user-dashboard-key-row">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '14px', fontWeight: 500 }}>{key.name}</div>
|
<div className="user-dashboard-row-title">{key.name}</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
|
<div className="user-dashboard-key-value">
|
||||||
{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'}`} style={{ fontSize: '10px' }}>
|
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`}>
|
||||||
{key.enabled ? '启用' : '禁用'}
|
{key.enabled ? '启用' : '禁用'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{keys.length > 3 && (
|
{keys.length > 3 && (
|
||||||
<div style={{ textAlign: 'center', fontSize: '12px', color: 'var(--color-text-muted)' }}>
|
<div className="user-dashboard-more">
|
||||||
还有 {keys.length - 3} 个密钥...
|
还有 {keys.length - 3} 个密钥
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Account Info */}
|
<section className="card user-dashboard-panel">
|
||||||
<section className="card" style={{ padding: '20px' }}>
|
<div className="user-dashboard-panel-head">
|
||||||
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>账号信息</h2>
|
<h2>账号信息</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div className="user-dashboard-profile-list">
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>邮箱</span>
|
<div>
|
||||||
|
<span>邮箱</span>
|
||||||
<span>{profile.email}</span>
|
<span>{profile.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div>
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>账号角色</span>
|
<span>账号角色</span>
|
||||||
<span className="badge badge-success">{profile.role === 'admin' ? '管理员' : '普通用户'}</span>
|
<span className="badge badge-success">{profile.role === 'admin' ? '管理员' : '普通用户'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div>
|
||||||
<span style={{ color: 'var(--color-text-muted)' }}>注册时间</span>
|
<span>注册时间</span>
|
||||||
<span>{new Date(profile.createdAt).toLocaleDateString()}</span>
|
<span>{new Date(profile.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
|
<div className="user-dashboard-profile-actions">
|
||||||
<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" onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
|
||||||
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={handleLogout}>退出登录</button>
|
<button className="btn btn-ghost btn-link-danger" onClick={handleLogout}>退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+61
-75
@@ -9,9 +9,11 @@ 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 [isCloud, setIsCloud] = React.useState(false);
|
const [backendLabel, setBackendLabel] = React.useState('');
|
||||||
|
const [reloadKey, setReloadKey] = React.useState(0);
|
||||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
const openFullscreen = () => {
|
const openFullscreen = () => {
|
||||||
@@ -20,8 +22,13 @@ 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(() => {
|
||||||
@@ -30,57 +37,71 @@ 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;
|
||||||
setIsCloud(cloudMode);
|
setBackendLabel(resolveBackendLabel(runtime.mode));
|
||||||
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 style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
<div className="workflow-shell">
|
||||||
<div style={toolbarStyle}>
|
<div className="workflow-toolbar">
|
||||||
<div style={toolbarTitleStyle}>工作流</div>
|
<div className="workflow-toolbar-main">
|
||||||
{!isCloud && workflowUrl === DOCKER_WORKFLOW_URL && <div style={cloudBadgeStyle}>Docker 完整版 ComfyUI</div>}
|
<div className="workflow-title">工作流</div>
|
||||||
{isCloud && <div style={cloudBadgeStyle}>Comfy Cloud API 后端</div>}
|
{backendLabel && <div className="workflow-backend-badge">{backendLabel}</div>}
|
||||||
<div style={toolbarActionsStyle}>
|
</div>
|
||||||
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
<div className="workflow-actions">
|
||||||
|
<button type="button" onClick={reloadFrame} disabled={!ready} className="btn btn-link workflow-action-btn">
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
|
<button type="button" onClick={openFullscreen} className="btn btn-primary workflow-primary-btn">
|
||||||
新窗口打开
|
新窗口打开
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<iframe
|
<div className="workflow-frame-wrap">
|
||||||
ref={iframeRef}
|
{loading && (
|
||||||
src={workflowUrl}
|
<div className="workflow-frame-loading" aria-live="polite">
|
||||||
title="Workflow Studio"
|
<div className="workflow-loading-bar" />
|
||||||
allow="clipboard-read; clipboard-write"
|
<span>正在连接工作流前端...</span>
|
||||||
referrerPolicy="no-referrer"
|
</div>
|
||||||
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 style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
|
<div className="workflow-empty">
|
||||||
<div>
|
<div className="workflow-empty-panel">
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
|
<div className="workflow-empty-title">Workflow Studio</div>
|
||||||
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
|
<div className="workflow-empty-desc">{message}</div>
|
||||||
|
<button type="button" onClick={reloadFrame} className="btn btn-soft-primary workflow-empty-btn">
|
||||||
|
重新检查
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -88,54 +109,19 @@ export default function Workflows() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolbarStyle: React.CSSProperties = {
|
function resolveBackendLabel(mode?: string) {
|
||||||
minHeight: 44,
|
switch (mode) {
|
||||||
display: 'flex',
|
case 'cloud':
|
||||||
alignItems: 'center',
|
return 'Comfy Cloud API 后端';
|
||||||
justifyContent: 'space-between',
|
case 'runninghub':
|
||||||
gap: 12,
|
return 'RunningHub API 后端';
|
||||||
padding: '8px 12px',
|
case 'external':
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
return '外部 ComfyUI API 后端';
|
||||||
background: 'var(--color-surface)',
|
case 'docker':
|
||||||
};
|
return 'Docker 完整版 ComfyUI';
|
||||||
|
case 'boosapi':
|
||||||
const cloudBadgeStyle: React.CSSProperties = {
|
return 'BoosAPI 工作流执行器';
|
||||||
marginRight: 'auto',
|
default:
|
||||||
border: '1px solid var(--color-border)',
|
return '';
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
+306
-334
@@ -1,9 +1,13 @@
|
|||||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { 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';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const AUTO_PREVIEW_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||||
|
const AUTO_PREVIEW_COUNT = 12;
|
||||||
|
|
||||||
const SOURCE_OPTIONS = [
|
const SOURCE_OPTIONS = [
|
||||||
{ value: 'all', label: '全部来源' },
|
{ value: 'all', label: '全部来源' },
|
||||||
{ value: 'picture-agent', label: '生图助手' },
|
{ value: 'picture-agent', label: '生图助手' },
|
||||||
@@ -30,6 +34,7 @@ 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 || '未知来源';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +56,23 @@ function assetCreatedAtLabel(asset: WorkspaceAsset) {
|
|||||||
return formatDateTimeLocal(asset.createdAt);
|
return formatDateTimeLocal(asset.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileEndpoint(audience: SessionAudience, publicId: string) {
|
||||||
|
return `${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(publicId)}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAutoPreview(asset: WorkspaceAsset) {
|
||||||
|
return Boolean(
|
||||||
|
asset.hasLocalFile
|
||||||
|
&& asset.kind === 'image'
|
||||||
|
&& Number(asset.sizeBytes || 0) > 0
|
||||||
|
&& Number(asset.sizeBytes || 0) <= AUTO_PREVIEW_LIMIT_BYTES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRenderPreview(asset: WorkspaceAsset) {
|
||||||
|
return asset.kind === 'image' || asset.kind === 'video' || asset.kind === 'audio';
|
||||||
|
}
|
||||||
|
|
||||||
export default function Workspace() {
|
export default function Workspace() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
@@ -62,9 +84,21 @@ export default function Workspace() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
||||||
|
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
|
||||||
|
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 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));
|
||||||
@@ -78,9 +112,14 @@ export default function Workspace() {
|
|||||||
source: source === 'all' ? undefined : source,
|
source: source === 'all' ? undefined : source,
|
||||||
kind: kind === 'all' ? undefined : kind,
|
kind: kind === 'all' ? undefined : kind,
|
||||||
q: query.trim() || undefined,
|
q: query.trim() || undefined,
|
||||||
page: 1,
|
page,
|
||||||
pageSize: 60,
|
pageSize: PAGE_SIZE,
|
||||||
}), [kind, query, source]);
|
}), [kind, page, query, source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}, [kind, query, source]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -89,11 +128,13 @@ export default function Workspace() {
|
|||||||
const response = audience === 'user'
|
const response = audience === 'user'
|
||||||
? await userApi.getWorkspaceAssets(params)
|
? await userApi.getWorkspaceAssets(params)
|
||||||
: await api.getWorkspaceAssets(params);
|
: await api.getWorkspaceAssets(params);
|
||||||
setItems(Array.isArray(response.items) ? response.items : []);
|
const nextItems = Array.isArray(response.items) ? response.items : [];
|
||||||
|
setItems((current) => (params.page && params.page > 1 ? [...current, ...nextItems] : nextItems));
|
||||||
setTotal(Number(response.total || 0));
|
setTotal(Number(response.total || 0));
|
||||||
setSelectedId((current) => {
|
setSelectedId((current) => {
|
||||||
if (current && response.items?.some((item) => item.publicId === current)) return current;
|
if (params.page && params.page > 1 && current) return current;
|
||||||
return response.items?.[0]?.publicId || '';
|
if (current && nextItems.some((item) => item.publicId === current)) return current;
|
||||||
|
return nextItems[0]?.publicId || '';
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '工作空间加载失败');
|
setError(err?.message || '工作空间加载失败');
|
||||||
@@ -106,38 +147,76 @@ export default function Workspace() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
objectUrlsRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const objectUrls: string[] = [];
|
|
||||||
async function hydratePreviews() {
|
async function hydratePreviews() {
|
||||||
const candidates = items
|
const candidates = items
|
||||||
.filter((item) => item.hasLocalFile && (item.kind === 'image' || item.kind === 'video' || item.kind === 'audio'))
|
.slice(0, AUTO_PREVIEW_COUNT)
|
||||||
.slice(0, 24);
|
.filter((item) => canAutoPreview(item) && !previewUrls[item.publicId])
|
||||||
|
.slice(0, AUTO_PREVIEW_COUNT);
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
await Promise.all(candidates.map(async (asset) => {
|
for (const asset of candidates) {
|
||||||
|
if (cancelled) break;
|
||||||
try {
|
try {
|
||||||
const response = await fetchSessionResponse(
|
const response = await fetchSessionResponse(
|
||||||
audience,
|
audience,
|
||||||
`${audience === 'user' ? '/api/user' : '/api'}/workspace/assets/${encodeURIComponent(asset.publicId)}/file`,
|
fileEndpoint(audience, asset.publicId),
|
||||||
{ timeoutMs: 30_000 },
|
{ timeoutMs: 15_000 },
|
||||||
);
|
);
|
||||||
if (!response.ok) return;
|
if (!response.ok) continue;
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (cancelled) break;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
objectUrls.push(url);
|
objectUrlsRef.current.add(url);
|
||||||
next[asset.publicId] = url;
|
next[asset.publicId] = url;
|
||||||
} catch {
|
} catch {
|
||||||
// Preview hydration is best-effort; the asset row still remains usable.
|
// Preview hydration is best-effort; the asset row still remains usable.
|
||||||
}
|
}
|
||||||
}));
|
}
|
||||||
if (!cancelled) setPreviewUrls(next);
|
if (!cancelled && Object.keys(next).length > 0) {
|
||||||
|
setPreviewUrls((current) => ({ ...current, ...next }));
|
||||||
|
setLoadedLocalPreviews((current) => new Set([...current, ...Object.keys(next)]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void hydratePreviews();
|
void hydratePreviews();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
||||||
};
|
};
|
||||||
}, [audience, items]);
|
}, [audience, items, previewUrls]);
|
||||||
|
|
||||||
|
const loadPreview = useCallback(async (asset: WorkspaceAsset) => {
|
||||||
|
if (!asset.hasLocalFile || !canRenderPreview(asset)) return;
|
||||||
|
setPreviewLoading((current) => ({ ...current, [asset.publicId]: true }));
|
||||||
|
try {
|
||||||
|
const response = await fetchSessionResponse(
|
||||||
|
audience,
|
||||||
|
fileEndpoint(audience, asset.publicId),
|
||||||
|
{ timeoutMs: 120_000 },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('预览加载失败');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const oldUrl = previewUrls[asset.publicId];
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
objectUrlsRef.current.add(url);
|
||||||
|
setPreviewUrls((current) => ({ ...current, [asset.publicId]: url }));
|
||||||
|
setLoadedLocalPreviews((current) => new Set(current).add(asset.publicId));
|
||||||
|
if (oldUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(oldUrl);
|
||||||
|
objectUrlsRef.current.delete(oldUrl);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '预览加载失败');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading((current) => ({ ...current, [asset.publicId]: false }));
|
||||||
|
}
|
||||||
|
}, [audience, previewUrls]);
|
||||||
|
|
||||||
const handleDelete = async (asset: WorkspaceAsset) => {
|
const handleDelete = async (asset: WorkspaceAsset) => {
|
||||||
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
|
if (!window.confirm(`删除「${assetTitle(asset)}」?`)) return;
|
||||||
@@ -147,6 +226,7 @@ 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 || '删除失败');
|
||||||
@@ -154,25 +234,108 @@ export default function Workspace() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (asset: WorkspaceAsset) => {
|
const handleDownload = async (asset: WorkspaceAsset) => {
|
||||||
const previewUrl = previewUrls[asset.publicId];
|
try {
|
||||||
const href = previewUrl || asset.remoteUrl || '';
|
let href = asset.remoteUrl || '';
|
||||||
if (!href) return;
|
if (asset.hasLocalFile) {
|
||||||
const link = document.createElement('a');
|
const response = await fetchSessionResponse(
|
||||||
link.href = href;
|
audience,
|
||||||
link.download = asset.fileName || `${asset.publicId}`;
|
fileEndpoint(audience, asset.publicId),
|
||||||
link.rel = 'noopener';
|
{ timeoutMs: 180_000 },
|
||||||
document.body.appendChild(link);
|
);
|
||||||
link.click();
|
if (!response.ok) throw new Error('下载失败');
|
||||||
link.remove();
|
const blob = await response.blob();
|
||||||
|
href = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
if (!href) return;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = href;
|
||||||
|
link.download = asset.fileName || `${asset.publicId}`;
|
||||||
|
link.rel = 'noopener';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
if (href.startsWith('blob:')) {
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(href), 30_000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '下载失败');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||||
|
const isLargeLocal = Boolean(asset.hasLocalFile && Number(asset.sizeBytes || 0) > AUTO_PREVIEW_LIMIT_BYTES);
|
||||||
|
if (asset.kind === 'image' && preview) {
|
||||||
|
return <img src={preview} alt="" className="workspace-media" loading="lazy" />;
|
||||||
|
}
|
||||||
|
if (asset.kind === 'video' && preview) {
|
||||||
|
return <video src={preview} className="workspace-media" muted controls={!compact} preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (asset.kind === 'audio' && preview) {
|
||||||
|
return compact ? <div className="workspace-placeholder">音频</div> : <audio src={preview} controls className="workspace-audio" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="workspace-placeholder">
|
||||||
|
<span>{kindLabel(asset.kind)}</span>
|
||||||
|
{asset.hasLocalFile && canRenderPreview(asset) ? (
|
||||||
|
<span className="workspace-placeholder-hint">
|
||||||
|
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="workspace-page animate-fade-in">
|
||||||
<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' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
|
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'},只归档最终产出。工作流上传素材和预置模板资源不会进入这里。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
@@ -182,52 +345,52 @@ export default function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={summaryStyle}>
|
<div className="workspace-summary">
|
||||||
<div style={summaryItemStyle}>
|
<div className="workspace-summary-item">
|
||||||
<span style={summaryLabelStyle}>当前结果</span>
|
<span className="workspace-summary-label">当前结果</span>
|
||||||
<strong style={summaryValueStyle}>{total}</strong>
|
<strong className="workspace-summary-value">{total}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={summaryItemStyle}>
|
<div className="workspace-summary-item">
|
||||||
<span style={summaryLabelStyle}>本地文件</span>
|
<span className="workspace-summary-label">本地文件</span>
|
||||||
<strong style={summaryValueStyle}>{localCount}</strong>
|
<strong className="workspace-summary-value">{localCount}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={summaryItemStyle}>
|
<div className="workspace-summary-item">
|
||||||
<span style={summaryLabelStyle}>主要来源</span>
|
<span className="workspace-summary-label">主要来源</span>
|
||||||
<strong style={{ ...summaryValueStyle, fontSize: 14 }}>
|
<strong className="workspace-summary-value workspace-summary-value-small">
|
||||||
{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 style={layoutStyle}>
|
<div className="workspace-layout">
|
||||||
<section>
|
<section>
|
||||||
<div style={toolbarStyle}>
|
<div className="workspace-toolbar">
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="搜索标题、提示词、模型或来源"
|
placeholder="搜索标题、提示词、模型或来源"
|
||||||
style={inputStyle}
|
className="workspace-search"
|
||||||
/>
|
/>
|
||||||
<div style={filterRowStyle}>
|
<div className="workspace-filter-row">
|
||||||
{SOURCE_OPTIONS.map((option) => (
|
{SOURCE_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
style={source === option.value ? activeChipStyle : chipStyle}
|
className={`workspace-chip ${source === option.value ? 'is-active' : ''}`}
|
||||||
onClick={() => setSource(option.value)}
|
onClick={() => setSource(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={filterRowStyle}>
|
<div className="workspace-filter-row">
|
||||||
{KIND_OPTIONS.map((option) => (
|
{KIND_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
style={kind === option.value ? activeChipStyle : chipStyle}
|
className={`workspace-chip ${kind === option.value ? 'is-active' : ''}`}
|
||||||
onClick={() => setKind(option.value)}
|
onClick={() => setKind(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -236,7 +399,32 @@ export default function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={gridStyle}>
|
{items.length > 0 && (
|
||||||
|
<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>
|
||||||
@@ -245,78 +433,102 @@ 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 (
|
||||||
<button
|
<article
|
||||||
type="button"
|
|
||||||
key={asset.publicId}
|
key={asset.publicId}
|
||||||
style={{
|
className={`workspace-asset-card ${active ? 'is-active' : ''} ${checked ? 'is-selected' : ''}`}
|
||||||
...cardStyle,
|
role="button"
|
||||||
borderColor: active ? 'var(--color-primary)' : 'var(--color-border)',
|
tabIndex={0}
|
||||||
boxShadow: active ? '0 14px 32px color-mix(in srgb, var(--color-primary) 16%, transparent)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => setSelectedId(asset.publicId)}
|
onClick={() => setSelectedId(asset.publicId)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedId(asset.publicId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={thumbStyle}>
|
<div className="workspace-thumb">
|
||||||
{asset.kind === 'image' && preview ? (
|
<label className="workspace-select" onClick={(event) => event.stopPropagation()}>
|
||||||
<img src={preview} alt="" style={mediaStyle} />
|
<input
|
||||||
) : asset.kind === 'video' && preview ? (
|
type="checkbox"
|
||||||
<video src={preview} style={mediaStyle} muted />
|
checked={checked}
|
||||||
) : asset.kind === 'audio' ? (
|
onChange={() => toggleSelectedAsset(asset.publicId)}
|
||||||
<div style={placeholderStyle}>音频</div>
|
aria-label={`选择 ${assetTitle(asset)}`}
|
||||||
) : (
|
/>
|
||||||
<div style={placeholderStyle}>{kindLabel(asset.kind)}</div>
|
<span className="workspace-select-mark" aria-hidden="true">✓</span>
|
||||||
|
</label>
|
||||||
|
{renderPreview(asset, true)}
|
||||||
|
<span className="workspace-type-badge">{kindLabel(asset.kind)}</span>
|
||||||
|
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
|
||||||
|
<span className="workspace-preview-badge">{formatBytes(asset.sizeBytes)}</span>
|
||||||
)}
|
)}
|
||||||
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={cardBodyStyle}>
|
<div className="workspace-card-body">
|
||||||
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
<strong className="workspace-card-title">{assetTitle(asset)}</strong>
|
||||||
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
|
<span className="workspace-meta">{sourceLabel(asset.source)} / {asset.model || '未记录模型'}</span>
|
||||||
<div style={cardFooterStyle}>
|
<div className="workspace-card-footer">
|
||||||
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
|
<span className="workspace-status-pill">{statusLabel(asset.status)}</span>
|
||||||
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
|
<span className="workspace-meta">{assetCreatedAtLabel(asset)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="workspace-load-more">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setPage((current) => current + 1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '加载中...' : `加载更多(${items.length}/${total})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside style={detailStyle}>
|
<aside className="workspace-detail">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<div style={detailHeaderStyle}>
|
<div className="workspace-detail-header">
|
||||||
<div>
|
<div>
|
||||||
<div style={detailEyebrowStyle}>{sourceLabel(selected.source)} · {kindLabel(selected.kind)}</div>
|
<div className="workspace-detail-kicker">{sourceLabel(selected.source)} / {kindLabel(selected.kind)}</div>
|
||||||
<h2 style={{ margin: '6px 0 0', fontSize: 18 }}>{assetTitle(selected)}</h2>
|
<h2 className="workspace-detail-title">{assetTitle(selected)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
|
<span className="workspace-status-pill">{statusLabel(selected.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={detailPreviewStyle}>
|
<div className="workspace-detail-preview">
|
||||||
{selected.kind === 'image' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
|
{renderPreview(selected)}
|
||||||
<img src={previewUrls[selected.publicId] || selected.remoteUrl || ''} alt="" style={mediaStyle} />
|
</div>
|
||||||
) : selected.kind === 'video' && (previewUrls[selected.publicId] || selected.remoteUrl) ? (
|
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
|
||||||
<video src={previewUrls[selected.publicId] || selected.remoteUrl || ''} controls style={mediaStyle} />
|
<button
|
||||||
) : selected.kind === 'audio' && previewUrls[selected.publicId] ? (
|
type="button"
|
||||||
<audio src={previewUrls[selected.publicId]} controls style={{ width: '100%' }} />
|
className="btn btn-ghost workspace-preview-load"
|
||||||
) : (
|
onClick={() => void loadPreview(selected)}
|
||||||
<div style={placeholderStyle}>{kindLabel(selected.kind)}</div>
|
disabled={Boolean(previewLoading[selected.publicId])}
|
||||||
|
>
|
||||||
|
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="workspace-detail-meta">
|
||||||
|
{audience !== 'user' && (
|
||||||
|
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div style={detailMetaStyle}>
|
|
||||||
<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 style={promptPanelStyle}>
|
<div className="workspace-prompt-panel">
|
||||||
<span>提示词</span>
|
<span>提示词</span>
|
||||||
<p style={promptStyle}>{selected.prompt}</p>
|
<p className="workspace-prompt">{selected.prompt}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
<div className="workspace-detail-actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void handleDownload(selected)}>
|
<button type="button" className="btn btn-primary" onClick={() => void handleDownload(selected)}>
|
||||||
下载
|
下载
|
||||||
</button>
|
</button>
|
||||||
@@ -336,243 +548,3 @@ 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',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
fontWeight: 700,
|
|
||||||
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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="BoosAPI" className="login-brand-logo" />
|
<img src="/logo.png" alt="BossAI" 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">BoosAPI</div>
|
<div className="login-brand-name">BossAI</div>
|
||||||
<div className="login-brand-kicker">
|
<div className="login-brand-kicker">
|
||||||
{variant === 'user' ? t('用户登录') : t('中转站的中转站')}
|
{variant === 'user' ? t('用户登录') : t('聚合多模型多模态的 AI 应用平台')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,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('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
|
{t('聚合文本、图像、视频、语音等多模态能力,统一接入多模型工作流和 AI 应用。')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-compat-line">
|
<div className="login-compat-line">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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,
|
||||||
@@ -163,31 +164,35 @@ 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', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
<div style={{ display: 'grid', gap: 14, fontSize: 12 }}>
|
||||||
<div>
|
<BillingLimitBar label="请求额度" used={item?.usedRequests || 0} max={item?.maxRequests} formatter={(value) => Math.trunc(value).toLocaleString()} />
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
|
<BillingLimitBar label="成本额度" used={item?.usedCost || 0} max={item?.maxCost} formatter={formatMoney} />
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>主分组</div>
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{item?.groupName || '未分组'}</div>
|
</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,6 +45,19 @@ 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;
|
||||||
@@ -167,3 +180,47 @@ 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 '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
|
return '无法连接到 BossAI 服务端,请检查服务状态或网络连接';
|
||||||
}
|
}
|
||||||
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 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
|
return '这不是 Token 错误判断。请检查 BossAI 服务是否在线,以及目标站点或代理是否可达。';
|
||||||
}
|
}
|
||||||
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 '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
|
return '验证请求未成功完成,请先检查 BossAI 服务、站点网络或代理配置。';
|
||||||
}
|
}
|
||||||
if (isTimeoutFailureMessage(result.message)) {
|
if (isTimeoutFailureMessage(result.message)) {
|
||||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | '
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
|
// Keep this in sync with the Rust site endpoint normalization in admin account routes.
|
||||||
// src/server/services/siteApiEndpointService.ts.
|
|
||||||
function normalizeSiteApiEndpointUrl(raw: string): string {
|
function normalizeSiteApiEndpointUrl(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return '';
|
if (!trimmed) 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 }}>
|
||||||
这是不可逆操作。系统会清空当前 BoosAPI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
这是不可逆操作。系统会清空当前 BossAI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||||
</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,也会先清空该外部库中的 BoosAPI 数据。</div>
|
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BossAI 数据。</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 }}>
|
||||||
开启后,BoosAPI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
开启后,BossAI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||||
</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 }}>
|
||||||
请手动输入以下整句后再开启:
|
请手动输入以下整句后再开启:
|
||||||
|
|||||||
+27
-12
@@ -317,9 +317,7 @@ async fn execute_node(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| inputs.get("image").cloned())
|
.or_else(|| inputs.get("image").cloned())
|
||||||
.or_else(|| inputs.get("IMAGE").cloned())
|
.or_else(|| inputs.get("IMAGE").cloned())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| AppError::BadRequest(format!("{} missing images input", node.id)))?;
|
||||||
AppError::BadRequest(format!("{} missing images input", node.id))
|
|
||||||
})?;
|
|
||||||
Ok(NodeExecution {
|
Ok(NodeExecution {
|
||||||
inputs,
|
inputs,
|
||||||
outputs: json!({ "IMAGE": image, "image": image }),
|
outputs: json!({ "IMAGE": image, "image": image }),
|
||||||
@@ -354,7 +352,12 @@ async fn execute_node(
|
|||||||
.get("file")
|
.get("file")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| inputs.get("image").cloned())
|
.or_else(|| inputs.get("image").cloned())
|
||||||
.or_else(|| inputs.get("images").and_then(|images| images.get(0)).cloned())
|
.or_else(|| {
|
||||||
|
inputs
|
||||||
|
.get("images")
|
||||||
|
.and_then(|images| images.get(0))
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
|
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
|
||||||
Ok(NodeExecution {
|
Ok(NodeExecution {
|
||||||
inputs,
|
inputs,
|
||||||
@@ -643,7 +646,10 @@ async fn video_status(
|
|||||||
|
|
||||||
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
|
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
|
||||||
let mut body = Map::new();
|
let mut body = Map::new();
|
||||||
body.insert("model".to_string(), json!(param_str(node, "model", "seedance-1-0-lite-t2v")));
|
body.insert(
|
||||||
|
"model".to_string(),
|
||||||
|
json!(param_str(node, "model", "seedance-1-0-lite-t2v")),
|
||||||
|
);
|
||||||
body.insert("prompt".to_string(), json!(prompt));
|
body.insert("prompt".to_string(), json!(prompt));
|
||||||
for key in ["ratio", "resolution"] {
|
for key in ["ratio", "resolution"] {
|
||||||
let value = param_str(node, key, "");
|
let value = param_str(node, key, "");
|
||||||
@@ -776,10 +782,7 @@ async fn vae_decode_to_image(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.or_else(|| vae.get("model").and_then(Value::as_str))
|
.or_else(|| vae.get("model").and_then(Value::as_str))
|
||||||
.unwrap_or("gpt-image-2");
|
.unwrap_or("gpt-image-2");
|
||||||
let width = samples
|
let width = samples.get("width").and_then(Value::as_i64).unwrap_or(1024);
|
||||||
.get("width")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.unwrap_or(1024);
|
|
||||||
let height = samples
|
let height = samples
|
||||||
.get("height")
|
.get("height")
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
@@ -895,7 +898,14 @@ async fn get_json(ctx: &RunContext, path: &str) -> AppResult<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_task_id(value: &Value) -> Value {
|
fn extract_task_id(value: &Value) -> Value {
|
||||||
for pointer in ["/id", "/task_id", "/data/id", "/data/task_id", "/result/id", "/result/task_id"] {
|
for pointer in [
|
||||||
|
"/id",
|
||||||
|
"/task_id",
|
||||||
|
"/data/id",
|
||||||
|
"/data/task_id",
|
||||||
|
"/result/id",
|
||||||
|
"/result/task_id",
|
||||||
|
] {
|
||||||
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
|
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
|
||||||
if !id.trim().is_empty() {
|
if !id.trim().is_empty() {
|
||||||
return json!(id);
|
return json!(id);
|
||||||
@@ -927,7 +937,10 @@ fn video_task_is_terminal_failure(value: &Value) -> bool {
|
|||||||
|
|
||||||
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
||||||
let value = value?;
|
let value = value?;
|
||||||
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
find_url_key(
|
||||||
|
value,
|
||||||
|
&["url", "remote_url", "remoteUrl", "image_url", "imageUrl"],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
|
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
@@ -972,7 +985,9 @@ fn find_url_by_extension(value: &Value, extensions: &[&str]) -> Option<String> {
|
|||||||
match value {
|
match value {
|
||||||
Value::String(text)
|
Value::String(text)
|
||||||
if (text.starts_with("http://") || text.starts_with("https://"))
|
if (text.starts_with("http://") || text.starts_with("https://"))
|
||||||
&& extensions.iter().any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
&& extensions
|
||||||
|
.iter()
|
||||||
|
.any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
||||||
{
|
{
|
||||||
Some(text.clone())
|
Some(text.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-7
@@ -13,13 +13,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true
|
||||||
"paths": {
|
|
||||||
"@db/*": ["./src/server/db/*"],
|
|
||||||
"@services/*": ["./src/server/services/*"],
|
|
||||||
"@routes/*": ["./src/server/routes/*"],
|
|
||||||
"@middleware/*": ["./src/server/middleware/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": false,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"declaration": false,
|
|
||||||
"sourceMap": false,
|
|
||||||
"outDir": "/tmp/tsc_out",
|
|
||||||
"rootDir": "/root/metapi",
|
|
||||||
"allowJs": false,
|
|
||||||
"removeComments": false
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/server/services/shared/apiKeyBatchCore.ts",
|
|
||||||
"src/server/shared/modelBrand.ts",
|
|
||||||
"src/server/shared/updateCenterReminder.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
+1
-1
Submodule vendor/ComfyUI_frontend updated: d405002127...8aec9178d8
Reference in New Issue
Block a user