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