Update BossAI workflow and workspace experience

This commit is contained in:
boos
2026-05-28 10:32:20 +08:00
parent 53d416cfb8
commit add7ae7048
39 changed files with 7791 additions and 1328 deletions
+71
View File
@@ -66,6 +66,29 @@
## 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 部署
1. **准备 docker-compose.yml**:
@@ -161,8 +184,54 @@
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
### Workflow 后端配置
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
外部完整版 ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native 执行器:
```toml
[comfyui]
backend = "boosapi"
```
说明:
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
---
## 5. 运维操作 (Ops)
@@ -179,6 +248,8 @@ BoosAPI 使用 SQLite,只需备份数据库文件即可:
cp data/boosapi.db data/boosapi.db.bak
```
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
### 查看日志
如果使用 Systemd:
```bash
+65 -3
View File
@@ -9,7 +9,7 @@
<br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br>
<strong>Workflow Studio / 视频助手</strong> — 用 API-native 工作流和自然语言助手组合图像、视频、语音与文本能力。
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
<br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p>
@@ -65,7 +65,8 @@
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音
- **Workflow Studio**:API-native 工作流入口,不依赖 Docker、GPU 或本地 ComfyUI
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面
@@ -176,6 +177,49 @@ BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
如果图像、视频、语音能力走 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 二进制。
## 🚀 快速开始
### Docker Compose(推荐)
@@ -296,6 +340,11 @@ BOOSAPI_UPSTREAM_KEY=sk-your-key
# 前端静态目录(默认 dist/web)
BOOSAPI_WEB_DIR=dist/web
# Workflow 后端
BOOSAPI_COMFYUI__BACKEND=cloud
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
```
## 🏗️ 技术栈
@@ -309,7 +358,7 @@ BOOSAPI_WEB_DIR=dist/web
| 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| Workflow Studio | API-native 工作流入口,后续承接节点画布 |
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) |
| 样式 | Tailwind CSS v4 |
@@ -376,6 +425,19 @@ BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 生产单二进制构建
```bash
./build-and-run.sh --production
```
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
### Docker 部署
```bash
+139 -150
View File
@@ -2,206 +2,195 @@
# BoosAPI
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point**
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
<p>
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
<br>
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization.
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
<br>
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
<br>
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
</p>
<p align="center">
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</p>
<p align="center">
<a href="README.md">中文</a> |
<a href="README_EN.md"><strong>English</strong></a>
<a href="README_EN.md"><strong>English</strong></a> |
<a href="DEPLOY.md">Deployment</a>
</p>
</div>
---
## 📖 Introduction
## Introduction
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming.
BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes.
Supported upstreams:
### Supported Upstreams
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
## Key Features
| Pain Point | BoosAPI Solution |
|------------|-----------------|
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
| 📊 Scattered balances | **Central dashboard** with alerts |
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
| 🤷 What models are available? | **Auto model discovery**, zero config |
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
### Unified API Gateway
---
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
- Admin and user dashboards with API key management and model visibility controls
## ✨ Features
### Workflow Studio
### 🎨 Built-in ComfyUI Chat Assistant
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline
- **Multi-view generation**: front/back/left/right directional image generation
- **Scene/video generation**: text description directly generates video content
- **SSE real-time streaming**: generation progress feedback
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
| backend | Purpose |
|---------|---------|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
| `external` | Proxy a full ComfyUI instance you deploy yourself |
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
### 🌐 Unified Proxy Gateway
Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
- Compatible with **OpenAI** and **Claude** downstream formats
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
- SSE streaming with automatic format conversion
### Assistants
### 🧠 Smart Routing Engine
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
- Volcengine video task templates for create-task and query-task flows
- Auto-discovers models from all upstream stations
- Four-tier cost signals: measured → configured → catalog → fallback
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
- Auto cooldown on failure (default 10 min), seamless channel switching
## Hardware Guide
### 📡 Multi-Platform Management
| Mode | CPU | Memory | Disk | GPU |
|------|-----|--------|------|-----|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
| Platform | Adapter |
|----------|---------|
| New API | `new-api` |
| One API | `one-api` |
| OneHub | `onehub` |
| DoneHub | `done-hub` |
| Veloera | `veloera` |
| AnyRouter | `anyrouter` |
| Sub2API | `sub2api` |
Recommended SaaS gateway server:
### 👥 Account & Token Management
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
- Encrypted credential storage, auto-renewal, cascading disable
### 💰 Balance Management
- Scheduled refresh (default hourly), income/expense trends
- Fallback estimation when API is unavailable
### 👥 SaaS Multi-Tenant
- **Organizations**: Create orgs, each with independent members and quotas
- **Member Invitations**: Email-based invites with admin/member roles
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
- **Role Control**: Owner / Admin / Member with granular permissions
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
- **Invite Links**: Token-based, 48h expiry
### 🔔 Alerts & Notifications
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
### 🔌 REST API Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/organizations` | GET/POST | List / Create org |
| `/api/organizations/plans` | GET | List available plans |
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
| `/api/organizations/:id/members` | GET | List members |
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
| `/api/invitations/:token` | GET | Public invite lookup |
| `/api/invitations/:token/accept` | POST | Accept invitation |
| `/api/users/register` | POST | Register new user |
| `/api/users/login` | POST | Login (returns JWT) |
| `/api/users/me` | GET/PATCH | Profile / Update |
| `/api/user-api-keys` | GET/POST | List / Create API key |
---
## 🚀 Quick Start
### Docker Compose (Recommended)
```bash
mkdir boosapi && cd boosapi
cat > docker-compose.yml << 'EOF'
services:
boosapi:
image: 1467078763/boosapi:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
CHECKIN_CRON: "0 8 * * *"
BALANCE_REFRESH_CRON: "0 * * * *"
PORT: ${PORT:-4000}
DATA_DIR: /app/data
TZ: ${TZ:-Asia/Shanghai}
restart: unless-stopped
EOF
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d
```txt
4 CPU cores
8GB RAM
50GB SSD
No GPU required
```
Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
## Quick Start
---
```bash
cp config.toml.example config.toml
./build-and-run.sh
```
## 🏗️ Tech Stack
For a production bundle with embedded web assets:
```bash
./build-and-run.sh --production
```
The production output is intended to run with only:
```txt
boosapi
config.toml
```
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
## Workflow Backend Configuration
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
External ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native executor:
```toml
[comfyui]
backend = "boosapi"
```
## Environment Variables
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
```bash
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
```
## Tech Stack
| Layer | Tech |
|-------|------|
| Backend | Fastify |
| Frontend | React 18 + Vite |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Database | SQLite, with PostgreSQL migration preparation |
| Visualization | VChart |
| Container | Docker + Docker Compose |
| Test | Vitest |
| Backend | Rust, Axum 0.8, Tokio |
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
| Proxy engine | Token Router + Proxy Conductor |
| Protocols | OpenAI / Claude / Gemini compatible transformers |
| Frontend | React 19 + Vite |
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
| Container | Docker / Docker Compose |
---
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
## 🛠️ Local Development
## Local Development
```bash
npm install
cargo check
cargo run
npm run dev
pnpm install
pnpm run dev:web
```
Build checks:
```bash
npm run build # Build web assets
npm test # Run tests
cargo check
pnpm run typecheck:web
npx vite build
```
---
## 📜 License
## License
[MIT](LICENSE)
+4
View File
@@ -20,6 +20,10 @@ RUN find /workspace/ComfyUI -mindepth 1 \
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
RUN bash /tmp/install-runninghub-template-nodes.sh \
&& rm -f /tmp/install-runninghub-template-nodes.sh
RUN python - <<'PY'
from pathlib import Path
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from 'fs/promises';
const DEFAULT_PACKS = [
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
];
const args = new Set(process.argv.slice(2));
const configPath = valueArg('--config') || 'config.toml';
const baseUrl = trimSlash(
process.env.BOOSAPI_COMFYUI__BASE_URL
|| process.env.COMFYUI_CLOUD_BASE_URL
|| (await readComfyConfig(configPath)).base_url
|| 'https://cloud.comfy.org'
);
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|| process.env.COMFYUI_CLOUD_API_KEY
|| (await readComfyConfig(configPath)).api_key
|| '';
const dryRun = args.has('--dry-run');
if (!baseUrl) {
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
}
if (!apiKey) {
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
}
console.log(`Cloud backend: ${baseUrl}`);
console.log(`Packs: ${DEFAULT_PACKS.length}`);
if (dryRun) {
for (const [id, repository] of DEFAULT_PACKS) {
console.log(`[dry-run] ${id} <- ${repository}`);
}
process.exit(0);
}
await request('GET', '/api/manager/queue/start').catch((error) => {
console.warn(`manager queue start warning: ${error.message}`);
});
const results = [];
for (const [id, repository] of DEFAULT_PACKS) {
const payload = {
id,
version: 'nightly',
selected_version: 'nightly',
repository,
mode: 'remote',
channel: 'default',
skip_post_install: false,
};
try {
await request('POST', '/api/manager/queue/install', payload);
console.log(`queued: ${id}`);
results.push({ id, ok: true });
} catch (error) {
console.error(`failed: ${id}: ${error.message}`);
results.push({ id, ok: false, error: error.message });
}
}
await request('GET', '/api/manager/queue/status')
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
.catch((error) => console.warn(`queue status warning: ${error.message}`));
const failed = results.filter((item) => !item.ok);
if (failed.length > 0) {
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
process.exit(1);
}
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
async function request(method, path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-API-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const data = text ? safeJson(text) : null;
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
}
return data;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { body: text };
}
}
async function readComfyConfig(path) {
const result = {};
let section = '';
let text = '';
try {
text = await fs.readFile(path, 'utf8');
} catch {
return result;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1];
continue;
}
if (section !== 'comfyui') continue;
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
if (match) result[match[1]] = match[2];
}
return result;
}
function trimSlash(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function valueArg(name) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : '';
}
function fail(message) {
console.error(message);
process.exit(2);
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
if [ ! -d "$COMFYUI_DIR" ]; then
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
exit 1
fi
if [ ! -x "$PYTHON_BIN" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
else
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
exit 1
fi
fi
mkdir -p "$CUSTOM_NODES_DIR"
install_node() {
local name="$1"
local repo="$2"
local ref="${3:-}"
local dir="$CUSTOM_NODES_DIR/$name"
if [ -d "$dir/.git" ]; then
echo "==> Updating $name"
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
else
echo "==> Installing $name"
git clone --depth 1 "$repo" "$dir"
fi
if [ -n "$ref" ]; then
git -C "$dir" checkout --quiet "$ref"
fi
if [ -f "$dir/requirements.txt" ]; then
echo "==> Installing Python requirements for $name"
if command -v uv >/dev/null 2>&1; then
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
else
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
fi
fi
if [ -f "$dir/install.py" ]; then
echo "==> Running install.py for $name"
"$PYTHON_BIN" "$dir/install.py" || true
fi
}
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
# for local/docker/external ComfyUI instances.
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
# Nodes used by common RunningHub digital-human / video templates.
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
cat <<'EOF'
RunningHub template nodes installed.
Notes:
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
node requires them at runtime.
- The screenshot also references a "soundflow" package. I did not find a stable
public repository for that exact package name; install it through
ComfyUI-Manager or add its repository manually when you have the source URL.
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
nodes. Put those models into the matching ComfyUI models folders or use a
backend where RunningHub/Cloud already provides them.
EOF
+4 -3
View File
@@ -6,13 +6,13 @@ const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
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.1';
const DEFAULT_CLOUD_REF = 'v1.46.2';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub often exposes older ComfyUI servers; v1.14.x matches
// ComfyUI 0.3.27 much better than the newer Cloud-oriented frontend.
// 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: {
@@ -71,6 +71,7 @@ async function buildVariant(name, config) {
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 });
+1493
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -76,6 +76,7 @@ pub enum ComfyUIBackend {
Boosapi,
External,
Cloud,
Runninghub,
Docker,
}
@@ -111,7 +112,7 @@ impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool {
matches!(
self.backend,
ComfyUIBackend::External | ComfyUIBackend::Cloud
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
)
}
@@ -119,6 +120,10 @@ impl ComfyUIConfig {
matches!(self.backend, ComfyUIBackend::Cloud)
}
pub fn uses_runninghub_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Runninghub)
}
pub fn uses_docker_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Docker)
}
+455 -60
View File
@@ -8,8 +8,10 @@ use axum::{
routing::{get, post, put},
Json, Router,
};
use chrono::{DateTime, Duration, Utc};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::Row;
pub fn routes() -> Router<AppState> {
Router::new()
@@ -100,6 +102,62 @@ struct IdPath {
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)]
#[serde(rename_all = "camelCase")]
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>> {
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)
.await?;
let mut summaries = Vec::new();
for route in routes {
let channel_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM route_channels WHERE route_id = ?")
.bind(route.id)
.fetch_one(&*state.db)
.await?;
let enabled_channel_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM route_channels WHERE route_id = ? AND COALESCE(enabled, 1) = 1",
)
.bind(route.id)
.fetch_one(&*state.db)
.await?;
summaries.push(json!({
"id": route.id,
"modelPattern": route.model_pattern,
"displayName": route.display_name,
"displayIcon": route.display_icon,
"routeMode": route.route_mode,
let summaries: Vec<Value> = routes
.into_iter()
.map(|row| {
let site_names_raw: String = row.try_get("site_names").unwrap_or_default();
let site_names: Vec<String> = site_names_raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect();
let decision_snapshot_raw: Option<String> = row.try_get("decision_snapshot").ok();
let decision_snapshot = decision_snapshot_raw
.as_deref()
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
.unwrap_or(Value::Null);
json!({
"id": row.try_get::<i64, _>("id").unwrap_or_default(),
"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": [],
"modelMapping": route.model_mapping,
"routingStrategy": route.routing_strategy,
"enabled": route.enabled.unwrap_or(true),
"channelCount": channel_count,
"enabledChannelCount": enabled_channel_count,
"siteNames": [],
"decisionSnapshot": Value::Null,
"decisionRefreshedAt": Value::Null,
}));
}
Ok(Json(
json!({ "success": true, "items": summaries, "data": summaries }),
))
"modelMapping": row.try_get::<Option<String>, _>("model_mapping").ok().flatten(),
"routingStrategy": row.try_get::<Option<String>, _>("routing_strategy").ok().flatten(),
"enabled": row.try_get::<Option<bool>, _>("enabled").ok().flatten().unwrap_or(true),
"channelCount": row.try_get::<i64, _>("channel_count").unwrap_or_default(),
"enabledChannelCount": row.try_get::<i64, _>("enabled_channel_count").unwrap_or_default(),
"siteNames": site_names,
"decisionSnapshot": decision_snapshot,
"decisionRefreshedAt": row.try_get::<Option<String>, _>("decision_refreshed_at").ok().flatten(),
})
})
.collect();
Ok(Json(json!({
"success": true,
"routes": summaries,
"items": summaries,
"data": summaries,
"total": summaries.len(),
})))
}
async fn create_route(
@@ -375,9 +464,17 @@ async fn clear_route_cooldown(
async fn rebuild_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
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!({
"success": true,
"rebuild": summary,
"totalRoutes": total_routes,
"totalChannels": total_channels,
})))
}
@@ -494,16 +591,13 @@ async fn reset_downstream_key_usage(
Ok(Json(json!({ "success": true })))
}
async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
let items: Vec<Value> = sqlx::query_as::<
_,
crate::db::models::downstream_api_key::DownstreamApiKey,
>("SELECT * FROM downstream_api_keys ORDER BY name")
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})))
.collect();
async fn downstream_keys_summary(
State(state): State<AppState>,
Query(query): Query<DownstreamUsageQuery>,
) -> AppResult<Json<Value>> {
let (range, since, _bucket_seconds) = resolve_downstream_usage_window(query.range.as_deref());
let rows = fetch_downstream_key_usage_rows(&state, since.as_deref()).await?;
let items: Vec<Value> = rows.into_iter().map(downstream_key_usage_json).collect();
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
.fetch_one(&*state.db)
.await?;
@@ -522,6 +616,7 @@ async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Jso
.await?;
Ok(Json(json!({
"success": true,
"range": range,
"items": items,
"data": items,
"summary": {
@@ -538,28 +633,327 @@ async fn downstream_key_overview(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
) -> AppResult<Json<Value>> {
let key: Option<Value> = sqlx::query_as::<
_,
crate::db::models::downstream_api_key::DownstreamApiKey,
>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})));
Ok(Json(json!({ "success": true, "item": key })))
let key = fetch_downstream_key_usage_row(&state, id, None).await?;
let item = downstream_key_usage_json(key);
let usage = json!({
"last24h": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::hours(24)))).await?,
"last7d": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::days(7)))).await?,
"all": downstream_usage_aggregate(&state, id, None).await?,
});
Ok(Json(json!({ "success": true, "item": item, "usage": usage })))
}
async fn downstream_key_trend(
State(state): State<AppState>,
Path(IdPath { id }): Path<IdPath>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Json<Value> {
Json(json!({
Query(q): Query<DownstreamUsageQuery>,
) -> AppResult<Json<Value>> {
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,
"range": q.get("range").map(String::as_str).unwrap_or("24h"),
"item": { "id": id, "name": "" },
"bucketSeconds": 3600,
"buckets": [],
}))
"range": range,
"item": { "id": key_id, "name": key_name },
"bucketSeconds": bucket_seconds,
"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> {
@@ -586,6 +980,7 @@ async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
},
"comfyui": {
"enabled": state.config.comfyui.enabled,
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
"port": state.config.comfyui.port,
"baseUrl": state.config.comfyui.base_url,
"externalUrl": state.config.comfyui.external_url,
+68 -4
View File
@@ -140,6 +140,10 @@ pub async fn create_site(
return Err(AppError::BadRequest("platform is required".into()));
}
let site_url = normalize_site_url(&payload.url)?;
let platform = payload.platform.trim().to_string();
ensure_site_unique(&state, &platform, &site_url, None).await?;
let status = payload.status.unwrap_or_else(|| "active".to_string());
let mut tx = state.db.begin().await?;
@@ -154,8 +158,8 @@ pub async fn create_site(
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&payload.name)
.bind(&payload.url)
.bind(&payload.platform)
.bind(&site_url)
.bind(&platform)
.bind(&status)
.bind(&payload.api_key)
.bind(&payload.proxy_url)
@@ -217,6 +221,27 @@ pub async fn update_site(
.await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
if payload
.platform
.as_deref()
.is_some_and(|platform| platform.trim().is_empty())
{
return Err(AppError::BadRequest("platform is required".into()));
}
let next_url = match payload.url.as_deref() {
Some(url) => normalize_site_url(url)?,
None => existing.url.clone(),
};
let next_platform = payload
.platform
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(existing.platform.as_str())
.to_string();
ensure_site_unique(&state, &next_platform, &next_url, Some(id)).await?;
let mut tx = state.db.begin().await?;
sqlx::query(
@@ -230,8 +255,8 @@ pub async fn update_site(
WHERE id = ?",
)
.bind(payload.name.unwrap_or(existing.name))
.bind(payload.url.unwrap_or(existing.url))
.bind(payload.platform.unwrap_or(existing.platform))
.bind(next_url)
.bind(next_platform)
.bind(payload.status.unwrap_or(existing.status))
.bind(payload.api_key.or(existing.api_key))
.bind(payload.proxy_url.or(existing.proxy_url))
@@ -466,6 +491,45 @@ fn normalize_site_url(raw: &str) -> AppResult<String> {
Ok(trimmed)
}
async fn ensure_site_unique(
state: &AppState,
platform: &str,
url: &str,
except_id: Option<i64>,
) -> AppResult<()> {
let existing: Option<(i64, String)> = if let Some(id) = except_id {
sqlx::query_as(
"SELECT id, name FROM sites WHERE platform = ? AND url = ? AND id != ? LIMIT 1",
)
.bind(platform)
.bind(url)
.bind(id)
.fetch_optional(&*state.db)
.await?
} else {
sqlx::query_as("SELECT id, name FROM sites WHERE platform = ? AND url = ? LIMIT 1")
.bind(platform)
.bind(url)
.fetch_optional(&*state.db)
.await?
};
if let Some((id, name)) = existing {
let display_name = name.trim();
let display_name = if display_name.is_empty() {
"未命名站点"
} else {
display_name
};
return Err(AppError::BadRequest(format!(
"该平台下已存在相同 URL 的站点:{}(ID {})",
display_name, id
)));
}
Ok(())
}
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
let lower = url.to_ascii_lowercase();
+225 -2
View File
@@ -10,7 +10,7 @@ use axum::{
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
#[derive(Debug, Clone)]
struct DigitalHumanAuth {
@@ -101,7 +101,11 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
let _ = resolve_auth(&headers, &state).await?;
Ok(Json(json!({
"success": true,
"mode": "boosapi-video-route",
"mode": if state.config.comfyui.uses_runninghub_backend() { "runninghub-ai-app" } else { "boosapi-video-route" },
"runninghub": {
"configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
"endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run"
},
"capabilities": [
"text_to_avatar_video",
"image_to_avatar_video",
@@ -112,12 +116,231 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
})))
}
fn truthy(value: Option<&Value>) -> bool {
match value {
Some(Value::Bool(value)) => *value,
Some(Value::Number(value)) => value.as_i64().unwrap_or_default() != 0,
Some(Value::String(value)) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "runninghub"
),
_ => false,
}
}
fn runninghub_api_key(state: &AppState, body: &Value) -> AppResult<String> {
body.get("runninghub_api_key")
.and_then(Value::as_str)
.or_else(|| state.config.comfyui.api_key.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
AppError::BadRequest(
"RunningHub api_key is required in config.toml [comfyui].api_key".into(),
)
})
}
fn runninghub_webapp_id(body: &Value) -> AppResult<Value> {
let raw = body
.get("webapp_id")
.or_else(|| body.get("webappId"))
.or_else(|| body.get("model"))
.and_then(|value| match value {
Value::String(value) => Some(value.trim().to_string()),
Value::Number(value) => Some(value.to_string()),
_ => None,
})
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::BadRequest("RunningHub webappId is required".into()))?;
if let Ok(value) = raw.parse::<i64>() {
Ok(json!(value))
} else {
Ok(json!(raw))
}
}
fn push_node_info(
items: &mut Vec<Value>,
body: &Value,
id_key: &str,
field_name: &str,
field_value: Option<String>,
) {
let Some(node_id) = body
.get(id_key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Some(field_value) = field_value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
else {
return;
};
items.push(json!({
"nodeId": node_id,
"fieldName": field_name,
"fieldValue": field_value
}));
}
fn runninghub_node_info_list(body: &Value) -> AppResult<Vec<Value>> {
if let Some(list) = body
.get("node_info_list")
.or_else(|| body.get("nodeInfoList"))
.and_then(Value::as_array)
{
let items = list
.iter()
.filter(|item| item.is_object())
.cloned()
.collect::<Vec<_>>();
if !items.is_empty() {
return Ok(items);
}
}
let mut items = Vec::new();
let prompt = body
.get("prompt")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let script = body
.get("script")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
body.get("prompt")
.and_then(Value::as_str)
.and_then(|value| {
value
.split_once("台词:")
.map(|(_, script)| script.trim().to_string())
})
});
push_node_info(&mut items, body, "prompt_node_id", "prompt", prompt);
push_node_info(&mut items, body, "script_node_id", "text", script);
push_node_info(
&mut items,
body,
"image_node_id",
"image",
body.get("image_url")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
);
push_node_info(
&mut items,
body,
"audio_node_id",
"audio",
body.get("audio_url")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
);
if items.is_empty() {
return Err(AppError::BadRequest(
"RunningHub nodeInfoList is required; set node_info_list or node id mappings".into(),
));
}
Ok(items)
}
fn runninghub_request_body(body: &Value, api_key: &str) -> AppResult<Value> {
let mut payload = Map::new();
payload.insert("webappId".to_string(), runninghub_webapp_id(body)?);
payload.insert("apiKey".to_string(), json!(api_key));
payload.insert(
"nodeInfoList".to_string(),
Value::Array(runninghub_node_info_list(body)?),
);
if let Some(value) = body
.get("access_password")
.or_else(|| body.get("accessPassword"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
payload.insert("accessPassword".to_string(), json!(value));
}
if let Some(value) = body
.get("instance_type")
.or_else(|| body.get("instanceType"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
payload.insert("instanceType".to_string(), json!(value));
}
if let Some(value) = body
.get("webhook_url")
.or_else(|| body.get("webhookUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
payload.insert("webhookUrl".to_string(), json!(value));
}
Ok(Value::Object(payload))
}
async fn generate_runninghub(state: &AppState, body: Value) -> AppResult<Json<Value>> {
let api_key = runninghub_api_key(state, &body)?;
let request_body = runninghub_request_body(&body, &api_key)?;
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
.post("https://www.runninghub.cn/task/openapi/ai-app/run")
.bearer_auth(&api_key)
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("RunningHub request failed: {err}")))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(Json(json!({
"success": true,
"provider": "runninghub",
"request": request_body,
"response": value
})))
}
async fn generate(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let provider = body
.get("provider")
.or_else(|| body.get("backend"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("");
if provider.eq_ignore_ascii_case("runninghub")
|| truthy(body.get("use_runninghub"))
|| (state.config.comfyui.uses_runninghub_backend()
&& truthy(body.get("use_config_backend")))
{
return generate_runninghub(&state, body).await;
}
if body
.get("model")
.and_then(Value::as_str)
+1
View File
@@ -624,6 +624,7 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<Option<i64>
if !key.enabled.unwrap_or(true) {
return Err(AppError::Forbidden("API key is disabled".into()));
}
UsageTracker::new(state.db.clone()).check_rate_limit(key.id).await?;
Ok(Some(key.id))
}
DownstreamAuthResult::Global { .. } => Ok(None),
+226 -16
View File
@@ -8,6 +8,8 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::Row;
use std::collections::{BTreeMap, HashMap};
use tracing::info;
#[derive(Debug, Deserialize)]
@@ -124,6 +126,7 @@ async fn dashboard(
.fetch_one(&*state.db)
.await?;
let site_availability = build_site_availability(&state).await?;
let model_analysis = build_model_analysis(&state, 7).await?;
let payload = json!({
"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,
},
"siteAvailability": site_availability,
"modelAnalysis": model_analysis,
"summary": {
"sites": sites,
"accounts": accounts,
@@ -186,11 +190,139 @@ async fn dashboard(
"generatedAt": payload["generatedAt"],
"insights": payload["insights"],
"siteAvailability": payload["siteAvailability"],
"modelAnalysis": payload["modelAnalysis"],
}))),
_ => 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>> {
let rows: Vec<(
i64,
@@ -489,28 +621,70 @@ async fn site_distribution(
.to_string();
// Sum per-site usage
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0)
let mut rows: Vec<(i64, String, Option<String>, i64, f64, i64, i64, f64)> = sqlx::query_as(
"SELECT
s.id,
s.name,
s.platform,
COALESCE(SUM(m.total_calls), 0),
COALESCE(SUM(m.total_spend), 0.0),
COALESCE(SUM(m.total_tokens), 0),
COUNT(DISTINCT a.id),
COALESCE(SUM(a.balance), 0.0)
FROM sites s
LEFT JOIN accounts a ON a.site_id = s.id
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
GROUP BY s.id, s.name
ORDER BY SUM(m.total_spend) DESC"
GROUP BY s.id, s.name, s.platform
ORDER BY SUM(m.total_spend) DESC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
if rows
.iter()
.all(|(_, _, _, calls, spend, tokens, _, _)| *calls == 0 && *spend == 0.0 && *tokens == 0)
{
rows = sqlx::query_as(
"SELECT
s.id,
s.name,
s.platform,
COUNT(l.id),
COALESCE(SUM(l.estimated_cost), 0.0),
COALESCE(SUM(l.total_tokens), 0),
COUNT(DISTINCT a.id),
COALESCE(SUM(DISTINCT a.balance), 0.0)
FROM sites s
LEFT JOIN accounts a ON a.site_id = s.id
LEFT JOIN proxy_logs l
ON l.account_id = a.id
AND date(l.created_at) >= ?
GROUP BY s.id, s.name, s.platform
ORDER BY COALESCE(SUM(l.estimated_cost), 0.0) DESC, COUNT(l.id) DESC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
}
let distribution: Vec<Value> = rows
.into_iter()
.map(|(id, name, calls, spend, tokens)| {
.map(
|(id, name, platform, calls, spend, tokens, account_count, balance)| {
json!({
"siteId": id,
"siteName": name,
"platform": platform,
"calls": calls,
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
"totalSpend": (spend * 1_000_000.0).round() / 1_000_000.0,
"totalBalance": balance,
"accountCount": account_count,
"tokens": tokens,
})
})
},
)
.collect();
Ok(Json(json!({ "distribution": distribution })))
@@ -528,25 +702,61 @@ async fn site_trend(
.format("%Y-%m-%d")
.to_string();
let rows: Vec<(String, i64, f64, i64)> = sqlx::query_as(
"SELECT local_day, COALESCE(SUM(total_calls), 0), COALESCE(SUM(total_spend), 0.0), COALESCE(SUM(total_tokens), 0)
FROM model_day_usage
WHERE local_day >= ?
GROUP BY local_day
ORDER BY local_day ASC",
let mut rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
"SELECT
m.local_day,
s.name,
COALESCE(SUM(m.total_calls), 0),
COALESCE(SUM(m.total_spend), 0.0),
COALESCE(SUM(m.total_tokens), 0)
FROM model_day_usage m
JOIN sites s ON s.id = m.site_id
WHERE m.local_day >= ?
GROUP BY m.local_day, s.id, s.name
ORDER BY m.local_day ASC, s.name ASC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
let trend: Vec<Value> = rows
.into_iter()
.map(|(day, calls, spend, tokens)| {
if rows.is_empty() {
rows = sqlx::query_as(
"SELECT
date(l.created_at),
COALESCE(s.name, '未知站点'),
COUNT(l.id),
COALESCE(SUM(l.estimated_cost), 0.0),
COALESCE(SUM(l.total_tokens), 0)
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE date(l.created_at) >= ?
GROUP BY date(l.created_at), s.id, s.name
ORDER BY date(l.created_at) ASC, s.name ASC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
}
let mut by_day: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
for (day, site, calls, spend, tokens) in rows {
by_day.entry(day).or_default().insert(
site,
json!({
"date": day,
"calls": calls,
"spend": spend,
"tokens": tokens,
}),
);
}
let trend: Vec<Value> = by_day
.into_iter()
.map(|(day, sites)| {
json!({
"date": day,
"sites": sites,
})
})
.collect();
+48
View File
@@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
.route("/api/user/logs", get(get_user_logs))
.route("/api/user/summary", get(get_user_summary))
.route("/api/user/dashboard", get(get_user_dashboard))
.route(
"/api/user/models/marketplace",
get(get_user_models_marketplace),
@@ -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(
State(state): State<AppState>,
headers: HeaderMap,
+1992 -101
View File
File diff suppressed because it is too large Load Diff
+98 -2
View File
@@ -7,12 +7,12 @@ use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, HeaderMap, Response, StatusCode},
routing::get,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::{collections::HashSet, path::PathBuf};
use tokio_util::io::ReaderStream;
#[derive(Debug, Clone)]
@@ -34,9 +34,19 @@ struct AssetQuery {
page_size: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct BatchDeleteAssetsRequest {
#[serde(default, alias = "publicIds")]
public_ids: Vec<String>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/workspace/assets", get(list_admin_assets))
.route(
"/api/workspace/assets/batch-delete",
post(batch_delete_admin_assets),
)
.route(
"/api/workspace/assets/{public_id}",
get(get_admin_asset).delete(delete_admin_asset),
@@ -46,6 +56,10 @@ pub fn routes() -> Router<AppState> {
get(get_admin_asset_file),
)
.route("/api/user/workspace/assets", get(list_user_assets))
.route(
"/api/user/workspace/assets/batch-delete",
post(batch_delete_user_assets),
)
.route(
"/api/user/workspace/assets/{public_id}",
get(get_user_asset).delete(delete_user_asset),
@@ -175,6 +189,10 @@ async fn list_assets(
FROM workspace_assets
WHERE deleted_at IS NULL
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 kind = ?)
AND (
@@ -212,6 +230,10 @@ async fn list_assets(
FROM workspace_assets
WHERE deleted_at IS NULL
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 kind = ?)
AND (
@@ -320,6 +342,62 @@ async fn delete_asset(
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(
state: AppState,
caller: WorkspaceCaller,
@@ -400,6 +478,15 @@ async fn delete_admin_asset(
delete_asset(state, caller, public_id).await
}
async fn batch_delete_admin_assets(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAssetsRequest>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, true)?;
delete_assets_batch(state, caller, payload.public_ids).await
}
async fn list_user_assets(
State(state): State<AppState>,
headers: HeaderMap,
@@ -435,3 +522,12 @@ async fn delete_user_asset(
let caller = resolve_caller(&headers, &state, false)?;
delete_asset(state, caller, public_id).await
}
async fn batch_delete_user_assets(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAssetsRequest>,
) -> AppResult<Json<Value>> {
let caller = resolve_caller(&headers, &state, false)?;
delete_assets_batch(state, caller, payload.public_ids).await
}
+6 -3
View File
@@ -108,8 +108,11 @@ impl UsageTracker {
return Err(AppError::Forbidden("API key is disabled".into()));
}
if let Some(max_cost) = max_cost {
if used_cost.unwrap_or(0.0) >= max_cost {
return Err(AppError::RateLimited);
if max_cost <= 0.0 || used_cost.unwrap_or(0.0) >= max_cost {
return Err(AppError::RateLimitedWithRetry {
message: "下游密钥成本额度已用完".into(),
retry_after_secs: 60,
});
}
}
Ok(())
@@ -260,7 +263,7 @@ mod tests {
.unwrap();
assert!(matches!(
tracker.check_rate_limit(1).await,
Err(AppError::RateLimited)
Err(AppError::RateLimitedWithRetry { .. })
));
}
+4
View File
@@ -94,6 +94,10 @@ impl ComfyUIProcess {
let base_url = self.internal_base_url();
let mode = if self.config.uses_docker_backend() {
"docker"
} else if self.config.uses_cloud_backend() {
"cloud"
} else if self.config.uses_runninghub_backend() {
"runninghub"
} else if self.config.enabled {
"embedded"
} else {
+10 -1
View File
@@ -21,8 +21,17 @@ pub async fn enforce_key_request_limit(
return Err(AppError::Forbidden("API key is disabled".into()));
}
if let Some(max_requests) = max_requests {
if max_requests <= 0 {
return Err(AppError::RateLimitedWithRetry {
message: "下游密钥请求额度已用完".into(),
retry_after_secs: 60,
});
}
if used_requests.unwrap_or(0) + increment > max_requests {
return Err(AppError::RateLimited);
return Err(AppError::RateLimitedWithRetry {
message: "下游密钥请求额度已用完".into(),
retry_after_secs: 60,
});
}
}
Ok(())
+47 -17
View File
@@ -7,6 +7,8 @@ import TooltipLayer from './components/TooltipLayer.js';
import { api, userApi } from './api.js';
import { clearSession, getSessionToken } from './authSession.js';
import {
ADMIN_PROFILE_STORAGE_KEY,
LEGACY_USER_PROFILE_STORAGE_KEY,
LEGACY_THEME_STORAGE_KEY,
THEME_MODE_STORAGE_KEY,
USER_PROFILE_STORAGE_KEY,
@@ -110,13 +112,15 @@ function buildDicebearAvatarUrl(style: string, seed: string): string {
const safeSeed = (seed || 'default').trim() || 'default';
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
}
function resolveStoredProfile(): UserProfile {
try {
const raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY);
if (!raw) {
function defaultProfile(name: string): UserProfile {
const avatarSeed = createRandomAvatarSeed();
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
return { name, avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string };
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
const avatarSeed = typeof parsed?.avatarSeed === 'string'
@@ -127,24 +131,46 @@ function resolveStoredProfile(): UserProfile {
? parsed.avatarStyle.trim()
: '';
return {
name: name || '管理员',
name: name || fallbackName,
avatarSeed: resolvedSeed,
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
: pickDicebearStyle(resolvedSeed),
};
} catch {
const avatarSeed = createRandomAvatarSeed();
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
return null;
}
}
function profileFromUsername(username: string, current?: UserProfile): UserProfile {
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 || '用户',
name: name || current?.name || fallbackName,
avatarSeed,
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
@@ -433,7 +459,9 @@ function AppShell() {
const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile());
const isUserPage = location.pathname.startsWith('/user/');
const profileAudience: 'admin' | 'user' = isUserPage ? 'user' : 'admin';
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile(profileAudience));
const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
@@ -455,6 +483,9 @@ function AppShell() {
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
useEffect(() => {
setUserProfile(resolveStoredProfile(profileAudience));
}, [profileAudience]);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemPrefersDark(media.matches);
@@ -590,8 +621,8 @@ function AppShell() {
if (!username) return;
setUserProfile((current) => {
if ((current.name || '').trim() === username) return current;
const next = profileFromUsername(username, current);
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(next));
const next = profileFromUsername(username, current, '用户');
localStorage.setItem(profileStorageKey('user'), JSON.stringify(next));
return next;
});
} catch {
@@ -622,14 +653,14 @@ function AppShell() {
const handleSaveProfile = (nextProfile: UserProfile) => {
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
const normalized = {
name: nextProfile.name.trim() || t('管理员'),
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
avatarSeed: normalizedSeed,
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
? nextProfile.avatarStyle
: pickDicebearStyle(normalizedSeed),
};
setUserProfile(normalized);
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized));
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
setShowProfileModal(false);
toast.success(t('个人信息已保存'));
};
@@ -660,7 +691,6 @@ function AppShell() {
setAnnouncementPopupPending(false);
}
};
const isUserPage = location.pathname.startsWith('/user/');
const isAdminLoginPage = location.pathname === '/boos';
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
// The default `/` route now serves the user-facing login; admins land on `/boos`.
+27
View File
@@ -322,6 +322,15 @@ function normalizeDownstreamKeyRow(row: any): any {
groupName: row.groupName ?? row.group_name ?? null,
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
billing: row.billing ?? null,
rangeUsage: row.rangeUsage ?? row.range_usage ?? {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
successRate: null,
totalTokens: 0,
totalCost: 0,
},
maxCost: row.maxCost ?? row.max_cost ?? null,
maxRequests: row.maxRequests ?? row.max_requests ?? null,
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
@@ -1083,6 +1092,13 @@ export type WorkspaceAssetsResponse = {
pageSize: number;
};
export type WorkspaceBatchDeleteResponse = {
success: boolean;
deletedCount: number;
deletedIds: string[];
failedIds: string[];
};
export type WorkspaceAssetQuery = {
source?: string;
kind?: string;
@@ -2000,6 +2016,11 @@ export const api = {
request<WorkspaceAssetsResponse>(`/api/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) =>
request(`/api/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
request<WorkspaceBatchDeleteResponse>("/api/workspace/assets/batch-delete", {
method: "POST",
body: JSON.stringify({ publicIds }),
}),
getWorkflowNodeTypes: () =>
request<{ success: boolean; nodeTypes: any[] }>("/api/workflow/node-types"),
getWorkflows: (params?: { include_public?: boolean }) =>
@@ -2047,6 +2068,7 @@ export const userApi = {
getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"),
getDashboard: () => userRequest("/api/user/dashboard"),
getModelsMarketplace: (options?: {
refresh?: boolean;
includePricing?: boolean;
@@ -2069,6 +2091,11 @@ export const userApi = {
userRequest<WorkspaceAssetsResponse>(`/api/user/workspace/assets${buildQueryString(params)}`),
deleteWorkspaceAsset: (publicId: string) =>
userRequest(`/api/user/workspace/assets/${encodeURIComponent(publicId)}`, { method: "DELETE" }),
batchDeleteWorkspaceAssets: (publicIds: string[]) =>
userRequest<WorkspaceBatchDeleteResponse>("/api/user/workspace/assets/batch-delete", {
method: "POST",
body: JSON.stringify({ publicIds }),
}),
getWorkflowNodeTypes: () =>
userRequest<{ success: boolean; nodeTypes: any[] }>("/api/user/workflow/node-types"),
getWorkflows: (params?: { include_public?: boolean }) =>
+3
View File
@@ -3,6 +3,8 @@ import { clearSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY;
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
type StorageLike = {
@@ -24,5 +26,6 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
}
+19 -10
View File
@@ -23,11 +23,11 @@ interface ModelAnalysisPanelProps {
data?: ModelAnalysisData | null;
}
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
{ key: 'spend', label: '消耗分布', icon: '💰' },
{ key: 'trend', label: '消耗趋势', icon: '📈' },
{ key: 'calls', label: '调用分布', icon: '🔄' },
{ key: 'rank', label: '排行榜', icon: '🏆' },
const tabs: Array<{ key: TabKey; label: string; code: string }> = [
{ key: 'spend', label: '消耗分布', code: 'SP' },
{ key: 'trend', label: '消耗趋势', code: 'TR' },
{ key: 'calls', label: '调用分布', code: 'CL' },
{ key: 'rank', label: '排行榜', code: 'RK' },
];
function toSafeNumber(value: unknown): number {
@@ -46,6 +46,13 @@ function formatPercent(value: number): string {
return `${toSafeNumber(value).toFixed(1)}%`;
}
function formatPiePercent(_text: unknown, datum: unknown): string {
const record = datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
const percent = toSafeNumber(record._percent_);
if (percent <= 0) return '';
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
}
function EmptyBlock() {
return (
<div className="empty-state" style={{ padding: 28 }}>
@@ -71,15 +78,16 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const callRanking = (data?.callRanking || []).slice(0, 10);
const hasData = totals.calls > 0
|| spendDistribution.length > 0
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
|| spendDistribution.some((item) => toSafeNumber(item.spend) > 0)
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0)
|| callsDistribution.some((item) => toSafeNumber(item.calls) > 0);
const spendBarSpec = useMemo(() => ({
type: 'bar' as const,
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
xField: 'value', yField: 'model', direction: 'horizontal' as const,
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
label: { visible: true, position: 'right', formatMethod: (text: unknown) => formatCurrency(Number(text || 0)), style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
animation: true, background: 'transparent',
}), [spendDistribution, labelColor]);
@@ -102,7 +110,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
valueField: 'calls', categoryField: 'model',
outerRadius: 0.8, innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
label: { visible: true, position: 'outside', formatMethod: formatPiePercent, style: { fill: labelColor, fontSize: 12, stroke: 'transparent' } },
legends: { visible: false },
animation: true,
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
@@ -138,7 +146,8 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon} {tab.label}
<span className="pill-tab-code">{tab.code}</span>
{tab.label}
</button>
))}
</div>
@@ -4,10 +4,13 @@ import { useThemeLabelColor } from '../useThemeLabelColor.js';
interface SiteDistributionData {
siteName: string;
platform: string;
totalBalance: number;
totalSpend: number;
accountCount: number;
platform?: string | null;
totalBalance?: number;
totalSpend?: number;
accountCount?: number;
spend?: number;
calls?: number;
tokens?: number;
}
interface SiteDistributionChartProps {
@@ -26,6 +29,13 @@ function safeNumber(value: unknown): number {
return value;
}
function formatPercentLabel(datum: unknown): string {
const item = coerceDatumRecord(datum);
const percent = safeNumber(item._percent_);
if (percent <= 0) return '';
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`;
}
function SkeletonCircle() {
return (
<div
@@ -98,8 +108,11 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
return data.map((item) => ({
siteName: item.siteName,
platform: item.platform,
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
value: safeNumber(viewMode === 'balance'
? item.totalBalance
: item.totalSpend ?? item.spend),
accountCount: safeNumber(item.accountCount),
calls: safeNumber(item.calls),
}));
}, [data, viewMode]);
@@ -118,7 +131,12 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
outerRadius: 0.8,
innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
label: {
visible: true,
position: 'outside',
formatMethod: (_text: unknown, datum: unknown) => formatPercentLabel(datum),
style: { fill: labelColor, fontSize: 12, stroke: 'transparent' },
},
legends: { visible: false },
tooltip: {
mark: {
@@ -149,6 +167,13 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
return String(item.accountCount || 0);
},
},
{
key: '调用',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.calls || 0);
},
},
] as any,
},
},
@@ -159,6 +184,7 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio
}, [chartData, hasData, labelColor]);
const formatValue = (value: number): string => {
if (viewMode === 'balance' && value === 0) return '$0.00';
if (value >= 1000) return `$${value.toFixed(2)}`;
if (value >= 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(6)}`;
+38 -12
View File
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { useThemeLabelColor } from '../useThemeLabelColor.js';
/* ------------------------------------------------------------------ */
/* Types */
@@ -7,7 +8,10 @@ import { VChart } from '@visactor/react-vchart';
interface SiteTrendData {
date: string;
sites: Record<string, { spend: number; calls: number }>;
sites?: Record<string, { spend?: number; calls?: number; tokens?: number }>;
spend?: number;
calls?: number;
tokens?: number;
}
interface SiteTrendChartProps {
@@ -43,20 +47,26 @@ const COLOR_PALETTE = [
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
const [metric, setMetric] = useState<Metric>('spend');
const labelColor = useThemeLabelColor();
/* ---------- data transform ---------- */
const flatData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.flatMap((d) =>
Object.entries(d.sites).map(([site, v]) => ({
return data.flatMap((d) => {
const sites = d.sites && Object.keys(d.sites).length > 0
? d.sites
: { 全部站点: { spend: d.spend, calls: d.calls, tokens: d.tokens } };
return Object.entries(sites).map(([site, v]) => ({
date: d.date,
site,
value: metric === 'spend' ? v.spend : v.calls,
})),
);
value: metric === 'spend' ? Number(v.spend ?? 0) : Number(v.calls ?? 0),
}));
});
}, [data, metric]);
const hasPositiveValue = flatData.some((item) => Number(item.value || 0) > 0);
/* ---------- loading state ---------- */
if (loading) {
@@ -72,7 +82,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
/* ---------- empty state ---------- */
if (!data || data.length === 0 || flatData.length === 0) {
if (!data || data.length === 0 || flatData.length === 0 || !hasPositiveValue) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
@@ -80,7 +90,7 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
</div>
<div className="empty-state" style={{ padding: 48 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
<div className="empty-state-desc">所选时间范围内还没有有效的{metric === 'spend' ? '消耗' : '调用'}记录</div>
</div>
</div>
);
@@ -144,22 +154,38 @@ export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
label: {
style: { fontSize: 11, fill: labelColor },
formatMethod: (value: string) => {
const text = String(value || '');
if (text.length >= 10) return text.slice(5, 10);
return text;
},
},
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
style: { fontSize: 11, fill: labelColor },
formatMethod: (value: string) => {
const n = Number(value || 0);
if (!Number.isFinite(n)) return String(value || '');
if (metric === 'spend') {
if (n >= 1) return `$${n.toFixed(1)}`;
return `$${n.toFixed(4)}`;
}
return Math.round(n).toLocaleString();
},
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
},
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [3, 5], lineWidth: 1 } },
domainLine: { visible: false },
},
],
color: COLOR_PALETTE,
background: 'transparent',
padding: { left: 8, right: 16, top: 8, bottom: 8 },
padding: { left: 48, right: 18, top: 12, bottom: 36 },
};
/* ---------- render ---------- */
+1770 -100
View File
File diff suppressed because it is too large Load Diff
+85 -93
View File
@@ -7,7 +7,7 @@ const FEATURES = [
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BossAI 模型路由、图像、视频、音频和工具节点。' },
{ icon: 'WF', title: '工作流画布', desc: '内嵌 ComfyUI 前端,支持 Comfy Cloud、RunningHub、外部 ComfyUI 与 BossAI API-native 执行器。' },
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BossAI 模型路由。' },
@@ -21,7 +21,8 @@ const TECH_STACK = [
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BossAI 不再内置 Docker 工作台。' },
{ name: 'Workflow UI', desc: '内嵌官方 ComfyUI 前端资产,按后端模式代理 Cloud、RunningHub 或外部 ComfyUI。' },
{ name: 'Multi Backend', desc: '支持 cloud / runninghub / external / BossAI / docker 五种工作流后端配置。' },
];
const ENDPOINTS = [
@@ -34,146 +35,137 @@ const ENDPOINTS = [
{ 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() {
return (
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: 14 }}>
<div className="about-page animate-fade-in">
<div className="page-header about-header">
<div>
<h2 className="page-title">{tr('关于 BossAI')}</h2>
<p className="page-subtitle">{tr('统一网关、模型路由、工作流与多模态助手的运行概览。')}</p>
</div>
<span className="about-version">v{VERSION}</span>
</div>
{/* Hero card */}
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
<section className="about-hero animate-slide-up stagger-1">
<div className="about-brand">
<img
src="/logo.png"
alt="BossAI"
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
className="about-logo"
/>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>BossAI</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
<div className="about-brand-name">BossAI</div>
<div className="about-brand-meta">{tr('自托管 AI 模型网关与工作台')}</div>
</div>
</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BossAI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
<span
key={tag}
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '5px 9px',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'var(--color-bg-secondary)',
}}
>
<p className="about-hero-copy">
{tr('BossAI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent、Workflow Studio 和外部 API 都能使用后台已经配置好的模型。')}
</p>
<div className="about-chip-row">
{['Rust Backend', 'OpenAI Compatible', 'Claude Messages', 'ComfyUI Frontend', 'Comfy Cloud', 'RunningHub'].map((tag) => (
<span key={tag} className="about-chip">
{tag}
</span>
))}
</div>
</div>
</section>
{/* Features */}
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 10,
}}>
<section className="about-section animate-slide-up stagger-2">
<div className="about-section-heading">
<h3>{tr('核心特色')}</h3>
<p>{tr('围绕网关、模型、工作流和运维审计组织能力。')}</p>
</div>
<div className="about-feature-grid">
{FEATURES.map((f) => (
<div key={f.title} style={{
display: 'flex', gap: 10, padding: '8px 0',
borderBottom: '1px solid var(--color-border-light)',
}}>
<span style={{
width: 34,
height: 26,
borderRadius: 8,
border: '1px solid var(--color-border-light)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
color: 'var(--color-primary)',
background: 'var(--color-bg-secondary)',
flexShrink: 0,
}}>
<div key={f.title} className="about-feature-row">
<span className="about-feature-mark">
{f.icon}
</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
<div className="about-feature-title">{tr(f.title)}</div>
<div className="about-feature-desc">
{tr(f.desc)}
</div>
</div>
</div>
))}
</div>
</div>
</section>
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3>
<div style={{ display: 'grid', gap: 9 }}>
<div className="about-two-column animate-slide-up stagger-3">
<section className="about-section">
<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) => (
<div
key={endpoint.path}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(190px, 260px) 1fr',
gap: 12,
alignItems: 'start',
padding: '9px 0',
borderBottom: '1px solid var(--color-border-light)',
}}
className="about-definition-row about-endpoint-row"
>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-primary)' }}>
<code>
{endpoint.path}
</code>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.6 }}>
<div>
{tr(endpoint.desc)}
</div>
</div>
))}
</div>
</section>
</div>
{/* Tech Stack */}
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
}}>
<section className="about-section animate-slide-up stagger-4">
<div className="about-section-heading">
<h3>{tr('技术栈')}</h3>
</div>
<div className="about-tech-grid">
{TECH_STACK.map((t) => (
<div key={t.name} style={{
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
<div key={t.name} className="about-tech-item">
<div className="about-tech-name">{t.name}</div>
<div className="about-tech-desc">
{tr(t.desc)}
</div>
</div>
))}
</div>
</div>
</section>
{/* Privacy */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
<section className="about-privacy animate-slide-up stagger-5">
<h3>{tr('数据与隐私')}</h3>
<p>
{tr('BossAI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
</div>
</div>
</p>
</section>
</div>
);
}
+16 -52
View File
@@ -17,11 +17,11 @@ const SiteTrendChart = lazy(
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 6) return "🌙 夜深了";
if (hour < 11) return "☀️ 早上好";
if (hour < 13) return "👋 中午好";
if (hour < 18) return "🌤️ 下午好";
return "🌙 晚上好";
if (hour < 6) return "夜深了";
if (hour < 11) return "早上好";
if (hour < 13) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
}
function safeNumber(value: unknown): number {
@@ -88,7 +88,7 @@ function formatAvailabilityPercent(value: number | null | undefined): string {
Number.isNaN(value) ||
!Number.isFinite(value)
)
return "—";
return "-";
return `${Math.round(value)}%`;
}
@@ -576,7 +576,7 @@ export default function Dashboard({
};
return (
<div className="animate-fade-in">
<div className="dashboard-page animate-fade-in">
<div
style={{
display: "flex",
@@ -969,25 +969,8 @@ export default function Dashboard({
</div>
{/* 站点级分析 */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
marginTop: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 14,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
<div className="dashboard-section-head">
<div className="dashboard-section-title">
<svg
width="16"
height="16"
@@ -1004,24 +987,12 @@ export default function Dashboard({
</svg>
站点分析
</div>
<div style={{ display: "flex", gap: 4 }}>
<div className="dashboard-range-tabs">
{[7, 30, 90].map((d) => (
<button
key={d}
onClick={() => setTrendDays(d)}
style={{
padding: "4px 12px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
border: "none",
cursor: "pointer",
background:
trendDays === d ? "var(--color-primary)" : "var(--color-bg)",
color:
trendDays === d ? "white" : "var(--color-text-secondary)",
transition: "all 0.2s ease",
}}
className={`dashboard-range-tab ${trendDays === d ? "is-active" : ""}`}
>
{d}天
</button>
@@ -1029,14 +1000,7 @@ export default function Dashboard({
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr",
gap: 16,
marginBottom: 24,
}}
>
<div className="dashboard-chart-grid">
<div className="chart-panel-enter animate-slide-up stagger-6">
<Suspense fallback={<ChartFallback height={320} />}>
<SiteDistributionChart
@@ -1190,7 +1154,7 @@ export default function Dashboard({
>
{site.averageLatencyMs != null
? `${site.averageLatencyMs}ms`
: "—"}
: "暂无"}
</span>
<span className="site-observability-metric-sep">·</span>
<span>{Math.round(site.totalRequests || 0)} 次</span>
@@ -1216,7 +1180,7 @@ export default function Dashboard({
`成功/失败:${bucket.successCount}/${bucket.failedCount}`,
bucket.averageLatencyMs != null
? `平均响应:${bucket.averageLatencyMs}ms`
: "平均响应:—",
: "平均响应:暂无",
].join(" · ")}
data-tooltip-align="start"
title={[
@@ -1227,7 +1191,7 @@ export default function Dashboard({
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
bucket.averageLatencyMs != null
? `平均响应 ${bucket.averageLatencyMs}ms`
: "平均响应 —",
: "平均响应 暂无",
].join(" | ")}
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
/>
@@ -1585,7 +1549,7 @@ export default function Dashboard({
<div style={{ fontSize: 18, fontWeight: 700 }}>
{proxy24hTotal > 0
? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}`
: "—"}
: "-"}
</div>
</div>
</div>
+120 -141
View File
@@ -1,9 +1,10 @@
import React, { CSSProperties, useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { fetchSessionResponse } from '../api.js';
import type { SessionAudience } from '../authSession.js';
type Mode = 'audio' | 'image' | 'text';
type Provider = 'BossAI' | 'runninghub';
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
@@ -49,8 +50,16 @@ function copyText(value: string) {
export default function DigitalHuman() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [provider, setProvider] = useState<Provider>('runninghub');
const [mode, setMode] = useState<Mode>('audio');
const [model, setModel] = useState(MODEL_PRESETS[0]);
const [runninghubWebappId, setRunninghubWebappId] = useState('');
const [runninghubApiKey, setRunninghubApiKey] = useState('');
const [runninghubInstanceType, setRunninghubInstanceType] = useState('default');
const [promptNodeId, setPromptNodeId] = useState('122');
const [scriptNodeId, setScriptNodeId] = useState('');
const [imageNodeId, setImageNodeId] = useState('');
const [audioNodeId, setAudioNodeId] = useState('');
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BossAI 的模型路由和工作流能力。');
const [script, setScript] = useState('大家好,欢迎使用 BossAI。今天我们演示如何用统一模型路由生成数字人口播视频。');
const [imageUrl, setImageUrl] = useState('');
@@ -68,6 +77,18 @@ export default function DigitalHuman() {
const [result, setResult] = useState<any>(null);
const bodyPreview = useMemo(() => ({
provider,
...(provider === 'runninghub' ? {
use_runninghub: true,
webappId: runninghubWebappId.trim(),
runninghub_api_key: runninghubApiKey.trim() || undefined,
instanceType: runninghubInstanceType,
prompt_node_id: promptNodeId.trim() || undefined,
script_node_id: scriptNodeId.trim() || undefined,
image_node_id: imageNodeId.trim() || undefined,
audio_node_id: audioNodeId.trim() || undefined,
script: script.trim(),
} : {}),
model: model.trim(),
prompt: [
prompt.trim(),
@@ -84,7 +105,7 @@ export default function DigitalHuman() {
ref_img_index: refImageIndex,
segmented: segments,
multi_person: multiPerson,
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
}), [audioCfg, audioNodeId, audioUrl, duration, imageNodeId, imageUrl, mode, model, multiPerson, prompt, promptNodeId, provider, ratio, refImageIndex, resolution, runninghubApiKey, runninghubInstanceType, runninghubWebappId, script, scriptNodeId, segments, voiceStyle]);
const submit = async () => {
setSubmitting(true);
@@ -108,11 +129,11 @@ export default function DigitalHuman() {
const taskId = extractTaskId(result);
return (
<div className="page">
<div className="digital-human-page animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">数字人</h1>
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BossAI 视频模型路由。</p>
<p className="page-subtitle">人物图、音频、台词、分段和多人数字人编排,底层走 BossAI 视频模型路由。</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
@@ -123,15 +144,70 @@ export default function DigitalHuman() {
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div style={layoutStyle}>
<section style={panelStyle}>
<div style={modeGridStyle}>
<div className="digital-human-layout">
<section className="digital-human-panel">
<div className="digital-human-mode-grid">
<button
type="button"
onClick={() => setProvider('runninghub')}
className={`digital-human-mode ${provider === 'runninghub' ? 'is-active' : ''}`}
>
<strong>RunningHub API</strong>
<span>调用 RunningHub AI 应用,适合数字人模板、口型和视频工作流。</span>
</button>
<button
type="button"
onClick={() => setProvider('BossAI')}
className={`digital-human-mode ${provider === 'BossAI' ? 'is-active' : ''}`}
>
<strong>BossAI 路由</strong>
<span>走本地 /v1/videos 中转,由模型路由选择视频上游。</span>
</button>
</div>
{provider === 'runninghub' && (
<div className="digital-human-form-grid">
<label className="digital-human-field">
<span>RunningHub WebApp ID</span>
<input value={runninghubWebappId} onChange={(event) => setRunninghubWebappId(event.target.value)} placeholder="例如 1877265245566922753" />
</label>
<label className="digital-human-field">
<span>API Key(可选)</span>
<input value={runninghubApiKey} onChange={(event) => setRunninghubApiKey(event.target.value)} placeholder="留空使用 config.toml 的 comfyui.api_key" />
</label>
<label className="digital-human-field">
<span>算力规格</span>
<select value={runninghubInstanceType} onChange={(event) => setRunninghubInstanceType(event.target.value)}>
<option value="default">default 24G</option>
<option value="plus">plus 48G</option>
</select>
</label>
<label className="digital-human-field">
<span>提示词节点 ID</span>
<input value={promptNodeId} onChange={(event) => setPromptNodeId(event.target.value)} placeholder="如 122" />
</label>
<label className="digital-human-field">
<span>台词节点 ID</span>
<input value={scriptNodeId} onChange={(event) => setScriptNodeId(event.target.value)} placeholder="模板需要独立台词节点时填写" />
</label>
<label className="digital-human-field">
<span>人物图节点 ID</span>
<input value={imageNodeId} onChange={(event) => setImageNodeId(event.target.value)} placeholder="模板需要图片输入时填写" />
</label>
<label className="digital-human-field">
<span>音频节点 ID</span>
<input value={audioNodeId} onChange={(event) => setAudioNodeId(event.target.value)} placeholder="模板需要音频输入时填写" />
</label>
</div>
)}
<div className="digital-human-mode-grid">
{MODE_OPTIONS.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setMode(item.value)}
style={mode === item.value ? activeModeStyle : modeStyle}
className={`digital-human-mode ${mode === item.value ? 'is-active' : ''}`}
>
<strong>{item.label}</strong>
<span>{item.description}</span>
@@ -139,90 +215,95 @@ export default function DigitalHuman() {
))}
</div>
<div style={formGridStyle}>
<label style={fieldStyle}>
<div className="digital-human-form-grid">
<label className="digital-human-field">
<span>模型</span>
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
<input
value={provider === 'runninghub' ? runninghubWebappId : model}
onChange={(event) => provider === 'runninghub' ? setRunninghubWebappId(event.target.value) : setModel(event.target.value)}
list={provider === 'runninghub' ? undefined : 'digital-human-models'}
placeholder={provider === 'runninghub' ? 'RunningHub WebApp ID' : undefined}
/>
<datalist id="digital-human-models">
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
</datalist>
</label>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
<option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select>
</label>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="480p">480p</option>
</select>
</label>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>时长(秒)</span>
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} />
</label>
</div>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>人物/场景描述</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} />
</label>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>台词/脚本</span>
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} />
</label>
<div style={formGridStyle}>
<div className="digital-human-form-grid">
{mode !== 'text' && (
<label style={fieldStyle}>
<label className="digital-human-field">
<span>人物图 URL</span>
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
</label>
)}
{mode === 'audio' && (
<label style={fieldStyle}>
<label className="digital-human-field">
<span>音频 URL</span>
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." />
</label>
)}
<label style={fieldStyle}>
<label className="digital-human-field">
<span>口播风格</span>
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)}>
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>ref_img_index</span>
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} />
</label>
<label style={fieldStyle}>
<label className="digital-human-field">
<span>audio CFG</span>
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} />
</label>
</div>
<div style={toggleRowStyle}>
<div className="digital-human-toggle-row">
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
</div>
</section>
<aside style={panelStyle}>
<h2 style={sideTitleStyle}>任务请求</h2>
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
<aside className="digital-human-panel digital-human-side">
<h2>任务请求</h2>
<pre>{JSON.stringify(bodyPreview, null, 2)}</pre>
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
<h2>返回结果</h2>
{result ? (
<>
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
{taskId && <div className="digital-human-task-id">任务 ID:{taskId}</div>}
<pre>{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>
</>
) : (
@@ -236,105 +317,3 @@ export default function DigitalHuman() {
</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,
};
+35 -6
View File
@@ -18,6 +18,7 @@ import DownstreamKeyEditorModal, {
} from './downstream-keys/DownstreamKeyEditorModal.js';
import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js';
import {
BillingLimitBar,
formatCompactTokens,
formatIso,
formatMoney,
@@ -44,6 +45,22 @@ type DownstreamApiKeyItem = {
usedCost: number;
maxRequests: number | null;
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[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
@@ -1087,6 +1104,7 @@ export default function DownstreamKeys() {
<SummaryMetric label="已选中" value={String(selectedIds.length)} />
<SummaryMetric label="请求数" value={totals.requests.toLocaleString()} />
<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 || '全部状态'} />
</div>
</div>
@@ -1192,7 +1210,16 @@ export default function DownstreamKeys() {
<MobileField label="模型" value={summarizeModelLimit(row.supportedModels || [])} stacked />
<MobileField label="群组" value={summarizeRouteLimit(row.allowedRouteIds || [], routeMap)} 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={formatIso(row.lastUsedAt)} stacked />
</MobileCard>
@@ -1209,7 +1236,7 @@ export default function DownstreamKeys() {
</th>
<th>密钥信息</th>
<th>授权范围</th>
<th style={{ textAlign: 'right' }}>额度</th>
<th style={{ minWidth: 220 }}>计费额度</th>
<th style={{ textAlign: 'right' }}>用量</th>
<th>最近使用</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>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>{row.maxCost == null ? '成本不限' : `成本 ${formatMoney(row.maxCost)}`}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>{row.expiresAt ? `到期 ${formatIso(row.expiresAt)}` : '永久有效'}</div>
<td style={{ fontVariantNumeric: 'tabular-nums', minWidth: 220 }}>
<div style={{ display: 'grid', gap: 10 }}>
<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 style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{row.expiresAt ? `到期 ${formatIso(row.expiresAt)}` : '永久有效'}</div>
</div>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatCompactTokens(row.rangeUsage?.totalTokens || 0)}</div>
+9 -9
View File
@@ -94,7 +94,7 @@ function getLatencyBadgeClass(latency: number | null) {
}
function formatLatency(latency: number | null): string {
return isKnownLatency(latency) ? `${latency}ms` : '—';
return isKnownLatency(latency) ? `${latency}ms` : '暂无';
}
function getSuccessBadgeClass(rate: number | null) {
@@ -710,7 +710,7 @@ export default function Models() {
style={{ fontVariantNumeric: 'tabular-nums' }}
data-tooltip={tr('成功率')}
>
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '—'}
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '暂无'}
</span>
</div>
</div>
@@ -847,7 +847,7 @@ export default function Models() {
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
<span style={{ color: 'var(--color-text-muted)' }}>{tr('延迟')}</span>
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums' }}>
{a.latency != null ? `${a.latency}ms` : '—'}
{a.latency != null ? `${a.latency}ms` : '暂无'}
</span>
</div>
<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' }}>
{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 style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>—</span>}
)) : <span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>暂无</span>}
</div>
</div>
</div>
@@ -885,12 +885,12 @@ export default function Models() {
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{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 style={{ color: 'var(--color-text-muted)' }}>—</span>}
)) : <span style={{ color: 'var(--color-text-muted)' }}>暂无</span>}
</td>
<td>
{a.latency != null ? (
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
) : '—'}
) : '暂无'}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
</tr>
@@ -964,7 +964,7 @@ export default function Models() {
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
style={{ fontSize: 12, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
>
{m.successRate != null ? `${m.successRate}%` : '—'}
{m.successRate != null ? `${m.successRate}%` : '暂无'}
</span>
</td>
<td onClick={e => e.stopPropagation()}>
@@ -1058,10 +1058,10 @@ export default function Models() {
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-info'}`}>{t.name}</span>
)) : '—'}
)) : '暂无'}
</td>
<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 style={{ padding: 8 }}>${(a.balance || 0).toFixed(2)}</td>
</tr>
+8 -1
View File
@@ -410,7 +410,14 @@ export default function TokenRoutes() {
}
const createdRoutes = res?.rebuild?.createdRoutes ?? 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();
await load();
} catch (e: any) {
+129 -99
View File
@@ -29,12 +29,32 @@ interface UserSummary {
interface LogItem {
id: number;
model_requested: string;
model_requested?: string;
model_actual?: string;
status: string;
latency_ms: number;
latency_ms?: number | null;
estimated_cost?: number | null;
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 }) {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
@@ -57,44 +77,23 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
setLoading(true);
setError('');
try {
const profileRes = await userApi.me();
setProfile(profileRes.user);
const dashboardRes: any = await userApi.getDashboard();
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) {
setError(err?.message || '加载失败');
if (String(err?.message || '').includes('401') || String(err?.message || '').includes('Unauthorized')) {
clearSession('user', localStorage);
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([]);
}
if (summaryRes.status === 'fulfilled') {
setSummary(summaryRes.value as UserSummary);
} else {
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
}
if (logsRes.status === 'fulfilled') {
const payload: any = logsRes.value;
setRecentLogs(Array.isArray(payload?.items) ? payload.items : []);
} else {
setRecentLogs([]);
}
} finally {
setLoading(false);
}
}, [checkAuth, navigate]);
useEffect(() => { loadData(); }, [loadData]);
@@ -107,16 +106,26 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', minHeight: '400px' }}>
<div className="spinner" />
<div className="user-dashboard-page">
<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>
);
}
if (!profile) {
return (
<div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<div className="card" style={{ padding: 20, color: 'var(--color-danger)' }}>
<div className="user-dashboard-page">
<div className="alert alert-error">
{error || '用户信息加载失败'}
</div>
</div>
@@ -126,69 +135,90 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
const successRate = summary && summary.totalRequests > 0
? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1)
: '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 (
<div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<header style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px 0' }}>你好, {profile.username}</h1>
<p style={{ color: 'var(--color-text-muted)', margin: 0 }}>欢迎回到 BossAI 用户中心。</p>
<div className="user-dashboard-page animate-fade-in">
<header className="user-dashboard-hero">
<div className="user-dashboard-identity">
<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>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '32px' }}>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总请求次数</div>
<div style={{ fontSize: '28px', fontWeight: 700 }}>{summary?.totalRequests || 0}</div>
<div className="user-dashboard-stat-grid">
<div className="card user-dashboard-stat">
<div className="user-dashboard-stat-label">总请求次数</div>
<div className="user-dashboard-stat-value">{summary?.totalRequests || 0}</div>
<div className="user-dashboard-stat-note">{failedRequests} 次失败</div>
</div>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>请求成功率</div>
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--color-success)' }}>{successRate}%</div>
<div className="card user-dashboard-stat">
<div className="user-dashboard-stat-label">请求成功率</div>
<div className="user-dashboard-stat-value is-success">{successRate}%</div>
<div className="user-dashboard-stat-note">{summary?.successRequests || 0} 次成功</div>
</div>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总消耗金额</div>
<div style={{ fontSize: '28px', fontWeight: 700 }}>${summary?.totalCost ? summary.totalCost.toFixed(4) : '0.0000'}</div>
<div className="card user-dashboard-stat">
<div className="user-dashboard-stat-label">总消耗金额</div>
<div className="user-dashboard-stat-value">{formatMoney(summary?.totalCost)}</div>
<div className="user-dashboard-stat-note">{activeKeyCount}/{keys.length} 个密钥启用</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
{/* Left Column: Recent Logs & Quick Entries */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{/* Quick Entries */}
<section className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<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' }}>
<div className="user-dashboard-grid">
<div className="user-dashboard-column">
<section className="card user-dashboard-panel">
<div className="user-dashboard-panel-head">
<h2>快速入口</h2>
</div>
<div className="user-dashboard-entry-grid">
<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>
<span>工作流</span>
</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>
<span>视频助手</span>
</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>
</section>
{/* Recent Records */}
<section className="card" style={{ padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>最近记录</h2>
<Link to="/user/usage" style={{ fontSize: '13px', color: 'var(--color-primary)', textDecoration: 'none' }}>查看全部</Link>
<section className="card user-dashboard-panel">
<div className="user-dashboard-panel-head">
<h2>最近记录</h2>
<Link to="/user/usage" className="btn btn-link btn-link-primary">查看全部</Link>
</div>
{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 => (
<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 style={{ fontSize: '14px', fontWeight: 500 }}>{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-title">{log.model_actual || log.model_requested || '未知模型'}</div>
<div className="user-dashboard-row-meta">{formatDateLabel(log.created_at)}</div>
</div>
<div style={{ textAlign: 'right' }}>
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
<div className="user-dashboard-row-side">
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}>
{log.status === 'success' ? '成功' : '失败'}
</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>
))}
@@ -197,59 +227,59 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
</section>
</div>
{/* Right Column: API Keys */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<section className="card" style={{ padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>API 密钥</h2>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
<div className="user-dashboard-column">
<section className="card user-dashboard-panel">
<div className="user-dashboard-panel-head">
<h2>API 密钥</h2>
<button className="btn btn-primary" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div className="user-dashboard-list">
{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 => (
<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 style={{ fontSize: '14px', fontWeight: 500 }}>{key.name}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
{key.key ? `${key.key.slice(0, 12)}...` : '********'}
<div className="user-dashboard-row-title">{key.name}</div>
<div className="user-dashboard-key-value">
{key.key ? `${key.key.slice(0, 12)}...` : '••••••••'}
</div>
</div>
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`}>
{key.enabled ? '启用' : '禁用'}
</span>
</div>
))
)}
{keys.length > 3 && (
<div style={{ textAlign: 'center', fontSize: '12px', color: 'var(--color-text-muted)' }}>
还有 {keys.length - 3} 个密钥...
<div className="user-dashboard-more">
还有 {keys.length - 3} 个密钥
</div>
)}
</div>
</section>
{/* Account Info */}
<section className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>账号信息</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted)' }}>邮箱</span>
<section className="card user-dashboard-panel">
<div className="user-dashboard-panel-head">
<h2>账号信息</h2>
</div>
<div className="user-dashboard-profile-list">
<div>
<span>邮箱</span>
<span>{profile.email}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted)' }}>账号角色</span>
<div>
<span>账号角色</span>
<span className="badge badge-success">{profile.role === 'admin' ? '管理员' : '普通用户'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted)' }}>注册时间</span>
<div>
<span>注册时间</span>
<span>{new Date(profile.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={handleLogout}>退出登录</button>
<div className="user-dashboard-profile-actions">
<button className="btn btn-ghost" onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
<button className="btn btn-ghost btn-link-danger" onClick={handleLogout}>退出登录</button>
</div>
</section>
</div>
+54 -68
View File
@@ -9,9 +9,11 @@ const DOCKER_WORKFLOW_URL = '/api/comfyui/proxy/';
export default function Workflows() {
const [ready, setReady] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [message, setMessage] = React.useState('正在加载工作流前端...');
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 openFullscreen = () => {
@@ -20,8 +22,13 @@ export default function Workflows() {
const reloadFrame = () => {
if (iframeRef.current) {
setLoading(true);
iframeRef.current.src = workflowUrl;
return;
}
setLoading(true);
setMessage('正在重新检查工作流入口...');
setReloadKey((value) => value + 1);
};
React.useEffect(() => {
@@ -30,57 +37,71 @@ export default function Workflows() {
const loadRuntime = async () => {
const runtimeResponse = await fetch('/api/workflow-ui/runtime', { signal: controller.signal });
const runtime = runtimeResponse.ok ? ((await runtimeResponse.json()) as WorkflowRuntime) : {};
const cloudMode = runtime.mode === 'cloud';
const dockerMode = runtime.mode === 'docker';
const targetUrl = dockerMode ? DOCKER_WORKFLOW_URL : LOCAL_WORKFLOW_URL;
if (cancelled) return;
setIsCloud(cloudMode);
setBackendLabel(resolveBackendLabel(runtime.mode));
setWorkflowUrl(targetUrl);
const response = await fetch(targetUrl, { signal: controller.signal });
if (cancelled) return;
setReady(response.ok);
setLoading(false);
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
};
void loadRuntime().catch((error) => {
if (cancelled || error?.name === 'AbortError') return;
setReady(false);
setLoading(false);
setMessage('工作流入口不可用,请检查服务配置');
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
}, [reloadKey]);
return (
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>工作流</div>
{!isCloud && workflowUrl === DOCKER_WORKFLOW_URL && <div style={cloudBadgeStyle}>Docker 完整版 ComfyUI</div>}
{isCloud && <div style={cloudBadgeStyle}>Comfy Cloud API 后端</div>}
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
<div className="workflow-shell">
<div className="workflow-toolbar">
<div className="workflow-toolbar-main">
<div className="workflow-title">工作流</div>
{backendLabel && <div className="workflow-backend-badge">{backendLabel}</div>}
</div>
<div className="workflow-actions">
<button type="button" onClick={reloadFrame} disabled={!ready} className="btn btn-link workflow-action-btn">
刷新
</button>
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
<button type="button" onClick={openFullscreen} className="btn btn-primary workflow-primary-btn">
新窗口打开
</button>
</div>
</div>
{ready ? (
<div className="workflow-frame-wrap">
{loading && (
<div className="workflow-frame-loading" aria-live="polite">
<div className="workflow-loading-bar" />
<span>正在连接工作流前端...</span>
</div>
)}
<iframe
ref={iframeRef}
src={workflowUrl}
title="Workflow Studio"
allow="clipboard-read; clipboard-write"
referrerPolicy="no-referrer"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
onLoad={() => setLoading(false)}
className="workflow-frame"
/>
</div>
) : (
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
<div className="workflow-empty">
<div className="workflow-empty-panel">
<div className="workflow-empty-title">Workflow Studio</div>
<div className="workflow-empty-desc">{message}</div>
<button type="button" onClick={reloadFrame} className="btn btn-soft-primary workflow-empty-btn">
重新检查
</button>
</div>
</div>
)}
@@ -88,54 +109,19 @@ export default function Workflows() {
);
}
const toolbarStyle: React.CSSProperties = {
minHeight: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid var(--color-border-light)',
background: 'var(--color-surface)',
};
const cloudBadgeStyle: React.CSSProperties = {
marginRight: 'auto',
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '3px 8px',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 700,
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const toolbarActionsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const toolbarButtonStyle: React.CSSProperties = {
minHeight: 30,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '5px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: 12,
};
const primaryToolbarButtonStyle: React.CSSProperties = {
...toolbarButtonStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};
function resolveBackendLabel(mode?: string) {
switch (mode) {
case 'cloud':
return 'Comfy Cloud API 后端';
case 'runninghub':
return 'RunningHub API 后端';
case 'external':
return '外部 ComfyUI API 后端';
case 'docker':
return 'Docker 完整版 ComfyUI';
case 'boosapi':
return 'BoosAPI 工作流执行器';
default:
return '';
}
}
+146 -324
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
import type { SessionAudience } from '../authSession.js';
@@ -34,6 +34,7 @@ function formatBytes(value?: number) {
}
function sourceLabel(value: string) {
if (value === 'workflow-ui') return '工作流';
return SOURCE_OPTIONS.find((item) => item.value === value)?.label || value || '未知来源';
}
@@ -86,10 +87,18 @@ export default function Workspace() {
const [previewLoading, setPreviewLoading] = useState<Record<string, boolean>>({});
const [loadedLocalPreviews, setLoadedLocalPreviews] = useState<Set<string>>(() => new Set());
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 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 counts = new Map<string, number>();
items.forEach((item) => counts.set(item.source, (counts.get(item.source) || 0) + 1));
@@ -109,6 +118,7 @@ export default function Workspace() {
useEffect(() => {
setPage(1);
setSelectedIds([]);
}, [kind, query, source]);
const load = useCallback(async () => {
@@ -216,6 +226,7 @@ export default function Workspace() {
} else {
await api.deleteWorkspaceAsset(asset.publicId);
}
setSelectedIds((current) => current.filter((publicId) => publicId !== asset.publicId));
await load();
} catch (err: any) {
setError(err?.message || '删除失败');
@@ -251,23 +262,64 @@ export default function Workspace() {
}
};
const toggleSelectedAsset = useCallback((publicId: string) => {
setSelectedIds((current) => (
current.includes(publicId)
? current.filter((item) => item !== publicId)
: [...current, publicId]
));
}, []);
const toggleSelectVisible = useCallback(() => {
setSelectedIds((current) => {
const visibleSet = new Set(visibleIds);
if (allVisibleSelected) {
return current.filter((publicId) => !visibleSet.has(publicId));
}
const next = new Set(current);
visibleIds.forEach((publicId) => next.add(publicId));
return Array.from(next);
});
}, [allVisibleSelected, visibleIds]);
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return;
if (!window.confirm(`删除已选的 ${selectedIds.length} 个成品?`)) return;
try {
const response = audience === 'user'
? await userApi.batchDeleteWorkspaceAssets(selectedIds)
: await api.batchDeleteWorkspaceAssets(selectedIds);
const deletedIds = Array.isArray(response.deletedIds) ? response.deletedIds : [];
setSelectedIds((current) => current.filter((publicId) => !deletedIds.includes(publicId)));
if (selectedId && deletedIds.includes(selectedId)) {
setSelectedId('');
}
if (response.failedIds?.length) {
setError(`已删除 ${response.deletedCount || deletedIds.length} 个,${response.failedIds.length} 个未找到或无权限`);
}
await load();
} catch (err: any) {
setError(err?.message || '批量删除失败');
}
};
const renderPreview = (asset: WorkspaceAsset, compact = false) => {
const 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="" style={mediaStyle} loading="lazy" />;
return <img src={preview} alt="" className="workspace-media" loading="lazy" />;
}
if (asset.kind === 'video' && preview) {
return <video src={preview} style={mediaStyle} muted controls={!compact} preload="metadata" />;
return <video src={preview} className="workspace-media" muted controls={!compact} preload="metadata" />;
}
if (asset.kind === 'audio' && preview) {
return compact ? <div style={placeholderStyle}>音频</div> : <audio src={preview} controls style={{ width: '100%' }} />;
return compact ? <div className="workspace-placeholder">音频</div> : <audio src={preview} controls className="workspace-audio" />;
}
return (
<div style={placeholderStyle}>
<div className="workspace-placeholder">
<span>{kindLabel(asset.kind)}</span>
{asset.hasLocalFile && canRenderPreview(asset) ? (
<span style={placeholderHintStyle}>
<span className="workspace-placeholder-hint">
{isLargeLocal ? '文件较大,按需预览' : '等待预览'}
</span>
) : null}
@@ -278,12 +330,12 @@ export default function Workspace() {
const hasMore = items.length < total;
return (
<div>
<div className="workspace-page animate-fade-in">
<div className="page-header">
<div>
<h1 className="page-title">工作空间</h1>
<p className="page-subtitle">
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'},只归档最终产出。工作流上传素材和预置模板资源不会进入这里。
</p>
</div>
<div className="page-actions">
@@ -293,52 +345,52 @@ export default function Workspace() {
</div>
</div>
<div style={summaryStyle}>
<div style={summaryItemStyle}>
<span style={summaryLabelStyle}>当前结果</span>
<strong style={summaryValueStyle}>{total}</strong>
<div className="workspace-summary">
<div className="workspace-summary-item">
<span className="workspace-summary-label">当前结果</span>
<strong className="workspace-summary-value">{total}</strong>
</div>
<div style={summaryItemStyle}>
<span style={summaryLabelStyle}>本地文件</span>
<strong style={summaryValueStyle}>{localCount}</strong>
<div className="workspace-summary-item">
<span className="workspace-summary-label">本地文件</span>
<strong className="workspace-summary-value">{localCount}</strong>
</div>
<div style={summaryItemStyle}>
<span style={summaryLabelStyle}>主要来源</span>
<strong style={{ ...summaryValueStyle, fontSize: 14 }}>
{sourceStats.length > 0 ? sourceStats.map(([name, count]) => `${sourceLabel(name)} ${count}`).join(' · ') : '暂无'}
<div className="workspace-summary-item">
<span className="workspace-summary-label">主要来源</span>
<strong className="workspace-summary-value workspace-summary-value-small">
{sourceStats.length > 0 ? sourceStats.map(([name, count]) => `${sourceLabel(name)} ${count}`).join(' / ') : '暂无'}
</strong>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div style={layoutStyle}>
<div className="workspace-layout">
<section>
<div style={toolbarStyle}>
<div className="workspace-toolbar">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索标题、提示词、模型或来源"
style={inputStyle}
className="workspace-search"
/>
<div style={filterRowStyle}>
<div className="workspace-filter-row">
{SOURCE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
style={source === option.value ? activeChipStyle : chipStyle}
className={`workspace-chip ${source === option.value ? 'is-active' : ''}`}
onClick={() => setSource(option.value)}
>
{option.label}
</button>
))}
</div>
<div style={filterRowStyle}>
<div className="workspace-filter-row">
{KIND_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
style={kind === option.value ? activeChipStyle : chipStyle}
className={`workspace-chip ${kind === option.value ? 'is-active' : ''}`}
onClick={() => setKind(option.value)}
>
{option.label}
@@ -347,7 +399,32 @@ export default function Workspace() {
</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 ? (
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
@@ -356,38 +433,51 @@ export default function Workspace() {
) : items.map((asset) => {
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
const active = selected?.publicId === asset.publicId;
const checked = selectedIdSet.has(asset.publicId);
return (
<button
type="button"
<article
key={asset.publicId}
style={{
...cardStyle,
borderColor: active ? 'var(--color-primary)' : 'var(--color-border)',
boxShadow: active ? '0 14px 32px color-mix(in srgb, var(--color-primary) 16%, transparent)' : undefined,
}}
className={`workspace-asset-card ${active ? 'is-active' : ''} ${checked ? 'is-selected' : ''}`}
role="button"
tabIndex={0}
onClick={() => setSelectedId(asset.publicId)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedId(asset.publicId);
}
}}
>
<div style={thumbStyle}>
<div className="workspace-thumb">
<label className="workspace-select" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelectedAsset(asset.publicId)}
aria-label={`选择 ${assetTitle(asset)}`}
/>
<span className="workspace-select-mark" aria-hidden="true">✓</span>
</label>
{renderPreview(asset, true)}
<span style={typeBadgeStyle}>{kindLabel(asset.kind)}</span>
<span className="workspace-type-badge">{kindLabel(asset.kind)}</span>
{asset.hasLocalFile && canRenderPreview(asset) && !preview && (
<span style={previewBadgeStyle}>{formatBytes(asset.sizeBytes)}</span>
<span className="workspace-preview-badge">{formatBytes(asset.sizeBytes)}</span>
)}
</div>
<div style={cardBodyStyle}>
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
<div style={cardFooterStyle}>
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
<div className="workspace-card-body">
<strong className="workspace-card-title">{assetTitle(asset)}</strong>
<span className="workspace-meta">{sourceLabel(asset.source)} / {asset.model || '未记录模型'}</span>
<div className="workspace-card-footer">
<span className="workspace-status-pill">{statusLabel(asset.status)}</span>
<span className="workspace-meta">{assetCreatedAtLabel(asset)}</span>
</div>
</div>
</button>
</article>
);
})}
</div>
{hasMore && (
<div style={loadMoreStyle}>
<div className="workspace-load-more">
<button
type="button"
className="btn btn-ghost"
@@ -400,31 +490,30 @@ export default function Workspace() {
)}
</section>
<aside style={detailStyle}>
<aside className="workspace-detail">
{selected ? (
<>
<div style={detailHeaderStyle}>
<div className="workspace-detail-header">
<div>
<div style={detailEyebrowStyle}>{sourceLabel(selected.source)} · {kindLabel(selected.kind)}</div>
<h2 style={{ margin: '6px 0 0', fontSize: 18 }}>{assetTitle(selected)}</h2>
<div className="workspace-detail-kicker">{sourceLabel(selected.source)} / {kindLabel(selected.kind)}</div>
<h2 className="workspace-detail-title">{assetTitle(selected)}</h2>
</div>
<span style={statusPillStyle}>{statusLabel(selected.status)}</span>
<span className="workspace-status-pill">{statusLabel(selected.status)}</span>
</div>
<div style={detailPreviewStyle}>
<div className="workspace-detail-preview">
{renderPreview(selected)}
</div>
{selected.hasLocalFile && canRenderPreview(selected) && !loadedLocalPreviews.has(selected.publicId) && (
<button
type="button"
className="btn btn-ghost"
style={{ width: '100%', marginTop: 10 }}
className="btn btn-ghost workspace-preview-load"
onClick={() => void loadPreview(selected)}
disabled={Boolean(previewLoading[selected.publicId])}
>
{previewLoading[selected.publicId] ? '预览加载中...' : `加载预览(${formatBytes(selected.sizeBytes)})`}
</button>
)}
<div style={detailMetaStyle}>
<div className="workspace-detail-meta">
{audience !== 'user' && (
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
)}
@@ -434,12 +523,12 @@ export default function Workspace() {
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
</div>
{selected.prompt && (
<div style={promptPanelStyle}>
<div className="workspace-prompt-panel">
<span>提示词</span>
<p style={promptStyle}>{selected.prompt}</p>
<p className="workspace-prompt">{selected.prompt}</p>
</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>
@@ -459,270 +548,3 @@ export default function Workspace() {
</div>
);
}
const toolbarStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 12,
marginBottom: 16,
padding: 14,
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-bg-card)',
};
const inputStyle: CSSProperties = {
height: 40,
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: '0 12px',
background: 'var(--color-bg-card)',
color: 'var(--color-text-primary)',
};
const layoutStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(300px, 380px)',
gap: 16,
alignItems: 'start',
};
const gridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
gap: 14,
};
const cardStyle: CSSProperties = {
textAlign: 'left',
border: '1px solid var(--color-border)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--color-bg-card)',
color: 'var(--color-text-primary)',
padding: 0,
cursor: 'pointer',
transition: 'border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease',
};
const thumbStyle: CSSProperties = {
position: 'relative',
aspectRatio: '16 / 11',
background: 'var(--color-bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
};
const mediaStyle: CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'contain',
};
const placeholderStyle: CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
color: 'var(--color-text-muted)',
fontWeight: 700,
textAlign: 'center',
background: 'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), var(--color-bg-card))',
};
const placeholderHintStyle: CSSProperties = {
fontSize: 12,
fontWeight: 500,
color: 'var(--color-text-muted)',
};
const cardBodyStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: 12,
};
const cardTitleStyle: CSSProperties = {
fontSize: 13,
lineHeight: 1.35,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const metaStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const detailStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-bg-card)',
padding: 16,
position: 'sticky',
top: 84,
};
const detailPreviewStyle: CSSProperties = {
aspectRatio: '16 / 11',
borderRadius: 6,
overflow: 'hidden',
background: 'var(--color-bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const detailMetaStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 10,
fontSize: 12,
color: 'var(--color-text-muted)',
marginTop: 14,
};
const promptStyle: CSSProperties = {
margin: '6px 0 0',
color: 'var(--color-text-secondary)',
fontSize: 13,
lineHeight: 1.6,
maxHeight: 160,
overflow: 'auto',
};
const summaryStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: '160px 160px minmax(0, 1fr)',
gap: 12,
marginBottom: 14,
};
const summaryItemStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-bg-card)',
padding: '12px 14px',
minHeight: 70,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
};
const summaryLabelStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const summaryValueStyle: CSSProperties = {
fontSize: 22,
color: 'var(--color-text-primary)',
lineHeight: 1.2,
};
const filterRowStyle: CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 8,
};
const chipStyle: CSSProperties = {
border: '1px solid var(--color-border)',
borderRadius: 999,
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
padding: '7px 12px',
cursor: 'pointer',
fontSize: 12,
};
const activeChipStyle: CSSProperties = {
...chipStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary-light)',
color: 'var(--color-primary)',
fontWeight: 700,
};
const typeBadgeStyle: CSSProperties = {
position: 'absolute',
left: 10,
top: 10,
borderRadius: 999,
padding: '4px 8px',
background: 'rgba(0, 0, 0, 0.56)',
color: '#fff',
fontSize: 12,
fontWeight: 700,
};
const previewBadgeStyle: CSSProperties = {
position: 'absolute',
right: 10,
bottom: 10,
borderRadius: 999,
padding: '4px 8px',
background: 'rgba(0, 0, 0, 0.5)',
color: '#fff',
fontSize: 12,
fontWeight: 700,
};
const loadMoreStyle: CSSProperties = {
display: 'flex',
justifyContent: 'center',
marginTop: 18,
};
const cardFooterStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
};
const statusPillStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '4px 8px',
background: 'color-mix(in srgb, var(--color-success) 12%, var(--color-bg-card))',
color: 'var(--color-success)',
fontSize: 12,
fontWeight: 700,
whiteSpace: 'nowrap',
};
const detailHeaderStyle: CSSProperties = {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
marginBottom: 12,
};
const detailEyebrowStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const promptPanelStyle: CSSProperties = {
marginTop: 14,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: 12,
background: 'var(--color-bg)',
fontSize: 12,
color: 'var(--color-text-muted)',
};
@@ -5,6 +5,7 @@ import { useToast } from '../../components/Toast.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { readClientTimeZone } from '../helpers/siteAnnouncementPresentation.js';
import {
BillingLimitBar,
formatCompactTokens,
formatIso,
formatMoney,
@@ -163,12 +164,15 @@ export default function DownstreamKeyDrawer({
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
基础信息
计费与额度
</div>
{overviewLoading ? (
<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 }}>
<BillingLimitBar label="请求额度" used={item?.usedRequests || 0} max={item?.maxRequests} formatter={(value) => Math.trunc(value).toLocaleString()} />
<BillingLimitBar label="成本额度" used={item?.usedCost || 0} max={item?.maxCost} formatter={formatMoney} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
@@ -189,6 +193,7 @@ export default function DownstreamKeyDrawer({
<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 style={{ gridColumn: '1 / -1' }}>
<div style={{ color: 'var(--color-text-muted)', marginBottom: 6 }}>标签</div>
<TagChips tags={item?.tags || []} accent maxVisible={6} />
+57
View File
@@ -45,6 +45,19 @@ export type AggregateUsage = {
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 = {
success: boolean;
item: SummaryItem;
@@ -167,3 +180,47 @@ export function StatusBadge({ enabled }: { enabled: boolean }) {
</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>
);
}